Write

import type { NextApiRequest, NextApiResponse } from "next";
import { turso } from "@/lib/turso";
import { checkAuth } from "@/lib/auth";

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (!checkAuth(req)) return res.status(401).json({ error: "not logged in" });

  const [countResult, segResult] = await Promise.all([
    turso.execute("SELECT status, COUNT(*) as c FROM jobs GROUP BY status"),
    turso.execute(
      "SELECT COALESCE(SUM(num_clean_segments),0) as total_clean, " +
      "COALESCE(SUM(num_overlap_segments),0) as total_overlap, " +
      "COALESCE(SUM(num_overlap_cleaned),0) as total_cleaned FROM jobs"
    ),
  ]);

  const counts: Record<string, number> = {};
  for (const r of countResult.rows as any[]) counts[r.status] = Number(r.c);
  const seg = segResult.rows[0] as any;

  // Vast.ai running instances (optional — needs VAST_API_KEY env var)
  let gpuPods: any = null;
  const vastKey = process.env.VAST_API_KEY;
  if (vastKey) {
    try {
      const r = await fetch(
        `https://console.vast.ai/api/v0/instances/?owner=me&api_key=${vastKey}`,
        { signal: AbortSignal.timeout(8000) }
      );
      if (r.ok) {
        const data = await r.json();
        const instances = data.instances || [];
        gpuPods = {
          total: instances.length,
          running: instances.filter((i: any) => i.actual_status === "running").length,
          instances: instances.map((i: any) => ({
            id: i.id,
            status: i.actual_status || i.intended_status,
            gpu: i.gpu_name,
            label: i.label,
            cost: i.dph_total,
          })),
        };
      }
    } catch {
      // vast.ai unreachable — skip
    }
  }

  // Active workers (jobs with worker_id set)
  const activeResult = await turso.execute(
    "SELECT name, status, worker_id FROM jobs WHERE worker_id IS NOT NULL"
  );

  return res.json({
    counts,
    totalClean: Number(seg.total_clean),
    totalOverlap: Number(seg.total_overlap),
    totalCleaned: Number(seg.total_cleaned),
    activeWorkers: activeResult.rows.length,
    gpuPods,
  });
}


File created successfully at: /home/ubuntu/karthyani_pipeline/dashboard/pages/api/stats.ts

import type { NextApiRequest, NextApiResponse } from "next";
import { turso } from "@/lib/turso";
import { checkAuth } from "@/lib/auth";

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (!checkAuth(req)) return res.status(401).json({ error: "not logged in" });

  const [countResult, segResult] = await Promise.all([
    turso.execute("SELECT status, COUNT(*) as c FROM jobs GROUP BY status"),
    turso.execute(
      "SELECT COALESCE(SUM(num_clean_segments),0) as total_clean, " +
      "COALESCE(SUM(num_overlap_segments),0) as total_overlap, " +
      "COALESCE(SUM(num_overlap_cleaned),0) as total_cleaned FROM jobs"
    ),
  ]);

  const counts: Record<string, number> = {};
  for (const r of countResult.rows as any[]) counts[r.status] = Number(r.c);
  const seg = segResult.rows[0] as any;

  // Vast.ai running instances (optional — needs VAST_API_KEY env var)
  let gpuPods: any = null;
  const vastKey = process.env.VAST_API_KEY;
  if (vastKey) {
    try {
      const r = await fetch(
        `https://console.vast.ai/api/v0/instances/?owner=me&api_key=${vastKey}`,
        { signal: AbortSignal.timeout(8000) }
      );
      if (r.ok) {
        const data = await r.json();
        const instances = data.instances || [];
        gpuPods = {
          total: instances.length,
          running: instances.filter((i: any) => i.actual_status === "running").length,
          instances: instances.map((i: any) => ({
            id: i.id,
            status: i.actual_status || i.intended_status,
            gpu: i.gpu_name,
            label: i.label,
            cost: i.dph_total,
          })),
        };
      }
    } catch {
      // vast.ai unreachable — skip
    }
  }

  // Active workers (jobs with worker_id set)
  const activeResult = await turso.execute(
    "SELECT name, status, worker_id FROM jobs WHERE worker_id IS NOT NULL"
  );

  return res.json({
    counts,
    totalClean: Number(seg.total_clean),
    totalOverlap: Number(seg.total_overlap),
    totalCleaned: Number(seg.total_cleaned),
    activeWorkers: activeResult.rows.length,
    gpuPods,
  });
}


Now update the job detail API to include durations:


import type { NextApiRequest, NextApiResponse } from "next";
import { turso } from "@/lib/turso";
import { checkAuth } from "@/lib/auth";
import { presign, listKeysWithSizes } from "@/lib/r2";

/** WAV 16kHz mono PCM_16 → duration in seconds */
function wavDuration(bytes: number): number {
  return Math.max(0, (bytes - 44) / 32000);
}

async function buildAudioList(items: { key: string; size: number }[]) {
  const wavs = items.filter(i => i.key.endsWith(".wav"));
  wavs.sort((a, b) => a.key.localeCompare(b.key));
  return Promise.all(
    wavs.map(async item => ({
      name: item.key.split("/").pop()!,
      url: await presign(item.key),
      duration: Math.round(wavDuration(item.size) * 10) / 10,
    }))
  );
}

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (!checkAuth(req)) return res.status(401).json({ error: "not logged in" });
  const id = req.query.id as string;

  if (req.method === "GET") {
    const r = await turso.execute({
      sql: "SELECT * FROM jobs WHERE id=?",
      args: [id],
    });
    if (r.rows.length === 0) return res.status(404).end();
    const job: any = r.rows[0];

    if (job.r2_prefix) {
      const prefix = job.r2_prefix;
      try {
        const [clipUrl, diarizeUrl, refUrl, cleanedItems, overlapItems, cleanedOverlapItems] =
          await Promise.all([
            presign(`${prefix}/clip.wav`).catch(() => null),
            presign(`${prefix}/diarize.json`).catch(() => null),
            presign(`${prefix}/main_speaker_ref.wav`).catch(() => null),
            listKeysWithSizes(`${prefix}/cleaned/`),
            listKeysWithSizes(`${prefix}/overlap_notcleaned/`),
            listKeysWithSizes(`${prefix}/overlap_cleaned/`),
          ]);

        const [cleaned, overlap_notcleaned, overlap_cleaned] = await Promise.all([
          buildAudioList(cleanedItems),
          buildAudioList(overlapItems),
          buildAudioList(cleanedOverlapItems),
        ]);

        job.urls = { clip: clipUrl, diarize: diarizeUrl, reference: refUrl,
                     cleaned, overlap_notcleaned, overlap_cleaned };
        job.totalDuration = {
          cleaned: Math.round(cleaned.reduce((s, f) => s + f.duration, 0) * 10) / 10,
          overlap_notcleaned: Math.round(overlap_notcleaned.reduce((s, f) => s + f.duration, 0) * 10) / 10,
          overlap_cleaned: Math.round(overlap_cleaned.reduce((s, f) => s + f.duration, 0) * 10) / 10,
        };
      } catch {
        // R2 unreachable — return job without urls
      }
    }

    return res.json(job);
  }

  if (req.method === "DELETE") {
    await turso.execute({ sql: "DELETE FROM jobs WHERE id=?", args: [id] });
    return res.json({ ok: true });
  }

  if (req.method === "PATCH") {
    const { action } = req.body || {};
    if (action === "retry") {
      await turso.execute({
        sql: `UPDATE jobs
              SET status = COALESCE(failed_at_status, 'pending'),
                  error = NULL,
                  failed_at_status = NULL,
                  worker_id = NULL
              WHERE id = ?`,
        args: [id],
      });
      return res.json({ ok: true });
    }
    return res.status(400).json({ error: "unknown action" });
  }

  return res.status(405).end();
}


The file /home/ubuntu/karthyani_pipeline/dashboard/pages/api/job/[id].ts has been updated successfully.

import type { NextApiRequest, NextApiResponse } from "next";
import { turso } from "@/lib/turso";
import { checkAuth } from "@/lib/auth";
import { presign, listKeysWithSizes } from "@/lib/r2";

/** WAV 16kHz mono PCM_16 → duration in seconds */
function wavDuration(bytes: number): number {
  return Math.max(0, (bytes - 44) / 32000);
}

async function buildAudioList(items: { key: string; size: number }[]) {
  const wavs = items.filter(i => i.key.endsWith(".wav"));
  wavs.sort((a, b) => a.key.localeCompare(b.key));
  return Promise.all(
    wavs.map(async item => ({
      name: item.key.split("/").pop()!,
      url: await presign(item.key),
      duration: Math.round(wavDuration(item.size) * 10) / 10,
    }))
  );
}

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (!checkAuth(req)) return res.status(401).json({ error: "not logged in" });
  const id = req.query.id as string;

  if (req.method === "GET") {
    const r = await turso.execute({
      sql: "SELECT * FROM jobs WHERE id=?",
      args: [id],
    });
    if (r.rows.length === 0) return res.status(404).end();
    const job: any = r.rows[0];

    if (job.r2_prefix) {
      const prefix = job.r2_prefix;
      try {
        const [clipUrl, diarizeUrl, refUrl, cleanedItems, overlapItems, cleanedOverlapItems] =
          await Promise.all([
            presign(`${prefix}/clip.wav`).catch(() => null),
            presign(`${prefix}/diarize.json`).catch(() => null),
            presign(`${prefix}/main_speaker_ref.wav`).catch(() => null),
            listKeysWithSizes(`${prefix}/cleaned/`),
            listKeysWithSizes(`${prefix}/overlap_notcleaned/`),
            listKeysWithSizes(`${prefix}/overlap_cleaned/`),
          ]);

        const [cleaned, overlap_notcleaned, overlap_cleaned] = await Promise.all([
          buildAudioList(cleanedItems),
          buildAudioList(overlapItems),
          buildAudioList(cleanedOverlapItems),
        ]);

        job.urls = { clip: clipUrl, diarize: diarizeUrl, reference: refUrl,
                     cleaned, overlap_notcleaned, overlap_cleaned };
        job.totalDuration = {
          cleaned: Math.round(cleaned.reduce((s, f) => s + f.duration, 0) * 10) / 10,
          overlap_notcleaned: Math.round(overlap_notcleaned.reduce((s, f) => s + f.duration, 0) * 10) / 10,
          overlap_cleaned: Math.round(overlap_cleaned.reduce((s, f) => s + f.duration, 0) * 10) / 10,
        };
      } catch {
        // R2 unreachable — return job without urls
      }
    }

    return res.json(job);
  }

  if (req.method === "DELETE") {
    await turso.execute({ sql: "DELETE FROM jobs WHERE id=?", args: [id] });
    return res.json({ ok: true });
  }

  if (req.method === "PATCH") {
    const { action } = req.body || {};
    if (action === "retry") {
      await turso.execute({
        sql: `UPDATE jobs
              SET status = COALESCE(failed_at_status, 'pending'),
                  error = NULL,
                  failed_at_status = NULL,
                  worker_id = NULL
              WHERE id = ?`,
        args: [id],
      });
      return res.json({ ok: true });
    }
    return res.status(400).json({ error: "unknown action" });
  }

  return res.status(405).end();
}

-import { presign, listKeys } from "@/lib/r2";

+import { presign, listKeysWithSizes } from "@/lib/r2";

+/** WAV 16kHz mono PCM_16 → duration in seconds */

+function wavDuration(bytes: number): number {

+  return Math.max(0, (bytes - 44) / 32000);

+async function buildAudioList(items: { key: string; size: number }[]) {

+  const wavs = items.filter(i => i.key.endsWith(".wav"));

+  wavs.sort((a, b) => a.key.localeCompare(b.key));

+      name: item.key.split("/").pop()!,

+      duration: Math.round(wavDuration(item.size) * 10) / 10,

-    // Build presigned URLs for outputs if available

-        const [clipUrl, diarizeUrl, refUrl, cleanedKeys, overlapKeys, cleanedOverlapKeys] = await Promise.all([

-          presign(`${prefix}/clip.wav`).catch(() => null),

-          presign(`${prefix}/diarize.json`).catch(() => null),

-          presign(`${prefix}/main_speaker_ref.wav`).catch(() => null),

-          listKeys(`${prefix}/cleaned/`),

-          listKeys(`${prefix}/overlap_notcleaned/`),

-          listKeys(`${prefix}/overlap_cleaned/`),

+        const [clipUrl, diarizeUrl, refUrl, cleanedItems, overlapItems, cleanedOverlapItems] =

+            presign(`${prefix}/clip.wav`).catch(() => null),

+            presign(`${prefix}/diarize.json`).catch(() => null),

+            presign(`${prefix}/main_speaker_ref.wav`).catch(() => null),

+            listKeysWithSizes(`${prefix}/cleaned/`),

+            listKeysWithSizes(`${prefix}/overlap_notcleaned/`),

+            listKeysWithSizes(`${prefix}/overlap_cleaned/`),

+        const [cleaned, overlap_notcleaned, overlap_cleaned] = await Promise.all([

+          buildAudioList(cleanedItems),

+          buildAudioList(overlapItems),

+          buildAudioList(cleanedOverlapItems),

-            cleanedKeys.filter(k => k.endsWith(".wav")).map(async k => ({

-              name: k.split("/").pop(), url: await presign(k),

-          overlap_notcleaned: await Promise.all(

-            overlapKeys.filter(k => k.endsWith(".wav")).map(async k => ({

-          overlap_cleaned: await Promise.all(

-            cleanedOverlapKeys.filter(k => k.endsWith(".wav")).map(async k => ({

+        job.urls = { clip: clipUrl, diarize: diarizeUrl, reference: refUrl,

+                     cleaned, overlap_notcleaned, overlap_cleaned };

+          cleaned: Math.round(cleaned.reduce((s, f) => s + f.duration, 0) * 10) / 10,

+          overlap_notcleaned: Math.round(overlap_notcleaned.reduce((s, f) => s + f.duration, 0) * 10) / 10,

+          overlap_cleaned: Math.round(overlap_cleaned.reduce((s, f) => s + f.duration, 0) * 10) / 10,

-        // R2 unreachable or missing — just return job without urls

+        // R2 unreachable — return job without urls

-      // Restore to the stage where it failed (granular resume), or pending if unknown

-                  failed_at_status = NULL

+                  failed_at_status = NULL,

import type { NextApiRequest, NextApiResponse } from "next";
import { turso } from "@/lib/turso";
import { checkAuth } from "@/lib/auth";
import { presign, listKeys } from "@/lib/r2";

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (!checkAuth(req)) return res.status(401).json({ error: "not logged in" });
  const id = req.query.id as string;

  if (req.method === "GET") {
    const r = await turso.execute({
      sql: "SELECT * FROM jobs WHERE id=?",
      args: [id],
    });
    if (r.rows.length === 0) return res.status(404).end();
    const job: any = r.rows[0];

    // Build presigned URLs for outputs if available
    if (job.r2_prefix) {
      const prefix = job.r2_prefix;
      try {
        const [clipUrl, diarizeUrl, refUrl, cleanedKeys, overlapKeys, cleanedOverlapKeys] = await Promise.all([
          presign(`${prefix}/clip.wav`).catch(() => null),
          presign(`${prefix}/diarize.json`).catch(() => null),
          presign(`${prefix}/main_speaker_ref.wav`).catch(() => null),
          listKeys(`${prefix}/cleaned/`),
          listKeys(`${prefix}/overlap_notcleaned/`),
          listKeys(`${prefix}/overlap_cleaned/`),
        ]);
        job.urls = {
          clip: clipUrl,
          diarize: diarizeUrl,
          reference: refUrl,
          cleaned: await Promise.all(
            cleanedKeys.filter(k => k.endsWith(".wav")).map(async k => ({
              name: k.split("/").pop(), url: await presign(k),
            }))
          ),
          overlap_notcleaned: await Promise.all(
            overlapKeys.filter(k => k.endsWith(".wav")).map(async k => ({
              name: k.split("/").pop(), url: await presign(k),
            }))
          ),
          overlap_cleaned: await Promise.all(
            cleanedOverlapKeys.filter(k => k.endsWith(".wav")).map(async k => ({
              name: k.split("/").pop(), url: await presign(k),
            }))
          ),
        };
      } catch (e) {
        // R2 unreachable or missing — just return job without urls
      }
    }

    return res.json(job);
  }

  if (req.method === "DELETE") {
    await turso.execute({ sql: "DELETE FROM jobs WHERE id=?", args: [id] });
    return res.json({ ok: true });
  }

  if (req.method === "PATCH") {
    const { action } = req.body || {};
    if (action === "retry") {
      // Restore to the stage where it failed (granular resume), or pending if unknown
      await turso.execute({
        sql: `UPDATE jobs
              SET status = COALESCE(failed_at_status, 'pending'),
                  error = NULL,
                  failed_at_status = NULL
              WHERE id = ?`,
        args: [id],
      });
      return res.json({ ok: true });
    }
    return res.status(400).json({ error: "unknown action" });
  }

  return res.status(405).end();
}


Now the big one — the frontend overhaul:


import { useEffect, useState, useCallback } from "react";
import Head from "next/head";

// ── Types ─────────────────────────────────────────────────────────────────

type Job = {
  id: string;
  name: string;
  url: string;
  status: string;
  r2_prefix: string | null;
  target_speaker: string | null;
  speakers_json: string | null;
  num_clean_segments: number | null;
  num_overlap_segments: number | null;
  num_overlap_cleaned: number | null;
  error: string | null;
  created_at: string;
  updated_at: string;
  worker_id: string | null;
  gpu_instance_id: string | null;
  failed_at_status: string | null;
};

type AudioFile = { name: string; url: string; duration: number };

type JobDetail = Job & {
  urls?: {
    clip: string | null;
    diarize: string | null;
    reference: string | null;
    cleaned: AudioFile[];
    overlap_notcleaned: AudioFile[];
    overlap_cleaned: AudioFile[];
  };
  totalDuration?: {
    cleaned: number;
    overlap_notcleaned: number;
    overlap_cleaned: number;
  };
};

type Stats = {
  counts: Record<string, number>;
  totalClean: number;
  totalOverlap: number;
  totalCleaned: number;
  activeWorkers: number;
  gpuPods: {
    running: number;
    total: number;
    instances: { id: number; status: string; gpu: string; label: string; cost: number }[];
  } | null;
};

// ── Utilities ─────────────────────────────────────────────────────────────

function fmtDur(secs: number): string {
  if (!secs || secs <= 0) return "0s";
  const h = Math.floor(secs / 3600);
  const m = Math.floor((secs % 3600) / 60);
  const s = Math.round(secs % 60);
  if (h > 0) return `${h}h ${m}m`;
  if (m > 0) return `${m}m ${s}s`;
  return `${s}s`;
}

function timeAgo(dateStr: string): string {
  if (!dateStr) return "";
  const d = new Date(dateStr.includes("Z") ? dateStr : dateStr + "Z");
  const diff = Math.floor((Date.now() - d.getTime()) / 1000);
  if (diff < 60) return "just now";
  if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
  if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
  return `${Math.floor(diff / 86400)}d ago`;
}

const STAGE_ORDER = [
  "pending", "audio_uploaded", "diarized", "segmented", "awaiting_solospeech", "done",
];
const STAGE_LABELS: Record<string, string> = {
  pending: "Downloading audio",
  audio_uploaded: "Running diarization",
  diarized: "Segmenting audio",
  segmented: "Preparing SoloSpeech",
  awaiting_solospeech: "SoloSpeech on GPU",
  done: "Complete",
  failed: "Failed",
};
const STATUS_COLORS: Record<string, string> = {
  pending: "#ca8a04",
  audio_uploaded: "#d97706",
  diarized: "#2563eb",
  segmented: "#2563eb",
  awaiting_solospeech: "#7c3aed",
  done: "#16a34a",
  failed: "#dc2626",
};

function stageProgress(status: string): number {
  if (status === "done") return 100;
  if (status === "failed") return 0;
  const idx = STAGE_ORDER.indexOf(status);
  if (idx < 0) return 0;
  return Math.round((idx / (STAGE_ORDER.length - 1)) * 100);
}

// ── Component ─────────────────────────────────────────────────────────────

export default function Home() {
  const [authed, setAuthed] = useState(false);
  const [pwd, setPwd] = useState("");
  const [loginErr, setLoginErr] = useState("");
  const [jobs, setJobs] = useState<Job[]>([]);
  const [stats, setStats] = useState<Stats | null>(null);
  const [url, setUrl] = useState("");
  const [name, setName] = useState("");
  const [submitErr, setSubmitErr] = useState("");
  const [openJob, setOpenJob] = useState<JobDetail | null>(null);
  const [lastRefresh, setLastRefresh] = useState("");
  const [showGpu, setShowGpu] = useState(false);
  const [showForm, setShowForm] = useState(false);

  const refreshJobs = useCallback(async () => {
    const r = await fetch("/api/jobs");
    if (r.ok) {
      const data = await r.json();
      setJobs(data.jobs);
      setAuthed(true);
      setLastRefresh(new Date().toLocaleTimeString());
    }
  }, []);

  const refreshStats = useCallback(async () => {
    try {
      const r = await fetch("/api/stats");
      if (r.ok) setStats(await r.json());
    } catch {}
  }, []);

  useEffect(() => {
    refreshJobs();
    refreshStats();
    const t1 = setInterval(refreshJobs, 5000);
    const t2 = setInterval(refreshStats, 10000);
    return () => { clearInterval(t1); clearInterval(t2); };
  }, [refreshJobs, refreshStats]);

  async function login(e: any) {
    e.preventDefault();
    setLoginErr("");
    const r = await fetch("/api/login", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ password: pwd }),
    });
    if (r.ok) { setAuthed(true); refreshJobs(); refreshStats(); }
    else setLoginErr("wrong password");
  }

  async function submit(e: any) {
    e.preventDefault();
    setSubmitErr("");
    const r = await fetch("/api/jobs", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ url, name }),
    });
    if (r.ok) { setUrl(""); setName(""); setShowForm(false); refreshJobs(); }
    else {
      const j = await r.json().catch(() => ({}));
      setSubmitErr(j.error || `error ${r.status}`);
    }
  }

  async function retry(id: string) {
    await fetch(`/api/job/${id}`, {
      method: "PATCH",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ action: "retry" }),
    });
    refreshJobs();
  }

  async function del(id: string) {
    if (!confirm("Delete this job?")) return;
    await fetch(`/api/job/${id}`, { method: "DELETE" });
    if (openJob?.id === id) setOpenJob(null);
    refreshJobs();
  }

  async function openDetails(id: string) {
    const r = await fetch(`/api/job/${id}`);
    if (r.ok) setOpenJob(await r.json());
  }

  // ── Login ───────────────────────────────────────────────────────────────

  if (!authed) {
    return (

        <title>karthyani</title>

karthyani

speech extraction pipeline

          <form onSubmit={login} style={{ display: "flex", gap: 8 }}>
            <input type="password" value={pwd} onChange={e => setPwd(e.target.value)}
              placeholder="password" style={{ ...inputStyle, flex: 1 }} />
            <button style={btnPrimary}>enter</button>
          </form>
          {loginErr &&
{loginErr}
}

    );
  }

  // ── Main dashboard ──────────────────────────────────────────────────────

  const doneCount = stats?.counts?.done || 0;
  const failedCount = stats?.counts?.failed || 0;
  const processingCount = jobs.filter(j => j.status !== "done" && j.status !== "failed").length;

  return (

      <title>karthyani dashboard</title>

      {/* Header */}

karthyani

            YouTube → pyannote diarization → clean segments → SoloSpeech overlap extraction

        <button style={{ ...btnSecondary, marginRight: 8 }} onClick={() => setShowForm(!showForm)}>
          + new job
        </button>

          auto-refresh 5s
{lastRefresh}

      {/* Stats bar */}

Done

{doneCount}

Processing

{processingCount}

Failed

 0 ? "#dc2626" : "#d1d5db" }}>{failedCount}

Clean Segments

{stats?.totalClean ?? "..."}

Overlap Cleaned

{stats?.totalCleaned ?? "..."}

 setShowGpu(!showGpu)}
        >

GPU Pods{stats?.gpuPods ? "" : " (no key)"}

            {stats?.gpuPods?.running ?? "—"}

Active Workers

{stats?.activeWorkers ?? "..."}

      {/* GPU pod details */}
      {showGpu && stats?.gpuPods && stats.gpuPods.instances.length > 0 && (

            Vast.ai Instances

                ID
                GPU
                Label
                $/hr
                Status

              {stats.gpuPods.instances.map(inst => (

                  {inst.id}
                  {inst.gpu}
                  {inst.label || "—"}
                  ${inst.cost?.toFixed(3)}

                    {inst.status}

              ))}

            Total cost: ${stats.gpuPods.instances.reduce((s, i) => s + (i.cost || 0), 0).toFixed(3)}/hr

      )}

      {/* New job form (collapsible) */}
      {showForm && (

            Submit new job

          <form onSubmit={submit} style={{ display: "flex", gap: 8 }}>
            <input type="url" required placeholder="https://www.youtube.com/watch?v=..."
              value={url} onChange={e => setUrl(e.target.value)}
              style={{ ...inputStyle, flex: 2 }} />
            <input type="text" required placeholder="job name"
              value={name} onChange={e => setName(e.target.value)}
              style={{ ...inputStyle, flex: 1 }} />
            <button style={btnPrimary}>submit</button>
          </form>
          {submitErr &&
{submitErr}
}

      )}

      {/* Job list */}

          Jobs ({jobs.length})

      {jobs.length === 0 && (

No jobs yet. Click "+ new job" to get started.

      )}

      {jobs.map(j => {
        const color = STATUS_COLORS[j.status] || "#6b7280";
        const progress = stageProgress(j.status);
        const isActive = j.status !== "done" && j.status !== "failed";
        const speakers = j.speakers_json ? JSON.parse(j.speakers_json) : null;
        const totalSpeakerDur = speakers
          ? Object.values(speakers as Record<string, number>).reduce((a: number, b: number) => a + b, 0)
          : 0;

        return (

            {/* Row 1: name + badge + time + buttons */}

              {j.name}
              {j.status.replace(/_/g, " ")}
              {isActive && j.worker_id && (

                  worker active

              )}
              {timeAgo(j.updated_at)}

              {j.r2_prefix && (
                <button style={btnSmall} onClick={() => openDetails(j.id)}>open</button>
              )}
              {j.status === "failed" && (
                <button style={{ ...btnSmall, color: "#dc2626", borderColor: "#fecaca" }}
                  onClick={() => retry(j.id)}>retry</button>
              )}
              <button style={{ ...btnSmall, color: "#6b7280" }} onClick={() => del(j.id)}>delete</button>

            {/* Row 2: URL */}

              {j.url}

            {/* Row 3: speaker info */}
            {speakers && (

                target: {j.target_speaker}
                {" "} ({fmtDur(speakers[j.target_speaker!] || 0)} of {fmtDur(totalSpeakerDur)} total)
                {" · "}{Object.keys(speakers).length} speakers

            )}

            {/* Row 4: segment counts */}
            {(j.num_clean_segments !== null || j.num_overlap_segments !== null) && (

                clean: {j.num_clean_segments ?? "?"}
                {" · "}overlap: {j.num_overlap_segments ?? "?"}
                {j.num_overlap_cleaned ? <> · cleaned: {j.num_overlap_cleaned}</> : null}

            )}

            {/* Progress bar for active jobs */}
            {isActive && (

                    {STAGE_LABELS[j.status] || j.status}
                    {j.gpu_instance_id ? ` (GPU #${j.gpu_instance_id})` : ""}

                  <div style={{
                    ...progressBar,
                    width: `${progress}%`,
                    background: color,
                  }} />

            )}

            {/* Error */}
            {j.error && (

{j.error}

            )}

        );
      })}

      {/* ── Detail modal ────────────────────────────────────────────────── */}
      {openJob && (

 setOpenJob(null)}>

 e.stopPropagation()}>
            {/* Modal header */}

{openJob.name}

              {openJob.status}

              <button style={btnSmall} onClick={() => setOpenJob(null)}>close</button>

            {/* Duration summary banner */}
            {openJob.totalDuration && (

Cleaned

{fmtDur(openJob.totalDuration.cleaned)}

{openJob.urls?.cleaned.length || 0} files

Overlap (raw)

{fmtDur(openJob.totalDuration.overlap_notcleaned)}

{openJob.urls?.overlap_notcleaned.length || 0} files

Overlap (cleaned)

{fmtDur(openJob.totalDuration.overlap_cleaned)}

{openJob.urls?.overlap_cleaned.length || 0} files

Total Usable

                    {fmtDur(openJob.totalDuration.cleaned + openJob.totalDuration.overlap_cleaned)}

                    {(openJob.urls?.cleaned.length || 0) + (openJob.urls?.overlap_cleaned.length || 0)} files

            )}

            {/* Source audio players */}

              {openJob.urls?.clip && (

Original clip

                  <audio controls preload="none" src={openJob.urls.clip} style={{ width: "100%", height: 32 }} />

              )}
              {openJob.urls?.reference && (

Reference (target speaker)

                  <audio controls preload="none" src={openJob.urls.reference} style={{ width: "100%", height: 32 }} />

              )}

            {openJob.urls?.diarize && (

                  diarize.json

            )}

            {/* Three columns of audio files */}

              <AudioColumn
                title="cleaned/"
                files={openJob.urls?.cleaned || []}
                totalDur={openJob.totalDuration?.cleaned}
                color="#16a34a"
              />
              <AudioColumn
                title="overlap_notcleaned/"
                files={openJob.urls?.overlap_notcleaned || []}
                totalDur={openJob.totalDuration?.overlap_notcleaned}
                color="#d97706"
              />
              <AudioColumn
                title="overlap_cleaned/"
                files={openJob.urls?.overlap_cleaned || []}
                totalDur={openJob.totalDuration?.overlap_cleaned}
                color="#7c3aed"
              />

      )}

  );
}

// ── Sub-components ────────────────────────────────────────────────────────

function AudioColumn({ title, files, totalDur, color }: {
  title: string;
  files: AudioFile[];
  totalDur?: number;
  color: string;
}) {
  return (

        {title} ({files.length})

      {totalDur !== undefined && (

          {fmtDur(totalDur)}

      )}

        {files.map(f => (

              {f.name}
              {fmtDur(f.duration)}

            <audio controls preload="none" src={f.url} style={{ width: "100%", height: 26 }} />

        ))}

  );
}

// ── Styles ─────────────────────────────────────────────────────────────────

const pageStyle: React.CSSProperties = {
  maxWidth: 1150,
  margin: "0 auto",
  padding: "28px 24px 80px",
  fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
  color: "#1a1d24",
  background: "#f9fafb",
  minHeight: "100vh",
};
const inputStyle: React.CSSProperties = {
  padding: "8px 12px",
  fontSize: 13,
  border: "1px solid #d1d5db",
  borderRadius: 6,
  outline: "none",
  background: "#fff",
};
const btnPrimary: React.CSSProperties = {
  padding: "8px 18px",
  fontSize: 13,
  fontWeight: 600,
  background: "#2563eb",
  color: "#fff",
  border: "none",
  borderRadius: 6,
  cursor: "pointer",
};
const btnSecondary: React.CSSProperties = {
  padding: "6px 14px",
  fontSize: 12,
  fontWeight: 600,
  background: "#fff",
  color: "#374151",
  border: "1px solid #d1d5db",
  borderRadius: 6,
  cursor: "pointer",
};
const btnSmall: React.CSSProperties = {
  padding: "3px 10px",
  fontSize: 11,
  fontWeight: 500,
  background: "#fff",
  color: "#374151",
  border: "1px solid #d1d5db",
  borderRadius: 5,
  cursor: "pointer",
};
const cardStyle: React.CSSProperties = {
  background: "#fff",
  border: "1px solid #e5e7eb",
  borderRadius: 8,
  padding: "12px 16px",
  marginBottom: 8,
};
const badgeStyle: React.CSSProperties = {
  padding: "2px 8px",
  borderRadius: 4,
  fontSize: 10,
  fontWeight: 600,
  textTransform: "uppercase",
  letterSpacing: "0.04em",
};
const statsGrid: React.CSSProperties = {
  display: "grid",
  gridTemplateColumns: "repeat(auto-fit, minmax(130px, 1fr))",
  gap: 10,
  marginBottom: 20,
};
const statCard: React.CSSProperties = {
  background: "#fff",
  border: "1px solid #e5e7eb",
  borderLeft: "3px solid #d1d5db",
  borderRadius: 6,
  padding: "10px 14px",
};
const statLabel: React.CSSProperties = {
  fontSize: 10,
  fontWeight: 600,
  color: "#9ca3af",
  textTransform: "uppercase",
  letterSpacing: "0.05em",
  marginBottom: 2,
};
const statValue: React.CSSProperties = {
  fontSize: 22,
  fontWeight: 700,
  lineHeight: "28px",
};
const progressBg: React.CSSProperties = {
  height: 4,
  background: "#e5e7eb",
  borderRadius: 2,
  overflow: "hidden",
};
const progressBar: React.CSSProperties = {
  height: "100%",
  borderRadius: 2,
  transition: "width 0.4s ease",
};
const errorBox: React.CSSProperties = {
  background: "#fef2f2",
  color: "#7f1d1d",
  padding: 8,
  fontSize: 10,
  overflow: "auto",
  margin: "6px 0 0",
  borderRadius: 4,
  maxHeight: 120,
  whiteSpace: "pre-wrap",
  wordBreak: "break-all",
};
const thStyle: React.CSSProperties = {
  textAlign: "left",
  padding: "4px 8px",
  fontSize: 10,
  fontWeight: 600,
  color: "#9ca3af",
  textTransform: "uppercase",
};
const tdStyle: React.CSSProperties = {
  padding: "5px 8px",
  fontSize: 12,
  color: "#374151",
};
const modalOverlay: React.CSSProperties = {
  position: "fixed",
  top: 0, left: 0, right: 0, bottom: 0,
  background: "rgba(0,0,0,0.4)",
  display: "flex",
  alignItems: "center",
  justifyContent: "center",
  zIndex: 100,
  padding: 20,
};
const modalBox: React.CSSProperties = {
  background: "#fff",
  borderRadius: 10,
  padding: 24,
  maxWidth: 1200,
  width: "100%",
  maxHeight: "92vh",
  overflow: "auto",
  boxShadow: "0 20px 60px rgba(0,0,0,0.15)",
};
const durationBanner: React.CSSProperties = {
  display: "flex",
  alignItems: "center",
  justifyContent: "center",
  gap: 0,
  background: "#f9fafb",
  border: "1px solid #e5e7eb",
  borderRadius: 8,
  padding: "14px 20px",
  marginBottom: 20,
};
const durationItem: React.CSSProperties = {
  textAlign: "center",
  flex: 1,
};
const durationLabel: React.CSSProperties = {
  fontSize: 10,
  fontWeight: 600,
  color: "#9ca3af",
  textTransform: "uppercase",
  letterSpacing: "0.04em",
};
const durationValue: React.CSSProperties = {
  fontSize: 18,
  fontWeight: 700,
  color: "#374151",
  lineHeight: "24px",
};
const durationSub: React.CSSProperties = {
  fontSize: 10,
  color: "#b0b7c3",
};
const durationDivider: React.CSSProperties = {
  width: 1,
  height: 36,
  background: "#e5e7eb",
  margin: "0 4px",
};
const sectionLabel: React.CSSProperties = {
  fontSize: 11,
  fontWeight: 600,
  color: "#6b7280",
  textTransform: "uppercase",
  letterSpacing: "0.04em",
  marginBottom: 6,
};
const miniCard: React.CSSProperties = {
  background: "#f9fafb",
  border: "1px solid #e5e7eb",
  borderRadius: 5,
  padding: "5px 8px",
  marginBottom: 5,
};
const miniLabel: React.CSSProperties = {
  fontSize: 10,
  color: "#6b7280",
};


The file /home/ubuntu/karthyani_pipeline/dashboard/pages/index.tsx has been updated successfully.

import { useEffect, useState, useCallback } from "react";
import Head from "next/head";

// ── Types ─────────────────────────────────────────────────────────────────

type Job = {
  id: string;
  name: string;
  url: string;
  status: string;
  r2_prefix: string | null;
  target_speaker: string | null;
  speakers_json: string | null;
  num_clean_segments: number | null;
  num_overlap_segments: number | null;
  num_overlap_cleaned: number | null;
  error: string | null;
  created_at: string;
  updated_at: string;
  worker_id: string | null;
  gpu_instance_id: string | null;
  failed_at_status: string | null;
};

type AudioFile = { name: string; url: string; duration: number };

type JobDetail = Job & {
  urls?: {
    clip: string | null;
    diarize: string | null;
    reference: string | null;
    cleaned: AudioFile[];
    overlap_notcleaned: AudioFile[];
    overlap_cleaned: AudioFile[];
  };
  totalDuration?: {
    cleaned: number;
    overlap_notcleaned: number;
    overlap_cleaned: number;
  };
};

type Stats = {
  counts: Record<string, number>;
  totalClean: number;
  totalOverlap: number;
  totalCleaned: number;
  activeWorkers: number;
  gpuPods: {
    running: number;
    total: number;
    instances: { id: number; status: string; gpu: string; label: string; cost: number }[];
  } | null;
};

// ── Utilities ─────────────────────────────────────────────────────────────

function fmtDur(secs: number): string {
  if (!secs || secs <= 0) return "0s";
  const h = Math.floor(secs / 3600);
  const m = Math.floor((secs % 3600) / 60);
  const s = Math.round(secs % 60);
  if (h > 0) return `${h}h ${m}m`;
  if (m > 0) return `${m}m ${s}s`;
  return `${s}s`;
}

function timeAgo(dateStr: string): string {
  if (!dateStr) return "";
  const d = new Date(dateStr.includes("Z") ? dateStr : dateStr + "Z");
  const diff = Math.floor((Date.now() - d.getTime()) / 1000);
  if (diff < 60) return "just now";
  if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
  if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
  return `${Math.floor(diff / 86400)}d ago`;
}

const STAGE_ORDER = [
  "pending", "audio_uploaded", "diarized", "segmented", "awaiting_solospeech", "done",
];
const STAGE_LABELS: Record<string, string> = {
  pending: "Downloading audio",
  audio_uploaded: "Running diarization",
  diarized: "Segmenting audio",
  segmented: "Preparing SoloSpeech",
  awaiting_solospeech: "SoloSpeech on GPU",
  done: "Complete",
  failed: "Failed",
};
const STATUS_COLORS: Record<string, string> = {
  pending: "#ca8a04",
  audio_uploaded: "#d97706",
  diarized: "#2563eb",
  segmented: "#2563eb",
  awaiting_solospeech: "#7c3aed",
  done: "#16a34a",
  failed: "#dc2626",
};

function stageProgress(status: string): number {
  if (status === "done") return 100;
  if (status === "failed") return 0;
  const idx = STAGE_ORDER.indexOf(status);
  if (idx < 0) return 0;
  return Math.round((idx / (STAGE_ORDER.length - 1)) * 100);
}

// ── Component ─────────────────────────────────────────────────────────────

export default function Home() {
  const [authed, setAuthed] = useState(false);
  const [pwd, setPwd] = useState("");
  const [loginErr, setLoginErr] = useState("");
  const [jobs, setJobs] = useState<Job[]>([]);
  const [stats, setStats] = useState<Stats | null>(null);
  const [url, setUrl] = useState("");
  const [name, setName] = useState("");
  const [submitErr, setSubmitErr] = useState("");
  const [openJob, setOpenJob] = useState<JobDetail | null>(null);
  const [lastRefresh, setLastRefresh] = useState("");
  const [showGpu, setShowGpu] = useState(false);
  const [showForm, setShowForm] = useState(false);

  const refreshJobs = useCallback(async () => {
    const r = await fetch("/api/jobs");
    if (r.ok) {
      const data = await r.json();
      setJobs(data.jobs);
      setAuthed(true);
      setLastRefresh(new Date().toLocaleTimeString());
    }
  }, []);

  const refreshStats = useCallback(async () => {
    try {
      const r = await fetch("/api/stats");
      if (r.ok) setStats(await r.json());
    } catch {}
  }, []);

  useEffect(() => {
    refreshJobs();
    refreshStats();
    const t1 = setInterval(refreshJobs, 5000);
    const t2 = setInterval(refreshStats, 10000);
    return () => { clearInterval(t1); clearInterval(t2); };
  }, [refreshJobs, refreshStats]);

  async function login(e: any) {
    e.preventDefault();
    setLoginErr("");
    const r = await fetch("/api/login", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ password: pwd }),
    });
    if (r.ok) { setAuthed(true); refreshJobs(); refreshStats(); }
    else setLoginErr("wrong password");
  }

  async function submit(e: any) {
    e.preventDefault();
    setSubmitErr("");
    const r = await fetch("/api/jobs", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ url, name }),
    });
    if (r.ok) { setUrl(""); setName(""); setShowForm(false); refreshJobs(); }
    else {
      const j = await r.json().catch(() => ({}));
      setSubmitErr(j.error || `error ${r.status}`);
    }
  }

  async function retry(id: string) {
    await fetch(`/api/job/${id}`, {
      method: "PATCH",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ action: "retry" }),
    });
    refreshJobs();
  }

  async function del(id: string) {
    if (!confirm("Delete this job?")) return;
    await fetch(`/api/job/${id}`, { method: "DELETE" });
    if (openJob?.id === id) setOpenJob(null);
    refreshJobs();
  }

  async function openDetails(id: string) {
    const r = await fetch(`/api/job/${id}`);
    if (r.ok) setOpenJob(await r.json());
  }

  // ── Login ───────────────────────────────────────────────────────────────

  if (!authed) {
    return (

        <title>karthyani</title>

karthyani

speech extraction pipeline

          <form onSubmit={login} style={{ display: "flex", gap: 8 }}>
            <input type="password" value={pwd} onChange={e => setPwd(e.target.value)}
              placeholder="password" style={{ ...inputStyle, flex: 1 }} />
            <button style={btnPrimary}>enter</button>
          </form>
          {loginErr &&
{loginErr}
}

    );
  }

  // ── Main dashboard ──────────────────────────────────────────────────────

  const doneCount = stats?.counts?.done || 0;
  const failedCount = stats?.counts?.failed || 0;
  const processingCount = jobs.filter(j => j.status !== "done" && j.status !== "failed").length;

  return (

      <title>karthyani dashboard</title>

      {/* Header */}

karthyani

            YouTube → pyannote diarization → clean segments → SoloSpeech overlap extraction

        <button style={{ ...btnSecondary, marginRight: 8 }} onClick={() => setShowForm(!showForm)}>
          + new job
        </button>

          auto-refresh 5s
{lastRefresh}

      {/* Stats bar */}

Done

{doneCount}

Processing

{processingCount}

Failed

 0 ? "#dc2626" : "#d1d5db" }}>{failedCount}

Clean Segments

{stats?.totalClean ?? "..."}

Overlap Cleaned

{stats?.totalCleaned ?? "..."}

 setShowGpu(!showGpu)}
        >

GPU Pods{stats?.gpuPods ? "" : " (no key)"}

            {stats?.gpuPods?.running ?? "—"}

Active Workers

{stats?.activeWorkers ?? "..."}

      {/* GPU pod details */}
      {showGpu && stats?.gpuPods && stats.gpuPods.instances.length > 0 && (

            Vast.ai Instances

                ID
                GPU
                Label
                $/hr
                Status

              {stats.gpuPods.instances.map(inst => (

                  {inst.id}
                  {inst.gpu}
                  {inst.label || "—"}
                  ${inst.cost?.toFixed(3)}

                    {inst.status}

              ))}

            Total cost: ${stats.gpuPods.instances.reduce((s, i) => s + (i.cost || 0), 0).toFixed(3)}/hr

      )}

      {/* New job form (collapsible) */}
      {showForm && (

            Submit new job

          <form onSubmit={submit} style={{ display: "flex", gap: 8 }}>
            <input type="url" required placeholder="https://www.youtube.com/watch?v=..."
              value={url} onChange={e => setUrl(e.target.value)}
              style={{ ...inputStyle, flex: 2 }} />
            <input type="text" required placeholder="job name"
              value={name} onChange={e => setName(e.target.value)}
              style={{ ...inputStyle, flex: 1 }} />
            <button style={btnPrimary}>submit</button>
          </form>
          {submitErr &&
{submitErr}
}

      )}

      {/* Job list */}

          Jobs ({jobs.length})

      {jobs.length === 0 && (

No jobs yet. Click "+ new job" to get started.

      )}

      {jobs.map(j => {
        const color = STATUS_COLORS[j.status] || "#6b7280";
        const progress = stageProgress(j.status);
        const isActive = j.status !== "done" && j.status !== "failed";
        const speakers = j.speakers_json ? JSON.parse(j.speakers_json) : null;
        const totalSpeakerDur = speakers
          ? Object.values(speakers as Record<string, number>).reduce((a: number, b: number) => a + b, 0)
          : 0;

        return (

            {/* Row 1: name + badge + time + buttons */}

              {j.name}
              {j.status.replace(/_/g, " ")}
              {isActive && j.worker_id && (

                  worker active

              )}
              {timeAgo(j.updated_at)}

              {j.r2_prefix && (
                <button style={btnSmall} onClick={() => openDetails(j.id)}>open</button>
              )}
              {j.status === "failed" && (
                <button style={{ ...btnSmall, color: "#dc2626", borderColor: "#fecaca" }}
                  onClick={() => retry(j.id)}>retry</button>
              )}
              <button style={{ ...btnSmall, color: "#6b7280" }} onClick={() => del(j.id)}>delete</button>

            {/* Row 2: URL */}

              {j.url}

            {/* Row 3: speaker info */}
            {speakers && (

                target: {j.target_speaker}
                {" "} ({fmtDur(speakers[j.target_speaker!] || 0)} of {fmtDur(totalSpeakerDur)} total)
                {" · "}{Object.keys(speakers).length} speakers

            )}

            {/* Row 4: segment counts */}
            {(j.num_clean_segments !== null || j.num_overlap_segments !== null) && (

                clean: {j.num_clean_segments ?? "?"}
                {" · "}overlap: {j.num_overlap_segments ?? "?"}
                {j.num_overlap_cleaned ? <> · cleaned: {j.num_overlap_cleaned}</> : null}

            )}

            {/* Progress bar for active jobs */}
            {isActive && (

                    {STAGE_LABELS[j.status] || j.status}
                    {j.gpu_instance_id ? ` (GPU #${j.gpu_instance_id})` : ""}

                  <div style={{
                    ...progressBar,
                    width: `${progress}%`,
                    background: color,
                  }} />

            )}

            {/* Error */}
            {j.error && (

{j.error}

            )}

        );
      })}

      {/* ── Detail modal ────────────────────────────────────────────────── */}
      {openJob && (

 setOpenJob(null)}>

 e.stopPropagation()}>
            {/* Modal header */}

{openJob.name}

              {openJob.status}

              <button style={btnSmall} onClick={() => setOpenJob(null)}>close</button>

            {/* Duration summary banner */}
            {openJob.totalDuration && (

Cleaned

{fmtDur(openJob.totalDuration.cleaned)}

{openJob.urls?.cleaned.length || 0} files

Overlap (raw)

{fmtDur(openJob.totalDuration.overlap_notcleaned)}

{openJob.urls?.overlap_notcleaned.length || 0} files

Overlap (cleaned)

{fmtDur(openJob.totalDuration.overlap_cleaned)}

{openJob.urls?.overlap_cleaned.length || 0} files

Total Usable

                    {fmtDur(openJob.totalDuration.cleaned + openJob.totalDuration.overlap_cleaned)}

                    {(openJob.urls?.cleaned.length || 0) + (openJob.urls?.overlap_cleaned.length || 0)} files

            )}

            {/* Source audio players */}

              {openJob.urls?.clip && (

Original clip

                  <audio controls preload="none" src={openJob.urls.clip} style={{ width: "100%", height: 32 }} />

              )}
              {openJob.urls?.reference && (

Reference (target speaker)

                  <audio controls preload="none" src={openJob.urls.reference} style={{ width: "100%", height: 32 }} />

              )}

            {openJob.urls?.diarize && (

                  diarize.json

            )}

            {/* Three columns of audio files */}

              <AudioColumn
                title="cleaned/"
                files={openJob.urls?.cleaned || []}
                totalDur={openJob.totalDuration?.cleaned}
                color="#16a34a"
              />
              <AudioColumn
                title="overlap_notcleaned/"
                files={openJob.urls?.overlap_notcleaned || []}
                totalDur={openJob.totalDuration?.overlap_notcleaned}
                color="#d97706"
              />
              <AudioColumn
                title="overlap_cleaned/"
                files={openJob.urls?.overlap_cleaned || []}
                totalDur={openJob.totalDuration?.overlap_cleaned}
                color="#7c3aed"
              />

      )}

  );
}

// ── Sub-components ────────────────────────────────────────────────────────

function AudioColumn({ title, files, totalDur, color }: {
  title: string;
  files: AudioFile[];
  totalDur?: number;
  color: string;
}) {
  return (

        {title} ({files.length})

      {totalDur !== undefined && (

          {fmtDur(totalDur)}

      )}

        {files.map(f => (

              {f.name}
              {fmtDur(f.duration)}

            <audio controls preload="none" src={f.url} style={{ width: "100%", height: 26 }} />

        ))}

  );
}

// ── Styles ─────────────────────────────────────────────────────────────────

const pageStyle: React.CSSProperties = {
  maxWidth: 1150,
  margin: "0 auto",
  padding: "28px 24px 80px",
  fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
  color: "#1a1d24",
  background: "#f9fafb",
  minHeight: "100vh",
};
const inputStyle: React.CSSProperties = {
  padding: "8px 12px",
  fontSize: 13,
  border: "1px solid #d1d5db",
  borderRadius: 6,
  outline: "none",
  background: "#fff",
};
const btnPrimary: React.CSSProperties = {
  padding: "8px 18px",
  fontSize: 13,
  fontWeight: 600,
  background: "#2563eb",
  color: "#fff",
  border: "none",
  borderRadius: 6,
  cursor: "pointer",
};
const btnSecondary: React.CSSProperties = {
  padding: "6px 14px",
  fontSize: 12,
  fontWeight: 600,
  background: "#fff",
  color: "#374151",
  border: "1px solid #d1d5db",
  borderRadius: 6,
  cursor: "pointer",
};
const btnSmall: React.CSSProperties = {
  padding: "3px 10px",
  fontSize: 11,
  fontWeight: 500,
  background: "#fff",
  color: "#374151",
  border: "1px solid #d1d5db",
  borderRadius: 5,
  cursor: "pointer",
};
const cardStyle: React.CSSProperties = {
  background: "#fff",
  border: "1px solid #e5e7eb",
  borderRadius: 8,
  padding: "12px 16px",
  marginBottom: 8,
};
const badgeStyle: React.CSSProperties = {
  padding: "2px 8px",
  borderRadius: 4,
  fontSize: 10,
  fontWeight: 600,
  textTransform: "uppercase",
  letterSpacing: "0.04em",
};
const statsGrid: React.CSSProperties = {
  display: "grid",
  gridTemplateColumns: "repeat(auto-fit, minmax(130px, 1fr))",
  gap: 10,
  marginBottom: 20,
};
const statCard: React.CSSProperties = {
  background: "#fff",
  border: "1px solid #e5e7eb",
  borderLeft: "3px solid #d1d5db",
  borderRadius: 6,
  padding: "10px 14px",
};
const statLabel: React.CSSProperties = {
  fontSize: 10,
  fontWeight: 600,
  color: "#9ca3af",
  textTransform: "uppercase",
  letterSpacing: "0.05em",
  marginBottom: 2,
};
const statValue: React.CSSProperties = {
  fontSize: 22,
  fontWeight: 700,
  lineHeight: "28px",
};
const progressBg: React.CSSProperties = {
  height: 4,
  background: "#e5e7eb",
  borderRadius: 2,
  overflow: "hidden",
};
const progressBar: React.CSSProperties = {
  height: "100%",
  borderRadius: 2,
  transition: "width 0.4s ease",
};
const errorBox: React.CSSProperties = {
  background: "#fef2f2",
  color: "#7f1d1d",
  padding: 8,
  fontSize: 10,
  overflow: "auto",
  margin: "6px 0 0",
  borderRadius: 4,
  maxHeight: 120,
  whiteSpace: "pre-wrap",
  wordBreak: "break-all",
};
const thStyle: React.CSSProperties = {
  textAlign: "left",
  padding: "4px 8px",
  fontSize: 10,
  fontWeight: 600,
  color: "#9ca3af",
  textTransform: "uppercase",
};
const tdStyle: React.CSSProperties = {
  padding: "5px 8px",
  fontSize: 12,
  color: "#374151",
};
const modalOverlay: React.CSSProperties = {
  position: "fixed",
  top: 0, left: 0, right: 0, bottom: 0,
  background: "rgba(0,0,0,0.4)",
  display: "flex",
  alignItems: "center",
  justifyContent: "center",
  zIndex: 100,
  padding: 20,
};
const modalBox: React.CSSProperties = {
  background: "#fff",
  borderRadius: 10,
  padding: 24,
  maxWidth: 1200,
  width: "100%",
  maxHeight: "92vh",
  overflow: "auto",
  boxShadow: "0 20px 60px rgba(0,0,0,0.15)",
};
const durationBanner: React.CSSProperties = {
  display: "flex",
  alignItems: "center",
  justifyContent: "center",
  gap: 0,
  background: "#f9fafb",
  border: "1px solid #e5e7eb",
  borderRadius: 8,
  padding: "14px 20px",
  marginBottom: 20,
};
const durationItem: React.CSSProperties = {
  textAlign: "center",
  flex: 1,
};
const durationLabel: React.CSSProperties = {
  fontSize: 10,
  fontWeight: 600,
  color: "#9ca3af",
  textTransform: "uppercase",
  letterSpacing: "0.04em",
};
const durationValue: React.CSSProperties = {
  fontSize: 18,
  fontWeight: 700,
  color: "#374151",
  lineHeight: "24px",
};
const durationSub: React.CSSProperties = {
  fontSize: 10,
  color: "#b0b7c3",
};
const durationDivider: React.CSSProperties = {
  width: 1,
  height: 36,
  background: "#e5e7eb",
  margin: "0 4px",
};
const sectionLabel: React.CSSProperties = {
  fontSize: 11,
  fontWeight: 600,
  color: "#6b7280",
  textTransform: "uppercase",
  letterSpacing: "0.04em",
  marginBottom: 6,
};
const miniCard: React.CSSProperties = {
  background: "#f9fafb",
  border: "1px solid #e5e7eb",
  borderRadius: 5,
  padding: "5px 8px",
  marginBottom: 5,
};
const miniLabel: React.CSSProperties = {
  fontSize: 10,
  color: "#6b7280",
};

-import { useEffect, useState } from "react";

+import { useEffect, useState, useCallback } from "react";

-const STATUS_COLORS: Record<string, string> = {

-  claimed_awaiting_solospeech: "#2563eb",

+// ── Types ─────────────────────────────────────────────────────────────────

+type AudioFile = { name: string; url: string; duration: number };

-    cleaned: { name: string; url: string }[];

-    overlap_notcleaned: { name: string; url: string }[];

-    overlap_cleaned: { name: string; url: string }[];

+    instances: { id: number; status: string; gpu: string; label: string; cost: number }[];

+// ── Utilities ─────────────────────────────────────────────────────────────

+function fmtDur(secs: number): string {

+  const m = Math.floor((secs % 3600) / 60);

+function timeAgo(dateStr: string): string {

+  const d = new Date(dateStr.includes("Z") ? dateStr : dateStr + "Z");

+  const diff = Math.floor((Date.now() - d.getTime()) / 1000);

+  if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;

+  if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;

+  return `${Math.floor(diff / 86400)}d ago`;

+  "pending", "audio_uploaded", "diarized", "segmented", "awaiting_solospeech", "done",

+const STAGE_LABELS: Record<string, string> = {

+  audio_uploaded: "Running diarization",

+  awaiting_solospeech: "SoloSpeech on GPU",

+const STATUS_COLORS: Record<string, string> = {

+function stageProgress(status: string): number {

+  const idx = STAGE_ORDER.indexOf(status);

+  return Math.round((idx / (STAGE_ORDER.length - 1)) * 100);

+// ── Component ─────────────────────────────────────────────────────────────

const [authed, setAuthed] = useState(false);

const [loginErr, setLoginErr] = useState("");

const [jobs, setJobs] = useState<Job[]>([]);

+  const [stats, setStats] = useState<Stats | null>(null);

const [submitErr, setSubmitErr] = useState("");

const [openJob, setOpenJob] = useState<JobDetail | null>(null);

+  const [lastRefresh, setLastRefresh] = useState("");

+  const [showGpu, setShowGpu] = useState(false);

+  const [showForm, setShowForm] = useState(false);

-    fetch("/api/jobs").then(r => r.ok ? r.json().then(j => { setJobs(j.jobs); setAuthed(true); }) : null);

-    const t = setInterval(refreshJobs, 5000);

+  const refreshJobs = useCallback(async () => {

+    const r = await fetch("/api/jobs");

+      setLastRefresh(new Date().toLocaleTimeString());

+  const refreshStats = useCallback(async () => {

+      const r = await fetch("/api/stats");

+      if (r.ok) setStats(await r.json());

+    const t1 = setInterval(refreshJobs, 5000);

+    const t2 = setInterval(refreshStats, 10000);

+    return () => { clearInterval(t1); clearInterval(t2); };

headers: { "Content-Type": "application/json" },

body: JSON.stringify({ password: pwd }),

-    if (r.ok) { setAuthed(true); refreshJobs(); }

+    if (r.ok) { setAuthed(true); refreshJobs(); refreshStats(); }

-    const r = await fetch("/api/jobs");

-    if (r.ok) setJobs((await r.json()).jobs);

+    if (r.ok) { setUrl(""); setName(""); setShowForm(false); refreshJobs(); }

const j = await r.json().catch(() => ({}));

setSubmitErr(j.error || `error ${r.status}`);

+    if (!confirm("Delete this job?")) return;

await fetch(`/api/job/${id}`, { method: "DELETE" });

if (openJob?.id === id) setOpenJob(null);

+  // ── Login ───────────────────────────────────────────────────────────────

-        <form onSubmit={login} style={{ display: "flex", gap: 8 }}>

-            onChange={e => setPwd(e.target.value)}

-          <button style={btnStyle}>enter</button>

+        <Head><title>karthyani</title></Head>

+        <div style={{ maxWidth: 360, margin: "140px auto", textAlign: "center" }}>

+          <form onSubmit={login} style={{ display: "flex", gap: 8 }}>

+            <input type="password" value={pwd} onChange={e => setPwd(e.target.value)}

+              placeholder="password" style={{ ...inputStyle, flex: 1 }} />

+            <button style={btnPrimary}>enter</button>

+  // ── Main dashboard ──────────────────────────────────────────────────────

+  const doneCount = stats?.counts?.done || 0;

+  const failedCount = stats?.counts?.failed || 0;

+  const processingCount = jobs.filter(j => j.status !== "done" && j.status !== "failed").length;

-
karthyani — speech extraction pipeline

-        Submit a YouTube URL + name. Worker downloads, runs pyannote diarization,

-        builds clean + overlap segments, then runs SoloSpeech on overlaps via Prime Intellect GPU.

+      <Head><title>karthyani dashboard</title></Head>

-      <form onSubmit={submit} style={{ display: "flex", gap: 8, marginBottom: 24 }}>

-          placeholder="https://www.youtube.com/watch?v=…"

-          onChange={e => setUrl(e.target.value)}

-          style={{ ...inputStyle, flex: 2 }}

-          placeholder="job name (folder)"

-          onChange={e => setName(e.target.value)}

-          style={{ ...inputStyle, flex: 1 }}

-        <button style={btnStyle}>submit</button>

+      <div style={{ display: "flex", alignItems: "center", marginBottom: 20 }}>

+          <p style={{ color: "#9ca3af", margin: "2px 0 0", fontSize: 12 }}>

+            YouTube → pyannote diarization → clean segments → SoloSpeech overlap extraction

+        <button style={{ ...btnSecondary, marginRight: 8 }} onClick={() => setShowForm(!showForm)}>

+        <div style={{ fontSize: 10, color: "#b0b7c3", lineHeight: "14px", textAlign: "right" }}>

+          auto-refresh 5s<br />{lastRefresh}

-      {jobs.length === 0 &&
no jobs yet
}

-        <div key={j.id} style={cardStyle}>

-          <div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 8 }}>

-              background: (STATUS_COLORS[j.status] || "#6b7280") + "20",

-              color: STATUS_COLORS[j.status] || "#6b7280",

-            <div style={{ flex: 1 }} />

-              <button style={btnStyleSmall} onClick={() => openDetails(j.id)}>open</button>

-            {j.status === "failed" && (

-              <button style={btnStyleSmall} onClick={() => retry(j.id)}>retry</button>

-            <button style={btnStyleSmall} onClick={() => del(j.id)}>delete</button>

+        <div style={{ ...statCard, borderLeftColor: "#16a34a" }}>

+        <div style={{ ...statCard, borderLeftColor: "#2563eb" }}>

+        <div style={{ ...statCard, borderLeftColor: "#dc2626" }}>

+        <div style={{ ...statCard, borderLeftColor: "#6366f1" }}>

+        <div style={{ ...statCard, borderLeftColor: "#8b5cf6" }}>

+          style={{ ...statCard, borderLeftColor: "#ec4899", cursor: "pointer" }}

+          onClick={() => setShowGpu(!showGpu)}

+
GPU Pods{stats?.gpuPods ? "" : " (no key)"}

+          <div style={{ ...statValue, color: "#ec4899" }}>

+            {stats?.gpuPods?.running ?? "—"}

-          <div style={{ fontSize: 12, color: "#6b7280", marginBottom: 8, wordBreak: "break-all" }}>

+        <div style={{ ...statCard, borderLeftColor: "#14b8a6" }}>

+      {showGpu && stats?.gpuPods && stats.gpuPods.instances.length > 0 && (

+        <div style={{ ...cardStyle, marginBottom: 16, padding: "12px 16px" }}>

+          <div style={{ fontSize: 11, fontWeight: 600, color: "#6b7280", textTransform: "uppercase", marginBottom: 8 }}>

-            <div style={{ fontSize: 12, color: "#6b7280" }}>

-              speakers: {j.speakers_json} · target: {j.target_speaker}

-          {(j.num_clean_segments !== null || j.num_overlap_segments !== null) && (

-              clean: {j.num_clean_segments ?? "?"} · overlap: {j.num_overlap_segments ?? "?"} · cleaned: {j.num_overlap_cleaned ?? 0}

-            <pre style={{ background: "#fef2f2", color: "#7f1d1d", padding: 8, fontSize: 11, overflow: "auto", margin: "8px 0 0", borderRadius: 4 }}>

+          <table style={{ width: "100%", fontSize: 12, borderCollapse: "collapse" }}>

+              <tr style={{ borderBottom: "1px solid #e5e7eb" }}>

+              {stats.gpuPods.instances.map(inst => (

+                <tr key={inst.id} style={{ borderBottom: "1px solid #f3f4f6" }}>

+                  ${inst.cost?.toFixed(3)}

+                      background: inst.status === "running" ? "#dcfce7" : "#fef9c3",

+                    }}>{inst.status}</span>

+          <div style={{ fontSize: 11, color: "#9ca3af", marginTop: 6 }}>

+            Total cost: ${stats.gpuPods.instances.reduce((s, i) => s + (i.cost || 0), 0).toFixed(3)}/hr

-        <div style={modalOverlay} onClick={() => setOpenJob(null)}>

-          <div style={modalStyle} onClick={e => e.stopPropagation()}>

-            <div style={{ display: "flex", alignItems: "center", marginBottom: 16 }}>

+      {/* New job form (collapsible) */}

+        <div style={{ ...cardStyle, marginBottom: 16, padding: "16px 18px" }}>

+          <div style={{ fontSize: 11, fontWeight: 600, color: "#6b7280", textTransform: "uppercase", marginBottom: 10 }}>

+          <form onSubmit={submit} style={{ display: "flex", gap: 8 }}>

+            <input type="url" required placeholder="https://www.youtube.com/watch?v=..."

+              value={url} onChange={e => setUrl(e.target.value)}

+              style={{ ...inputStyle, flex: 2 }} />

+            <input type="text" required placeholder="job name"

+              value={name} onChange={e => setName(e.target.value)}

+              style={{ ...inputStyle, flex: 1 }} />

+            <button style={btnPrimary}>submit</button>

+      <div style={{ display: "flex", alignItems: "center", marginBottom: 10 }}>

+        <div style={{ fontSize: 13, fontWeight: 600, color: "#374151" }}>

+
No jobs yet. Click "+ new job" to get started.

+        const color = STATUS_COLORS[j.status] || "#6b7280";

+        const progress = stageProgress(j.status);

+        const isActive = j.status !== "done" && j.status !== "failed";

+        const speakers = j.speakers_json ? JSON.parse(j.speakers_json) : null;

+        const totalSpeakerDur = speakers

+          ? Object.values(speakers as Record<string, number>).reduce((a: number, b: number) => a + b, 0)

+          <div key={j.id} style={cardStyle}>

+            {/* Row 1: name + badge + time + buttons */}

+            <div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 6 }}>

+                background: color + "18",

+              }}>{j.status.replace(/_/g, " ")}</span>

+              {isActive && j.worker_id && (

+                <span style={{ fontSize: 10, color: "#9ca3af" }}>

-              <button style={btnStyleSmall} onClick={() => setOpenJob(null)}>close</button>

+                <button style={btnSmall} onClick={() => openDetails(j.id)}>open</button>

+              {j.status === "failed" && (

+                <button style={{ ...btnSmall, color: "#dc2626", borderColor: "#fecaca" }}

+                  onClick={() => retry(j.id)}>retry</button>

+              <button style={{ ...btnSmall, color: "#6b7280" }} onClick={() => del(j.id)}>delete</button>

-              <div style={{ marginBottom: 16 }}>

-                <audio controls preload="none" src={openJob.urls.clip} style={{ width: "100%" }} />

+            <div style={{ fontSize: 11, color: "#9ca3af", marginBottom: 4, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>

+            {/* Row 3: speaker info */}

+              <div style={{ fontSize: 11, color: "#6b7280", marginBottom: 4 }}>

+                target: {j.target_speaker}

+                {" "} ({fmtDur(speakers[j.target_speaker!] || 0)} of {fmtDur(totalSpeakerDur)} total)

+                {" · "}{Object.keys(speakers).length} speakers

-            {openJob.urls?.reference && (

-                <audio controls preload="none" src={openJob.urls.reference} style={{ width: "100%" }} />

+            {/* Row 4: segment counts */}

+            {(j.num_clean_segments !== null || j.num_overlap_segments !== null) && (

+              <div style={{ fontSize: 11, color: "#6b7280", marginBottom: 6 }}>

+                clean: <strong>{j.num_clean_segments ?? "?"}</strong>

+                {" · "}overlap: <strong>{j.num_overlap_segments ?? "?"}</strong>

+                {j.num_overlap_cleaned ? <> · cleaned: <strong>{j.num_overlap_cleaned}</strong></> : null}

-            {openJob.urls?.diarize && (

-              <div style={{ marginBottom: 16, fontSize: 12 }}>

+            {/* Progress bar for active jobs */}

+              <div style={{ marginTop: 4 }}>

+                <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 3 }}>

+                  <div style={{ fontSize: 11, color }}>

+                    {STAGE_LABELS[j.status] || j.status}

+                    {j.gpu_instance_id ? ` (GPU #${j.gpu_instance_id})` : ""}

+                <div style={progressBg}>

+                    width: `${progress}%`,

-            <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 16 }}>

-
cleaned/ ({openJob.urls?.cleaned.length || 0})

-                {openJob.urls?.cleaned.map(c => (

-                  <div key={c.name} style={miniCard}>

-                    <audio controls preload="none" src={c.url} style={{ width: "100%", height: 28 }} />

+      {/* ── Detail modal ────────────────────────────────────────────────── */}

+        <div style={modalOverlay} onClick={() => setOpenJob(null)}>

+          <div style={modalBox} onClick={e => e.stopPropagation()}>

+            <div style={{ display: "flex", alignItems: "center", marginBottom: 16 }}>

+                background: (STATUS_COLORS[openJob.status] || "#6b7280") + "18",

+                color: STATUS_COLORS[openJob.status] || "#6b7280",

+              }}>{openJob.status}</span>

+              <div style={{ flex: 1 }} />

+              <button style={btnSmall} onClick={() => setOpenJob(null)}>close</button>

+            {/* Duration summary banner */}

+            {openJob.totalDuration && (

+              <div style={durationBanner}>

+                <div style={durationItem}>

+
{openJob.urls?.cleaned.length || 0} files

+                <div style={durationDivider} />

+
{openJob.urls?.overlap_notcleaned.length || 0} files

+
{openJob.urls?.overlap_cleaned.length || 0} files

+                  <div style={{ ...durationValue, color: "#16a34a", fontSize: 22 }}>

+                    {fmtDur(openJob.totalDuration.cleaned + openJob.totalDuration.overlap_cleaned)}

-
overlap_notcleaned/ ({openJob.urls?.overlap_notcleaned.length || 0})

-                {openJob.urls?.overlap_notcleaned.map(c => (

-                  <div key={c.name} style={miniCard}>

+                  <div style={durationSub}>

+                    {(openJob.urls?.cleaned.length || 0) + (openJob.urls?.overlap_cleaned.length || 0)} files

-
overlap_cleaned/ ({openJob.urls?.overlap_cleaned.length || 0})

-                {openJob.urls?.overlap_cleaned.map(c => (

+            {/* Source audio players */}

+            <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12, marginBottom: 16 }}>

+                  <audio controls preload="none" src={openJob.urls.clip} style={{ width: "100%", height: 32 }} />

+              {openJob.urls?.reference && (

+                  <audio controls preload="none" src={openJob.urls.reference} style={{ width: "100%", height: 32 }} />

+            {openJob.urls?.diarize && (

+              <div style={{ marginBottom: 12, fontSize: 12 }}>

+                <a href={openJob.urls.diarize} target="_blank" rel="noreferrer" style={{ color: "#2563eb" }}>

+            {/* Three columns of audio files */}

+            <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 16 }}>

+                files={openJob.urls?.cleaned || []}

+                totalDur={openJob.totalDuration?.cleaned}

+                title="overlap_notcleaned/"

+                files={openJob.urls?.overlap_notcleaned || []}

+                totalDur={openJob.totalDuration?.overlap_notcleaned}

+                title="overlap_cleaned/"

+                files={openJob.urls?.overlap_cleaned || []}

+                totalDur={openJob.totalDuration?.overlap_cleaned}

+// ── Sub-components ────────────────────────────────────────────────────────

+function AudioColumn({ title, files, totalDur, color }: {

+        fontSize: 11, fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.05em",

+        color: "#6b7280", marginBottom: 4,

+        <div style={{ fontSize: 12, fontWeight: 600, color, marginBottom: 8 }}>

+      <div style={{ maxHeight: 400, overflowY: "auto" }}>

+          <div key={f.name} style={miniCard}>

+            <div style={{ display: "flex", justifyContent: "space-between", marginBottom: 2 }}>

+            <audio controls preload="none" src={f.url} style={{ width: "100%", height: 26 }} />

+// ── Styles ─────────────────────────────────────────────────────────────────

+const pageStyle: React.CSSProperties = {

fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",

+const inputStyle: React.CSSProperties = {

+const btnPrimary: React.CSSProperties = {

+const btnSecondary: React.CSSProperties = {

+const btnSmall: React.CSSProperties = {

+const cardStyle: React.CSSProperties = {

+const badgeStyle: React.CSSProperties = {

+const statsGrid: React.CSSProperties = {

+  gridTemplateColumns: "repeat(auto-fit, minmax(130px, 1fr))",

+const statCard: React.CSSProperties = {

+const statLabel: React.CSSProperties = {

+const statValue: React.CSSProperties = {

+const progressBg: React.CSSProperties = {

+const progressBar: React.CSSProperties = {

+const errorBox: React.CSSProperties = {

+const modalOverlay: React.CSSProperties = {

+const modalBox: React.CSSProperties = {

+const durationBanner: React.CSSProperties = {

+const durationItem: React.CSSProperties = {

+const durationLabel: React.CSSProperties = {

+const durationValue: React.CSSProperties = {

+const durationSub: React.CSSProperties = {

+const durationDivider: React.CSSProperties = {

+const sectionLabel: React.CSSProperties = {

+const miniCard: React.CSSProperties = {

+const miniLabel: React.CSSProperties = {


Now add the VAST_API_KEY to Vercel env vars and deploy:


Bash

cd /home/ubuntu/karthyani_pipeline/dashboard && npx next build 2>&1 | tail -30

Build Next.js to check for errors


  ▲ Next.js 14.2.5

   Linting and checking validity of types ...
   Creating an optimized production build ...
 ✓ Compiled successfully
   Collecting page data ...
   Generating static pages (0/3) ...
 ✓ Generating static pages (3/3)
   Finalizing page optimization ...
   Collecting build traces ...

Route (pages)                             Size     First Load JS
┌ ○ /                                     5 kB           83.1 kB
├ ○ /404                                  180 B          78.3 kB
├ ƒ /api/job/[id]                         0 B            78.1 kB
├ ƒ /api/jobs                             0 B            78.1 kB
├ ƒ /api/login                            0 B            78.1 kB
└ ƒ /api/stats                            0 B            78.1 kB
+ First Load JS shared by all             78.1 kB
  ├ chunks/framework-ecc4130bc7a58a64.js  45.2 kB
  ├ chunks/main-ac52e5f1ea1d2a16.js       32 kB
  └ other shared chunks (total)           947 B

○  (Static)   prerendered as static content
ƒ  (Dynamic)  server-rendered on demand


Build passes. Now let me add the VAST_API_KEY to Vercel and deploy:


which vercel 2>/dev/null || npm list -g vercel 2>/dev/null || echo "vercel CLI not found"

Check if Vercel CLI is available


List current Vercel env vars


Retrieving project…
> Environment Variables found for charans-projects-9fb8fade/karthyani-dashboard [127ms]

 name                       value               environments        created
 R2_SECRET_KEY              Encrypted           Production          2h ago
 R2_ACCESS_KEY              Encrypted           Production          2h ago
 R2_BUCKET                  Encrypted           Production          2h ago
 R2_ENDPOINT                Encrypted           Production          2h ago
 DASHBOARD_PASSWORD         Encrypted           Production          2h ago
 TURSO_TOKEN                Encrypted           Production          2h ago
 TURSO_URL                  Encrypted           Production          2h ago

Common next commands:
- `vercel env add`
- `vercel env rm`
- `vercel env pull`


echo "c7b672898715b5cb4ded9df45ae3db2d9bf634c078e69a98ef0834cf9734030a" | vercel env add VAST_API_KEY production 2>&1

Add VAST_API_KEY to Vercel env vars


Retrieving project…
Retrieving project…
Saving
Added Environment Variable VAST_API_KEY to Project karthyani-dashboard [137ms]
Common next commands:
- `vercel env ls`
- `vercel env pull`


Deploy dashboard to Vercel production


  "deployment": {
    "id": "dpl_3eGUR1chT3q1bZaMvXBCP6UxYd6n",
    "url": "https://karthyani-dashboard-rf4h84bxm-charans-projects-9fb8fade.vercel.app",
    "inspectorUrl": "https://vercel.com/charans-projects-9fb8fade/karthyani-dashboard/3eGUR1chT3q1bZaMvXBCP6UxYd6n",
    "readyState": "READY",
    "target": "production",
    "deploymentApiUrl": "https://api.vercel.com/v13/deployments/dpl_3eGUR1chT3q1bZaMvXBCP6UxYd6n"
  },
  "message": "Deployment karthyani-dashboard-rf4h84bxm-charans-projects-9fb8fade.vercel.app ready.",
  "next": [
    {
      "command": "vercel inspect karthyani-dashboard-rf4h84bxm-charans-projects-9fb8fade.vercel.app",
      "when": "Inspect deployment"
    },
    {
      "command": "vercel deploy --prod",
      "when": "Promote to production"
    }
  ]
}


Promote deployment to production


    "target": "production",
    "deploymentApiUrl": "https://api.vercel.com/v13/deployments/dpl_7hzvQAvsvZSYAfN4jnTb7ijWTqq9"
  },
  "message": "Deployment karthyani-dashboard-geidmfysl-charans-projects-9fb8fade.vercel.app ready.",
  "next": [
    {
      "command": "vercel inspect karthyani-dashboard-geidmfysl-charans-projects-9fb8fade.vercel.app",
      "when": "Inspect deployment"
    },
    {
      "command": "vercel deploy --prod",
      "when": "Promote to production"
    }
  ]
}


Deployed. Dashboard is live at **https://karthyani-dashboard.vercel.app/**

Here's what's new:

**Stats bar** — 7 cards across the top:
- Done / Processing / Failed counts
- Clean Segments / Overlap Cleaned totals (from DB)
- GPU Pods (live from vast.ai API — click to expand and see each instance's GPU, cost/hr, status)
- Active Workers count

**Job cards** — improved:
- Progress bar showing pipeline stage (downloading → diarization → segmenting → SoloSpeech → done)
- Human-readable stage label (e.g. "SoloSpeech on GPU")
- Target speaker duration vs total duration
- Relative timestamps ("5m ago")
- GPU instance ID shown when running SoloSpeech

**Detail modal** — duration banner at top showing:
- Cleaned duration + file count
- Overlap (raw) duration + file count
- Overlap (cleaned) duration + file count
- **Total Usable** = cleaned + overlap_cleaned (highlighted in green)
- Per-file duration shown next to each audio player

**Other:**
- Collapsible "new job" form
- Scrollable audio columns in modal (max 400px)
- Light gray page background with white cards
- Auto-refresh every 5s (jobs) / 10s (stats)


https://www.youtube.com/watch?v=2xzIc-sY-cA - kohli_1
https://www.youtube.com/watch?v=1J0o3v8ptHY - kohli_2
https://www.youtube.com/watch?v=FoMeOqnvU3w - kohli_3
https://www.youtube.com/watch?v=jqUdnuxQijs - samaray_1 can u add these 4 also to the db


https://www.youtube.com/watch?v=2xzIc-sY-cA - kohli_1
https://www.youtube.com/watch?v=1J0o3v8ptHY - kohli_2
https://www.youtube.com/watch?v=FoMeOqnvU3w - kohli_3
https://www.youtube.com/watch?v=jqUdnuxQijs - samaray_1


cd /home/ubuntu/karthyani_pipeline/worker && source .env 2>/dev/null && export TURSO_URL TURSO_TOKEN R2_ENDPOINT R2_BUCKET R2_ACCESS_KEY R2_SECRET_KEY && python3 -c "
import sys, uuid
sys.path.insert(0, '.')
import db

jobs = [
    ('kohli_1', 'https://www.youtube.com/watch?v=2xzIc-sY-cA'),
    ('kohli_2', 'https://www.youtube.com/watch?v=1J0o3v8ptHY'),
    ('kohli_3', 'https://www.youtube.com/watch?v=FoMeOqnvU3w'),
    ('samaray_1', 'https://www.youtube.com/watch?v=jqUdnuxQijs'),
]

for name, url in jobs:
    # Check if already exists
    existing = db.query('SELECT id FROM jobs WHERE name=?', [name])
    if existing:
        print(f'  SKIP {name}: already in DB')
        continue
    job_id = str(uuid.uuid4())
    db.execute(
        \"INSERT INTO jobs (id, name, url, status) VALUES (?, ?, ?, 'pending')\",
        [job_id, name, url],
    )
    print(f'  + {name} inserted (status=pending)')
"

Insert 4 new jobs into Turso DB


  + kohli_1 inserted (status=pending)
  + kohli_2 inserted (status=pending)
  + kohli_3 inserted (status=pending)
  + samaray_1 inserted (status=pending)


All 4 added as `pending`. The workers will pick them up — though note these will use the vidssave.com API for downloading since they're starting from `pending` (not pre-seeded with yt-dlp). If that fails from Railway/worker IPs, you may want to seed them locally like the others.


remove rgv2 and rgv3 we dont need those from db remove keep in r2 if needed we can use later


source .env 2>/dev/null && export TURSO_URL TURSO_TOKEN && python3 -c "
import sys
sys.path.insert(0, '.')
import db

for name in ['rgv_2', 'rgv_3']:
    rows = db.query('SELECT id, status, worker_id FROM jobs WHERE name=?', [name])
    if not rows:
        print(f'{name}: not found')
        continue
    r = rows[0]
    print(f'{name}: status={r[\"status\"]} worker={r[\"worker_id\"] or \"none\"}')
    db.execute('DELETE FROM jobs WHERE id=?', [r['id']])
    print(f'  deleted from DB')
"

Delete rgv_2 and rgv_3 from Turso DB


rgv_2: status=awaiting_solospeech worker=0321-dsm2-nvdgxa100-prxmx70052-1635285-d9c8be
  deleted from DB
rgv_3: status=awaiting_solospeech worker=0321-dsm2-nvdgxa100-prxmx70052-1635288-b74568
  deleted from DB


Done. rgv_2 and rgv_3 removed from DB. R2 files kept.

Note: those two had workers running SoloSpeech on vast.ai GPUs — the workers will notice the job is gone and the GPU instances should get destroyed automatically when the worker finishes/errors.


and also see if overlap audios are less than 4 sec remove thsoe dont fucking push to r2 also


and also see if overlap audios are less than 4 sec remove thsoe dont fucking push to r2 also and see if overlap audios toatl durations combined is greate than 5 mins only do solospeech other wise ignore


and also see if overlap audios are less than 4 sec remove thsoe dont fucking push to r2 also and see if overlap audios toatl durations combined is greate than 5 mins only do solospeech other wise ignore and mention this  in dashbaord aslo


The user wants two changes to the worker pipeline:

1. Filter out overlap segments shorter than 4 seconds - don't upload them to R2
2. Only run SoloSpeech if total overlap duration > 5 minutes, otherwise skip it and go straight to done
3. Show this info on the dashboard

Let me look at the relevant code:

1. `worker/audio.py` - `build_segments()` function where overlap segments are created
2. `worker/worker.py` - `step_segment()` and the pipeline logic that decides whether to run SoloSpeech

For change 1: In `audio.py`, the `MIN_DUR` is currently 0.0 for all segments. But the user only wants to filter overlaps, not clean segments.

So I need to add a separate minimum duration threshold specifically for overlap segments. Looking at the request again - I should filter out any overlap segments under 4 seconds and prevent them from being created or uploaded to R2.

For change 2: After segmentation completes, I'll check if the total overlap duration is less than 5 minutes (300 seconds), and if so, skip the SoloSpeech step entirely and move to done.

For change 3: I need to update the dashboard to show when SoloSpeech was skipped due to insufficient overlap duration.

Now I'll modify the code - adding a MIN_OVERLAP_DUR constant to `audio.py`...

Examining the overlap segment creation logic more closely...

Filtering overlap segments by duration...

I need to modify `build_segments` to return the total overlap duration alongside the segment counts, then use that in the worker to decide whether to proceed with solospeech or skip it entirely. If the combined overlap duration falls below 300 seconds, there's not enough material to process, so I'll route directly to done instead of awaiting solospeech. For the dashboard, I'll need to track and display when solospeech gets skipped due to insufficient overlap data.

Implementing the changes...

I need to thread the total overlap duration through the return values: `build_segments()` will include `overlap_total_dur` in its dict, then `step_segment()` passes that up, and finally `process_job()` uses it to decide whether to skip SoloSpeech based on the 300-second threshold.

For the filtering logic, I'm setting `MIN_DUR=0.0` for clean segments since only overlap segments under 4 seconds should be filtered out, and the total overlap duration needs to exceed 5 minutes to justify running SoloSpeech.

Implementing this now...


Two changes: filter out overlap segments < 4s, and skip SoloSpeech if total overlap < 5 min. Let me update the worker and dashboard.


8
9	SR = 16000
10	MERGE_GAP = 2.0  # merge adjacent same-speaker turns when gap < this
11	MIN_DUR = 0.0    # no minimum duration filter — keep everything
12

SR = 16000
MERGE_GAP = 2.0  # merge adjacent same-speaker turns when gap < this
MIN_DUR = 0.0    # no minimum duration filter — keep everything


Edit

SR = 16000
MERGE_GAP = 2.0  # merge adjacent same-speaker turns when gap < this
MIN_DUR = 0.0    # no minimum duration filter — keep everything

SR = 16000
MERGE_GAP = 2.0  # merge adjacent same-speaker turns when gap < this
MIN_DUR = 0.0    # no minimum duration filter for clean segments
MIN_OVERLAP_DUR = 4.0  # drop overlap segments shorter than 4s


The file /home/ubuntu/karthyani_pipeline/worker/audio.py has been updated successfully.

SR = 16000
MERGE_GAP = 2.0  # merge adjacent same-speaker turns when gap < this
MIN_DUR = 0.0    # no minimum duration filter for clean segments
MIN_OVERLAP_DUR = 4.0  # drop overlap segments shorter than 4s

"""YouTube audio download (via vidssave.com API — works from cloud IPs)
+ ffmpeg conversion + diarization-driven segmentation."""
import json, os, shutil, subprocess
from pathlib import Path
import numpy as np
import requests
import soundfile as sf

SR = 16000
MERGE_GAP = 2.0  # merge adjacent same-speaker turns when gap < this
MIN_DUR = 0.0    # no minimum duration filter — keep everything

VIDSSAVE_PARSE = "https://api.vidssave.com/api/contentsite_api/media/parse"
VIDSSAVE_HEADERS = {
    "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
                  "(KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
    "Origin": "https://vidssave.com",
    "Referer": "https://vidssave.com/",
}
VIDSSAVE_AUTH = "20250901majwlqo"
VIDSSAVE_DOMAIN = "api-ak.vidssave.com"

def yt_dlp(url: str, out_dir: Path) -> Path:
    """Download YouTube audio via vidssave.com (works from datacenter IPs).
    Returns path to source.m4a."""
    out_dir.mkdir(parents=True, exist_ok=True)

    # 1. parse
    r = requests.post(
        VIDSSAVE_PARSE,
        headers={**VIDSSAVE_HEADERS, "Content-Type": "application/x-www-form-urlencoded"},
        data={
            "auth": VIDSSAVE_AUTH,
            "domain": VIDSSAVE_DOMAIN,
            "originsource": "",
            "link": url,
        },
        timeout=60,
    )
    if r.status_code != 200:
        raise RuntimeError(f"vidssave parse failed {r.status_code}: {r.text[:500]}")
    j = r.json()
    data = j.get("data") or {}
    resources = data.get("resources") or []
    audio_resources = [x for x in resources if x.get("type") == "audio"]
    if not audio_resources:
        raise RuntimeError(f"vidssave returned no audio resources for {url}: {json.dumps(j)[:500]}")
    # Pick highest-quality audio (sorted by size, biggest = best)
    audio_resources.sort(key=lambda x: x.get("size", 0), reverse=True)
    chosen = audio_resources[0]
    download_url = chosen["download_url"]
    fmt = (chosen.get("format") or "m4a").lower()
    out_path = out_dir / f"source.{fmt}"

    # 2. download (streaming, save to disk)
    with requests.get(download_url, headers=VIDSSAVE_HEADERS, stream=True, timeout=600) as dl:
        if dl.status_code != 200:
            raise RuntimeError(f"vidssave download failed {dl.status_code}: {dl.text[:300]}")
        with open(out_path, "wb") as f:
            for chunk in dl.iter_content(chunk_size=1024 * 1024):
                if chunk:
                    f.write(chunk)
    if not out_path.exists() or out_path.stat().st_size < 1024:
        raise RuntimeError(f"vidssave download produced no file at {out_path}")
    return out_path

def to_wav_16k_mono(src: Path, dst: Path):
    cmd = ["ffmpeg", "-y", "-i", str(src),
           "-ar", str(SR), "-ac", "1", "-c:a", "pcm_s16le",
           str(dst)]
    try:
        subprocess.run(cmd, check=True, capture_output=True, text=True)
    except subprocess.CalledProcessError as e:
        raise RuntimeError(
            f"ffmpeg failed (rc={e.returncode}):\nSTDERR: {e.stderr[:1500]}"
        ) from e

def build_segments(clip_wav: Path, diarization: list, target_speaker: str,
                   work: Path) -> dict:
    """From clip.wav + diarization turns, build:
       - clean/clean_NNNN.wav: target speaker turns minus loose-cut overlap windows
       - overlap_notcleaned/overlap_NNNN.wav: loose-cut overlap windows
       Adjacent same-speaker turns are merged when gap < MERGE_GAP.
       MIN_DUR filter applied last.
       Returns counts.
    """
    audio, sr = sf.read(clip_wav)
    if sr != SR:
        raise RuntimeError(f"expected {SR} Hz, got {sr}")
    n = len(audio)

    turns_sorted = sorted(diarization, key=lambda t: t["start"])

    # 1. Loose-cut overlap windows
    raw_overlaps = []
    for i, a in enumerate(turns_sorted):
        for b in turns_sorted[i + 1:]:
            if b["start"] >= a["end"]:
                break
            if a["speaker"] == b["speaker"]:
                continue
            raw_overlaps.append((min(a["start"], b["start"]),
                                 max(a["end"], b["end"])))
    raw_overlaps.sort()
    merged_ov = []
    for s, e in raw_overlaps:
        if merged_ov and s <= merged_ov[-1][1] + MERGE_GAP:
            merged_ov[-1] = (merged_ov[-1][0], max(merged_ov[-1][1], e))
        else:
            merged_ov.append((s, e))

    # 2. Target-speaker turns — MERGE adjacent same-speaker turns when gap < MERGE_GAP
    sp_turns = sorted(
        (t["start"], t["end"]) for t in turns_sorted if t["speaker"] == target_speaker
    )
    mt = []
    for s, e in sp_turns:
        if mt and s <= mt[-1][1] + MERGE_GAP:
            mt[-1] = (mt[-1][0], max(mt[-1][1], e))
        else:
            mt.append((s, e))

    # 3. Clean intervals = target turns - loose overlaps
    def subtract(intervals, holes):
        out = []
        for s, e in intervals:
            cur = [(s, e)]
            for hs, he in holes:
                new = []
                for cs, ce in cur:
                    if he <= cs or hs >= ce:
                        new.append((cs, ce)); continue
                    if hs > cs: new.append((cs, hs))
                    if he < ce: new.append((he, ce))
                cur = new
            out.extend(cur)
        return out
    clean = subtract(mt, merged_ov)

    # 4. Write files
    clean_dir = work / "cleaned"
    ov_dir = work / "overlap_notcleaned"
    clean_dir.mkdir(exist_ok=True)
    ov_dir.mkdir(exist_ok=True)

    n_clean = 0
    for s, e in sorted(clean):
        if MIN_DUR > 0 and (e - s) < MIN_DUR:
            continue
        i0 = max(0, int(round(s * SR)))
        i1 = min(n, int(round(e * SR)))
        if i1 <= i0:
            continue
        out = audio[i0:i1].astype(np.float32)
        sf.write(str(clean_dir / f"clean_{n_clean:04d}.wav"), out, SR, subtype="PCM_16")
        n_clean += 1

    n_ov = 0
    for s, e in merged_ov:
        if MIN_DUR > 0 and (e - s) < MIN_DUR:
            continue
        i0 = max(0, int(round(s * SR)))
        i1 = min(n, int(round(e * SR)))
        if i1 <= i0:
            continue
        out = audio[i0:i1].astype(np.float32)
        sf.write(str(ov_dir / f"overlap_{n_ov:04d}.wav"), out, SR, subtype="PCM_16")
        n_ov += 1

    return {"clean": n_clean, "overlap": n_ov,
            "clean_dir": str(clean_dir), "overlap_dir": str(ov_dir)}

-MIN_DUR = 0.0    # no minimum duration filter — keep everything

+MIN_DUR = 0.0    # no minimum duration filter for clean segments

+MIN_OVERLAP_DUR = 4.0  # drop overlap segments shorter than 4s


Now update the overlap writing loop to filter < 4s and track total duration:


    n_ov = 0
    for s, e in merged_ov:
        if MIN_DUR > 0 and (e - s) < MIN_DUR:
            continue
        i0 = max(0, int(round(s * SR)))
        i1 = min(n, int(round(e * SR)))
        if i1 <= i0:
            continue
        out = audio[i0:i1].astype(np.float32)
        sf.write(str(ov_dir / f"overlap_{n_ov:04d}.wav"), out, SR, subtype="PCM_16")
        n_ov += 1

    return {"clean": n_clean, "overlap": n_ov,
            "clean_dir": str(clean_dir), "overlap_dir": str(ov_dir)}

    n_ov = 0
    ov_total_dur = 0.0
    for s, e in merged_ov:
        dur = e - s
        if dur < MIN_OVERLAP_DUR:
            continue
        i0 = max(0, int(round(s * SR)))
        i1 = min(n, int(round(e * SR)))
        if i1 <= i0:
            continue
        out = audio[i0:i1].astype(np.float32)
        sf.write(str(ov_dir / f"overlap_{n_ov:04d}.wav"), out, SR, subtype="PCM_16")
        n_ov += 1
        ov_total_dur += dur

    return {"clean": n_clean, "overlap": n_ov, "overlap_total_dur": round(ov_total_dur, 2),
            "clean_dir": str(clean_dir), "overlap_dir": str(ov_dir)}


    n_ov = 0
    ov_total_dur = 0.0
    for s, e in merged_ov:
        dur = e - s
        if dur < MIN_OVERLAP_DUR:
            continue
        i0 = max(0, int(round(s * SR)))
        i1 = min(n, int(round(e * SR)))
        if i1 <= i0:
            continue
        out = audio[i0:i1].astype(np.float32)
        sf.write(str(ov_dir / f"overlap_{n_ov:04d}.wav"), out, SR, subtype="PCM_16")
        n_ov += 1
        ov_total_dur += dur

    return {"clean": n_clean, "overlap": n_ov, "overlap_total_dur": round(ov_total_dur, 2),
            "clean_dir": str(clean_dir), "overlap_dir": str(ov_dir)}

"""YouTube audio download (via vidssave.com API — works from cloud IPs)
+ ffmpeg conversion + diarization-driven segmentation."""
import json, os, shutil, subprocess
from pathlib import Path
import numpy as np
import requests
import soundfile as sf

SR = 16000
MERGE_GAP = 2.0  # merge adjacent same-speaker turns when gap < this
MIN_DUR = 0.0    # no minimum duration filter for clean segments
MIN_OVERLAP_DUR = 4.0  # drop overlap segments shorter than 4s

VIDSSAVE_PARSE = "https://api.vidssave.com/api/contentsite_api/media/parse"
VIDSSAVE_HEADERS = {
    "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
                  "(KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
    "Origin": "https://vidssave.com",
    "Referer": "https://vidssave.com/",
}
VIDSSAVE_AUTH = "20250901majwlqo"
VIDSSAVE_DOMAIN = "api-ak.vidssave.com"

def yt_dlp(url: str, out_dir: Path) -> Path:
    """Download YouTube audio via vidssave.com (works from datacenter IPs).
    Returns path to source.m4a."""
    out_dir.mkdir(parents=True, exist_ok=True)

    # 1. parse
    r = requests.post(
        VIDSSAVE_PARSE,
        headers={**VIDSSAVE_HEADERS, "Content-Type": "application/x-www-form-urlencoded"},
        data={
            "auth": VIDSSAVE_AUTH,
            "domain": VIDSSAVE_DOMAIN,
            "originsource": "",
            "link": url,
        },
        timeout=60,
    )
    if r.status_code != 200:
        raise RuntimeError(f"vidssave parse failed {r.status_code}: {r.text[:500]}")
    j = r.json()
    data = j.get("data") or {}
    resources = data.get("resources") or []
    audio_resources = [x for x in resources if x.get("type") == "audio"]
    if not audio_resources:
        raise RuntimeError(f"vidssave returned no audio resources for {url}: {json.dumps(j)[:500]}")
    # Pick highest-quality audio (sorted by size, biggest = best)
    audio_resources.sort(key=lambda x: x.get("size", 0), reverse=True)
    chosen = audio_resources[0]
    download_url = chosen["download_url"]
    fmt = (chosen.get("format") or "m4a").lower()
    out_path = out_dir / f"source.{fmt}"

    # 2. download (streaming, save to disk)
    with requests.get(download_url, headers=VIDSSAVE_HEADERS, stream=True, timeout=600) as dl:
        if dl.status_code != 200:
            raise RuntimeError(f"vidssave download failed {dl.status_code}: {dl.text[:300]}")
        with open(out_path, "wb") as f:
            for chunk in dl.iter_content(chunk_size=1024 * 1024):
                if chunk:
                    f.write(chunk)
    if not out_path.exists() or out_path.stat().st_size < 1024:
        raise RuntimeError(f"vidssave download produced no file at {out_path}")
    return out_path

def to_wav_16k_mono(src: Path, dst: Path):
    cmd = ["ffmpeg", "-y", "-i", str(src),
           "-ar", str(SR), "-ac", "1", "-c:a", "pcm_s16le",
           str(dst)]
    try:
        subprocess.run(cmd, check=True, capture_output=True, text=True)
    except subprocess.CalledProcessError as e:
        raise RuntimeError(
            f"ffmpeg failed (rc={e.returncode}):\nSTDERR: {e.stderr[:1500]}"
        ) from e

def build_segments(clip_wav: Path, diarization: list, target_speaker: str,
                   work: Path) -> dict:
    """From clip.wav + diarization turns, build:
       - clean/clean_NNNN.wav: target speaker turns minus loose-cut overlap windows
       - overlap_notcleaned/overlap_NNNN.wav: loose-cut overlap windows
       Adjacent same-speaker turns are merged when gap < MERGE_GAP.
       MIN_DUR filter applied last.
       Returns counts.
    """
    audio, sr = sf.read(clip_wav)
    if sr != SR:
        raise RuntimeError(f"expected {SR} Hz, got {sr}")
    n = len(audio)

    turns_sorted = sorted(diarization, key=lambda t: t["start"])

    # 1. Loose-cut overlap windows
    raw_overlaps = []
    for i, a in enumerate(turns_sorted):
        for b in turns_sorted[i + 1:]:
            if b["start"] >= a["end"]:
                break
            if a["speaker"] == b["speaker"]:
                continue
            raw_overlaps.append((min(a["start"], b["start"]),
                                 max(a["end"], b["end"])))
    raw_overlaps.sort()
    merged_ov = []
    for s, e in raw_overlaps:
        if merged_ov and s <= merged_ov[-1][1] + MERGE_GAP:
            merged_ov[-1] = (merged_ov[-1][0], max(merged_ov[-1][1], e))
        else:
            merged_ov.append((s, e))

    # 2. Target-speaker turns — MERGE adjacent same-speaker turns when gap < MERGE_GAP
    sp_turns = sorted(
        (t["start"], t["end"]) for t in turns_sorted if t["speaker"] == target_speaker
    )
    mt = []
    for s, e in sp_turns:
        if mt and s <= mt[-1][1] + MERGE_GAP:
            mt[-1] = (mt[-1][0], max(mt[-1][1], e))
        else:
            mt.append((s, e))

    # 3. Clean intervals = target turns - loose overlaps
    def subtract(intervals, holes):
        out = []
        for s, e in intervals:
            cur = [(s, e)]
            for hs, he in holes:
                new = []
                for cs, ce in cur:
                    if he <= cs or hs >= ce:
                        new.append((cs, ce)); continue
                    if hs > cs: new.append((cs, hs))
                    if he < ce: new.append((he, ce))
                cur = new
            out.extend(cur)
        return out
    clean = subtract(mt, merged_ov)

    # 4. Write files
    clean_dir = work / "cleaned"
    ov_dir = work / "overlap_notcleaned"
    clean_dir.mkdir(exist_ok=True)
    ov_dir.mkdir(exist_ok=True)

    n_clean = 0
    for s, e in sorted(clean):
        if MIN_DUR > 0 and (e - s) < MIN_DUR:
            continue
        i0 = max(0, int(round(s * SR)))
        i1 = min(n, int(round(e * SR)))
        if i1 <= i0:
            continue
        out = audio[i0:i1].astype(np.float32)
        sf.write(str(clean_dir / f"clean_{n_clean:04d}.wav"), out, SR, subtype="PCM_16")
        n_clean += 1

    n_ov = 0
    for s, e in merged_ov:
        if MIN_DUR > 0 and (e - s) < MIN_DUR:
            continue
        i0 = max(0, int(round(s * SR)))
        i1 = min(n, int(round(e * SR)))
        if i1 <= i0:
            continue
        out = audio[i0:i1].astype(np.float32)
        sf.write(str(ov_dir / f"overlap_{n_ov:04d}.wav"), out, SR, subtype="PCM_16")
        n_ov += 1

    return {"clean": n_clean, "overlap": n_ov,
            "clean_dir": str(clean_dir), "overlap_dir": str(ov_dir)}

-        if MIN_DUR > 0 and (e - s) < MIN_DUR:

-    return {"clean": n_clean, "overlap": n_ov,

+    return {"clean": n_clean, "overlap": n_ov, "overlap_total_dur": round(ov_total_dur, 2),


Now update the worker to skip SoloSpeech if total overlap < 5 min: