1	import { useEffect, useState, useCallback } from "react";
2	import Head from "next/head";
3
4	// ── Types ─────────────────────────────────────────────────────────────────
5
6	type Job = {
7	  id: string;
8	  name: string;
9	  url: string;
10	  status: string;
11	  r2_prefix: string | null;
12	  target_speaker: string | null;
13	  speakers_json: string | null;
14	  num_clean_segments: number | null;
15	  num_overlap_segments: number | null;
16	  num_overlap_cleaned: number | null;
17	  error: string | null;
18	  created_at: string;
19	  updated_at: string;
20	  worker_id: string | null;
21	  gpu_instance_id: string | null;
22	  failed_at_status: string | null;
23	};
24
25	type AudioFile = { name: string; url: string; duration: number };
26
27	type JobDetail = Job & {
28	  urls?: {
29	    clip: string | null;
30	    diarize: string | null;
31	    reference: string | null;
32	    cleaned: AudioFile[];
33	    overlap_notcleaned: AudioFile[];
34	    overlap_cleaned: AudioFile[];
35	  };
36	  totalDuration?: {
37	    clip: number;
38	    cleaned: number;
39	    overlap_notcleaned: number;
40	    overlap_cleaned: number;
41	  };
42	};
43
44	type Stats = {
45	  counts: Record<string, number>;
46	  totalClean: number;
47	  totalOverlap: number;
48	  totalCleaned: number;
49	  activeWorkers: number;
50	  gpuPods: {

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?: {
    clip: number;
    cleaned: number;
    overlap_notcleaned: number;
    overlap_cleaned: number;
  };
};

type Stats = {
  counts: Record<string, number>;
  totalClean: number;
  totalOverlap: number;
  totalCleaned: number;
  activeWorkers: number;
  gpuPods: {


50	  gpuPods: {
51	    running: number;
52	    total: number;
53	    instances: { id: number; status: string; gpu: string; label: string; cost: number }[];
54	  } | null;
55	};
56
57	// ── Utilities ─────────────────────────────────────────────────────────────
58
59	function fmtDur(secs: number): string {
60	  if (!secs || secs <= 0) return "0s";
61	  const h = Math.floor(secs / 3600);
62	  const m = Math.floor((secs % 3600) / 60);
63	  const s = Math.round(secs % 60);
64	  if (h > 0) return `${h}h ${m}m`;
65	  if (m > 0) return `${m}m ${s}s`;
66	  return `${s}s`;
67	}
68
69	function timeAgo(dateStr: string): string {
70	  if (!dateStr) return "";
71	  const d = new Date(dateStr.includes("Z") ? dateStr : dateStr + "Z");
72	  const diff = Math.floor((Date.now() - d.getTime()) / 1000);
73	  if (diff < 60) return "just now";
74	  if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
75	  if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
76	  return `${Math.floor(diff / 86400)}d ago`;
77	}
78
79	const STAGE_ORDER = [
80	  "pending", "audio_uploaded", "diarized", "segmented", "awaiting_solospeech", "done",
81	];
82	const STAGE_LABELS: Record<string, string> = {
83	  pending: "Downloading audio",
84	  audio_uploaded: "Running diarization",
85	  diarized: "Segmenting audio",
86	  segmented: "Preparing SoloSpeech",
87	  awaiting_solospeech: "SoloSpeech on GPU",
88	  done: "Complete",
89	  failed: "Failed",
90	};
91	const STATUS_COLORS: Record<string, string> = {
92	  pending: "#ca8a04",
93	  audio_uploaded: "#d97706",
94	  diarized: "#2563eb",
95	  segmented: "#2563eb",
96	  awaiting_solospeech: "#7c3aed",
97	  done: "#16a34a",
98	  failed: "#dc2626",
99	};
100
101	function stageProgress(status: string): number {
102	  if (status === "done") return 100;
103	  if (status === "failed") return 0;
104	  const idx = STAGE_ORDER.indexOf(status);
105	  if (idx < 0) return 0;
106	  return Math.round((idx / (STAGE_ORDER.length - 1)) * 100);
107	}
108
109	// ── Component ─────────────────────────────────────────────────────────────
110
111	export default function Home() {
112	  const [authed, setAuthed] = useState(false);
113	  const [pwd, setPwd] = useState("");
114	  const [loginErr, setLoginErr] = useState("");
115	  const [jobs, setJobs] = useState<Job[]>([]);
116	  const [stats, setStats] = useState<Stats | null>(null);
117	  const [url, setUrl] = useState("");
118	  const [name, setName] = useState("");
119	  const [submitErr, setSubmitErr] = useState("");

  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("");


119	  const [submitErr, setSubmitErr] = useState("");
120	  const [openJob, setOpenJob] = useState<JobDetail | null>(null);
121	  const [lastRefresh, setLastRefresh] = useState("");
122	  const [showGpu, setShowGpu] = useState(false);
123	  const [showForm, setShowForm] = useState(false);
124
125	  const refreshJobs = useCallback(async () => {
126	    const r = await fetch("/api/jobs");
127	    if (r.ok) {
128	      const data = await r.json();
129	      setJobs(data.jobs);
130	      setAuthed(true);
131	      setLastRefresh(new Date().toLocaleTimeString());
132	    }
133	  }, []);
134
135	  const refreshStats = useCallback(async () => {
136	    try {
137	      const r = await fetch("/api/stats");
138	      if (r.ok) setStats(await r.json());
139	    } catch {}
140	  }, []);
141
142	  useEffect(() => {
143	    refreshJobs();
144	    refreshStats();
145	    const t1 = setInterval(refreshJobs, 5000);
146	    const t2 = setInterval(refreshStats, 10000);
147	    return () => { clearInterval(t1); clearInterval(t2); };
148	  }, [refreshJobs, refreshStats]);

  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]);


Now I have a good picture of the full file. Let me rewrite it with tabs and the Final Audio view:


148	  }, [refreshJobs, refreshStats]);
149
150	  async function login(e: any) {
151	    e.preventDefault();
152	    setLoginErr("");
153	    const r = await fetch("/api/login", {
154	      method: "POST",
155	      headers: { "Content-Type": "application/json" },
156	      body: JSON.stringify({ password: pwd }),
157	    });
158	    if (r.ok) { setAuthed(true); refreshJobs(); refreshStats(); }
159	    else setLoginErr("wrong password");
160	  }
161
162	  async function submit(e: any) {
163	    e.preventDefault();
164	    setSubmitErr("");
165	    const r = await fetch("/api/jobs", {
166	      method: "POST",
167	      headers: { "Content-Type": "application/json" },
168	      body: JSON.stringify({ url, name }),
169	    });
170	    if (r.ok) { setUrl(""); setName(""); setShowForm(false); refreshJobs(); }
171	    else {
172	      const j = await r.json().catch(() => ({}));
173	      setSubmitErr(j.error || `error ${r.status}`);
174	    }
175	  }
176
177	  async function retry(id: string) {
178	    await fetch(`/api/job/${id}`, {
179	      method: "PATCH",
180	      headers: { "Content-Type": "application/json" },
181	      body: JSON.stringify({ action: "retry" }),
182	    });
183	    refreshJobs();
184	  }
185
186	  async function del(id: string) {
187	    if (!confirm("Delete this job?")) return;
188	    await fetch(`/api/job/${id}`, { method: "DELETE" });
189	    if (openJob?.id === id) setOpenJob(null);
190	    refreshJobs();
191	  }
192
193	  async function openDetails(id: string) {
194	    const r = await fetch(`/api/job/${id}`);
195	    if (r.ok) setOpenJob(await r.json());
196	  }
197
198	  // ── Login ───────────────────────────────────────────────────────────────
199
200	  if (!authed) {
201	    return (
202

203	        <title>karthyani</title>
204

205
karthyani

206
speech extraction pipeline

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

214

215	    );
216	  }
217
218	  // ── Main dashboard ──────────────────────────────────────────────────────
219
220	  const doneCount = stats?.counts?.done || 0;
221	  const failedCount = stats?.counts?.failed || 0;
222	  const processingCount = jobs.filter(j => j.status !== "done" && j.status !== "failed").length;
223
224	  return (
225

226	      <title>karthyani dashboard</title>
227
228	      {/* Header */}
229

230

231
karthyani

232

233	            YouTube → pyannote diarization → clean segments → SoloSpeech overlap extraction
234

235

236

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

241	          auto-refresh 5s
{lastRefresh}
242

243

244
245	      {/* Stats bar */}
246

247

248
Done

249
{doneCount}

250

251

252
Processing

253
{processingCount}

254

255

256
Failed

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

258

259

260
Clean Segments

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

262

263

264
Overlap Cleaned

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

266

267
 setShowGpu(!showGpu)}
270	        >
271
GPU Pods{stats?.gpuPods ? "" : " (no key)"}

272

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

275

276

277
Active Workers

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

279

280

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

285

286	            Vast.ai Instances
287

288

289

290

291	                ID
292	                GPU
293	                Label
294	                $/hr
295	                Status
296

297

298

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

301	                  {inst.id}
302	                  {inst.gpu}
303	                  {inst.label || "—"}
304	                  ${inst.cost?.toFixed(3)}
305
306	                    {inst.status}
311
312

313	              ))}
314

315

316

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

319

320	      )}
321
322	      {/* New job form (collapsible) */}
323	      {showForm && (
324

325

326	            Submit new job
327

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

339	      )}
340
341	      {/* Job list */}
342

343

344	          Jobs ({jobs.length})
345

346

347
348	      {jobs.length === 0 && (
349
No jobs yet. Click "+ new job" to get started.

350	      )}
351
352	      {jobs.map(j => {
353	        const color = STATUS_COLORS[j.status] || "#6b7280";
354	        const progress = stageProgress(j.status);
355	        const isActive = j.status !== "done" && j.status !== "failed";
356	        const speakers = j.speakers_json ? JSON.parse(j.speakers_json) : null;
357	        const totalSpeakerDur = speakers
358	          ? Object.values(speakers as Record<string, number>).reduce((a: number, b: number) => a + b, 0)
359	          : 0;
360
361	        return (
362

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

365	              {j.name}
366	              {j.status.replace(/_/g, " ")}
371	              {isActive && j.worker_id && (
372
373	                  worker active
374
375	              )}
376	              {timeAgo(j.updated_at)}
377

378	              {j.r2_prefix && (
379	                <button style={btnSmall} onClick={() => openDetails(j.id)}>open</button>
380	              )}
381	              {j.status === "failed" && (
382	                <button style={{ ...btnSmall, color: "#dc2626", borderColor: "#fecaca" }}
383	                  onClick={() => retry(j.id)}>retry</button>
384	              )}
385	              <button style={{ ...btnSmall, color: "#6b7280" }} onClick={() => del(j.id)}>delete</button>
386

387
388	            {/* Row 2: URL */}
389

390	              {j.url}
391

392
393	            {/* Row 3: speaker info */}
394	            {speakers && (
395

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

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

405	                clean: {j.num_clean_segments ?? "?"}
406	                {" · "}overlap: {j.num_overlap_segments ?? "?"}
407	                {j.num_overlap_cleaned ? <> · cleaned: {j.num_overlap_cleaned}</> : null}
408	                {j.status === "done" && (j.num_overlap_segments || 0) > 0 && !j.num_overlap_cleaned && (
409
410	                    SoloSpeech skipped (overlap < 5 min)
411
412	                )}
413

414	            )}
415
416	            {/* Progress bar for active jobs */}
417	            {isActive && (
418

419

420

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

424

425

426	                  <div style={{
427	                    ...progressBar,
428	                    width: `${progress}%`,
429	                    background: color,
430	                  }} />
431

432

433	            )}
434
435	            {/* Error or note */}
436	            {j.error && (
437
{j.error}

438	            )}
439

440	        );
441	      })}
442
443	      {/* ── Detail modal ────────────────────────────────────────────────── */}
444	      {openJob && (
445
 setOpenJob(null)}>
446
 e.stopPropagation()}>
447	            {/* Modal header */}
448

449
{openJob.name}

450	              {openJob.status}
456

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

459
460	            {/* Duration summary banner */}
461	            {openJob.totalDuration && (
462

463

464
Original Clip

465
{fmtDur(openJob.totalDuration.clip)}

466

467

468

469
Cleaned

470
{fmtDur(openJob.totalDuration.cleaned)}

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

472

473

474

475
Overlap (raw)

476
{fmtDur(openJob.totalDuration.overlap_notcleaned)}

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

478

479

480

481
Overlap (cleaned)

482
{fmtDur(openJob.totalDuration.overlap_cleaned)}

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

484

485

486

487
Total Usable

488

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

491

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

494

495

496	            )}
497
498	            {/* Source audio players */}
499

500	              {openJob.urls?.clip && (
501

502
Original clip

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

505	              )}
506	              {openJob.urls?.reference && (
507

508
Reference (target speaker)

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

511	              )}
512

513
514	            {openJob.urls?.diarize && (
515

516
517	                  diarize.json
518
519

520	            )}
521
522	            {/* Three columns of audio files */}
523

524	              <AudioColumn
525	                title="cleaned/"
526	                files={openJob.urls?.cleaned || []}
527	                totalDur={openJob.totalDuration?.cleaned}
528	                color="#16a34a"
529	              />
530	              <AudioColumn
531	                title="overlap_notcleaned/"
532	                files={openJob.urls?.overlap_notcleaned || []}
533	                totalDur={openJob.totalDuration?.overlap_notcleaned}
534	                color="#d97706"
535	              />
536	              <AudioColumn
537	                title="overlap_cleaned/"
538	                files={openJob.urls?.overlap_cleaned || []}
539	                totalDur={openJob.totalDuration?.overlap_cleaned}
540	                color="#7c3aed"
541	              />
542

543

544

545	      )}
546

547	  );
548	}
549
550	// ── Sub-components ────────────────────────────────────────────────────────
551
552	function AudioColumn({ title, files, totalDur, color }: {
553	  title: string;
554	  files: AudioFile[];
555	  totalDur?: number;
556	  color: string;
557	}) {
558	  return (
559

560

564	        {title} ({files.length})
565

566	      {totalDur !== undefined && (
567

568	          {fmtDur(totalDur)}
569

570	      )}
571

572	        {files.map(f => (
573

574

575	              {f.name}
576	              {fmtDur(f.duration)}
577

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

580	        ))}
581

582

583	  );
584	}
585
586	// ── Styles ─────────────────────────────────────────────────────────────────
587
588	const pageStyle: React.CSSProperties = {
589	  maxWidth: 1150,
590	  margin: "0 auto",
591	  padding: "28px 24px 80px",
592	  fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
593	  color: "#1a1d24",
594	  background: "#f9fafb",
595	  minHeight: "100vh",
596	};
597	const inputStyle: React.CSSProperties = {
598	  padding: "8px 12px",
599	  fontSize: 13,
600	  border: "1px solid #d1d5db",
601	  borderRadius: 6,
602	  outline: "none",
603	  background: "#fff",
604	};
605	const btnPrimary: React.CSSProperties = {
606	  padding: "8px 18px",
607	  fontSize: 13,
608	  fontWeight: 600,
609	  background: "#2563eb",
610	  color: "#fff",
611	  border: "none",
612	  borderRadius: 6,
613	  cursor: "pointer",
614	};
615	const btnSecondary: React.CSSProperties = {
616	  padding: "6px 14px",
617	  fontSize: 12,
618	  fontWeight: 600,
619	  background: "#fff",
620	  color: "#374151",
621	  border: "1px solid #d1d5db",
622	  borderRadius: 6,
623	  cursor: "pointer",
624	};
625	const btnSmall: React.CSSProperties = {
626	  padding: "3px 10px",
627	  fontSize: 11,
628	  fontWeight: 500,
629	  background: "#fff",
630	  color: "#374151",
631	  border: "1px solid #d1d5db",
632	  borderRadius: 5,
633	  cursor: "pointer",
634	};
635	const cardStyle: React.CSSProperties = {
636	  background: "#fff",
637	  border: "1px solid #e5e7eb",
638	  borderRadius: 8,
639	  padding: "12px 16px",
640	  marginBottom: 8,
641	};
642	const badgeStyle: React.CSSProperties = {
643	  padding: "2px 8px",
644	  borderRadius: 4,
645	  fontSize: 10,
646	  fontWeight: 600,
647	  textTransform: "uppercase",

}, [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}
                {j.status === "done" && (j.num_overlap_segments || 0) > 0 && !j.num_overlap_cleaned && (

                    SoloSpeech skipped (overlap < 5 min)

                )}

            )}

            {/* 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 or note */}
            {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 && (

Original Clip

{fmtDur(openJob.totalDuration.clip)}

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",


Now I have the full picture. Let me rewrite the component to add tabs and the Final Audio view. I'll add state for the tab, speaker list, and speaker audio data:


Edit

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

type AudioFile = { name: string; url: string; duration: number };
type FinalAudioFile = AudioFile & { job: string; type: string };
type SpeakerSummary = { name: string; jobCount: number; doneCount: number; processingCount: number };
type SpeakerAudio = { speaker: string; totalFiles: number; totalDuration: number; files: FinalAudioFile[] };


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

type AudioFile = { name: string; url: string; duration: number };
type FinalAudioFile = AudioFile & { job: string; type: string };
type SpeakerSummary = { name: string; jobCount: number; doneCount: number; processingCount: number };
type SpeakerAudio = { speaker: string; totalFiles: number; totalDuration: number; files: FinalAudioFile[] };

+type FinalAudioFile = AudioFile & { job: string; type: string };

+type SpeakerSummary = { name: string; jobCount: number; doneCount: number; processingCount: number };

+type SpeakerAudio = { speaker: string; totalFiles: number; totalDuration: number; files: FinalAudioFile[] };


Now add the new state variables and the Final Audio tab logic:


  const [lastRefresh, setLastRefresh] = useState("");
  const [showGpu, setShowGpu] = useState(false);
  const [showForm, setShowForm] = useState(false);

  const [lastRefresh, setLastRefresh] = useState("");
  const [showGpu, setShowGpu] = useState(false);
  const [showForm, setShowForm] = useState(false);
  const [tab, setTab] = useState<"final" | "jobs">("final");
  const [speakers, setSpeakers] = useState<SpeakerSummary[]>([]);
  const [activeSpeaker, setActiveSpeaker] = useState<string | null>(null);
  const [speakerAudio, setSpeakerAudio] = useState<SpeakerAudio | null>(null);
  const [loadingSpeaker, setLoadingSpeaker] = useState(false);


  const [lastRefresh, setLastRefresh] = useState("");
  const [showGpu, setShowGpu] = useState(false);
  const [showForm, setShowForm] = useState(false);
  const [tab, setTab] = useState<"final" | "jobs">("final");
  const [speakers, setSpeakers] = useState<SpeakerSummary[]>([]);
  const [activeSpeaker, setActiveSpeaker] = useState<string | null>(null);
  const [speakerAudio, setSpeakerAudio] = useState<SpeakerAudio | null>(null);
  const [loadingSpeaker, setLoadingSpeaker] = useState(false);

+  const [tab, setTab] = useState<"final" | "jobs">("final");

+  const [speakers, setSpeakers] = useState<SpeakerSummary[]>([]);

+  const [activeSpeaker, setActiveSpeaker] = useState<string | null>(null);

+  const [speakerAudio, setSpeakerAudio] = useState<SpeakerAudio | null>(null);

+  const [loadingSpeaker, setLoadingSpeaker] = useState(false);

const refreshJobs = useCallback(async () => {


Add the speaker fetching logic:


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

  const refreshSpeakers = useCallback(async () => {
    try {
      const r = await fetch("/api/final-audio");
      if (r.ok) setSpeakers((await r.json()).speakers);
    } catch {}
  }, []);

  async function loadSpeaker(name: string) {
    setActiveSpeaker(name);
    setLoadingSpeaker(true);
    setSpeakerAudio(null);
    try {
      const r = await fetch(`/api/final-audio?speaker=${name}`);
      if (r.ok) setSpeakerAudio(await r.json());
    } catch {}
    setLoadingSpeaker(false);
  }

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


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

  const refreshSpeakers = useCallback(async () => {
    try {
      const r = await fetch("/api/final-audio");
      if (r.ok) setSpeakers((await r.json()).speakers);
    } catch {}
  }, []);

  async function loadSpeaker(name: string) {
    setActiveSpeaker(name);
    setLoadingSpeaker(true);
    setSpeakerAudio(null);
    try {
      const r = await fetch(`/api/final-audio?speaker=${name}`);
      if (r.ok) setSpeakerAudio(await r.json());
    } catch {}
    setLoadingSpeaker(false);
  }

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

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

+      const r = await fetch("/api/final-audio");

+      if (r.ok) setSpeakers((await r.json()).speakers);

+  async function loadSpeaker(name: string) {

+      const r = await fetch(`/api/final-audio?speaker=${name}`);

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

+  }, [refreshJobs, refreshStats, refreshSpeakers]);


Now replace the job list section with tabs:


{/* 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})

{/* 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}
}

      )}

      {/* Tabs */}

        <button
          style={{ ...tabBtn, ...(tab === "final" ? tabActive : {}) }}
          onClick={() => { setTab("final"); refreshSpeakers(); }}
        >Final Audio</button>
        <button
          style={{ ...tabBtn, ...(tab === "jobs" ? tabActive : {}) }}
          onClick={() => setTab("jobs")}
        >Jobs ({jobs.length})</button>

      {/* ── Final Audio Tab ─────────────────────────────────────────────── */}
      {tab === "final" && (

          {/* Speaker buttons */}

            {speakers.map(sp => (
              <button
                key={sp.name}
                style={{
                  ...speakerBtn,
                  ...(activeSpeaker === sp.name ? speakerBtnActive : {}),
                }}
                onClick={() => loadSpeaker(sp.name)}
              >

{sp.name}

                  {sp.doneCount}/{sp.jobCount} done
                  {sp.processingCount > 0 && ` · ${sp.processingCount} processing`}

              </button>
            ))}

          {/* Loading */}
          {loadingSpeaker && (

Loading audio files...

          )}

          {/* Speaker audio grid */}
          {speakerAudio && !loadingSpeaker && (

{speakerAudio.speaker}

                    {fmtDur(speakerAudio.totalDuration)}

{speakerAudio.totalFiles} clips

                {speakerAudio.files.map((f, i) => (
                  <div key={`${f.job}-${f.name}-${i}`} style={gridCard}>

                      {fmtDur(f.duration)}
                      {f.type === "clean" ? "clean" : "ov-clean"}

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

{f.job}/{f.name}

                ))}

          )}

          {!activeSpeaker && !loadingSpeaker && (

Select a speaker above to view all final audio clips.

          )}

      )}

      {/* ── Jobs Tab ────────────────────────────────────────────────────── */}
      {tab === "jobs" && <>

          Jobs ({jobs.length})


{/* 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})

{/* 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}
}

      )}

      {/* Tabs */}

        <button
          style={{ ...tabBtn, ...(tab === "final" ? tabActive : {}) }}
          onClick={() => { setTab("final"); refreshSpeakers(); }}
        >Final Audio</button>
        <button
          style={{ ...tabBtn, ...(tab === "jobs" ? tabActive : {}) }}
          onClick={() => setTab("jobs")}
        >Jobs ({jobs.length})</button>

      {/* ── Final Audio Tab ─────────────────────────────────────────────── */}
      {tab === "final" && (

          {/* Speaker buttons */}

            {speakers.map(sp => (
              <button
                key={sp.name}
                style={{
                  ...speakerBtn,
                  ...(activeSpeaker === sp.name ? speakerBtnActive : {}),
                }}
                onClick={() => loadSpeaker(sp.name)}
              >

{sp.name}

                  {sp.doneCount}/{sp.jobCount} done
                  {sp.processingCount > 0 && ` · ${sp.processingCount} processing`}

              </button>
            ))}

          {/* Loading */}
          {loadingSpeaker && (

Loading audio files...

          )}

          {/* Speaker audio grid */}
          {speakerAudio && !loadingSpeaker && (

{speakerAudio.speaker}

                    {fmtDur(speakerAudio.totalDuration)}

{speakerAudio.totalFiles} clips

                {speakerAudio.files.map((f, i) => (
                  <div key={`${f.job}-${f.name}-${i}`} style={gridCard}>

                      {fmtDur(f.duration)}
                      {f.type === "clean" ? "clean" : "ov-clean"}

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

{f.job}/{f.name}

                ))}

          )}

          {!activeSpeaker && !loadingSpeaker && (

Select a speaker above to view all final audio clips.

          )}

      )}

      {/* ── Jobs Tab ────────────────────────────────────────────────────── */}
      {tab === "jobs" && <>

          Jobs ({jobs.length})

+      <div style={{ display: "flex", gap: 0, marginBottom: 16, borderBottom: "2px solid #e5e7eb" }}>

+          style={{ ...tabBtn, ...(tab === "final" ? tabActive : {}) }}

+          onClick={() => { setTab("final"); refreshSpeakers(); }}

+          style={{ ...tabBtn, ...(tab === "jobs" ? tabActive : {}) }}

+          onClick={() => setTab("jobs")}

+      {/* ── Final Audio Tab ─────────────────────────────────────────────── */}

+          <div style={{ display: "flex", gap: 10, marginBottom: 16, flexWrap: "wrap" }}>

+                  ...(activeSpeaker === sp.name ? speakerBtnActive : {}),

+                onClick={() => loadSpeaker(sp.name)}

+                <div style={{ fontSize: 10, color: activeSpeaker === sp.name ? "#dbeafe" : "#9ca3af" }}>

+                  {sp.doneCount}/{sp.jobCount} done

+                  {sp.processingCount > 0 && ` · ${sp.processingCount} processing`}

+          {speakerAudio && !loadingSpeaker && (

+              <div style={{ ...durationBanner, marginBottom: 16 }}>

+                <div style={durationItem}>

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

+                    {fmtDur(speakerAudio.totalDuration)}

+                {speakerAudio.files.map((f, i) => (

+                  <div key={`${f.job}-${f.name}-${i}`} style={gridCard}>

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

+                      {fmtDur(f.duration)}

+                        fontWeight: 600,

+                        textTransform: "uppercase",

+                      }}>{f.type === "clean" ? "clean" : "ov-clean"}</span>

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

+          {!activeSpeaker && !loadingSpeaker && (

+
Select a speaker above to view all final audio clips.

+      {/* ── Jobs Tab ────────────────────────────────────────────────────── */}

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

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


Now close the jobs tab section before the modal:


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

      </>}

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


      </>}

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

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


Now add the new styles at the bottom:


680

681	        {files.map(f => (
682

683

684	              {f.name}
685	              {fmtDur(f.duration)}
686

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

689	        ))}
690

691

692	  );
693	}
694
695	// ── Styles ─────────────────────────────────────────────────────────────────
696
697	const pageStyle: React.CSSProperties = {
698	  maxWidth: 1150,
699	  margin: "0 auto",
700	  padding: "28px 24px 80px",
701	  fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
702	  color: "#1a1d24",
703	  background: "#f9fafb",
704	  minHeight: "100vh",
705	};
706	const inputStyle: React.CSSProperties = {
707	  padding: "8px 12px",
708	  fontSize: 13,
709	  border: "1px solid #d1d5db",
710	  borderRadius: 6,
711	  outline: "none",
712	  background: "#fff",
713	};
714	const btnPrimary: React.CSSProperties = {
715	  padding: "8px 18px",
716	  fontSize: 13,
717	  fontWeight: 600,
718	  background: "#2563eb",
719	  color: "#fff",
720	  border: "none",
721	  borderRadius: 6,
722	  cursor: "pointer",
723	};
724	const btnSecondary: React.CSSProperties = {
725	  padding: "6px 14px",
726	  fontSize: 12,
727	  fontWeight: 600,
728	  background: "#fff",
729	  color: "#374151",

        {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",


830	const modalOverlay: React.CSSProperties = {
831	  position: "fixed",
832	  top: 0, left: 0, right: 0, bottom: 0,
833	  background: "rgba(0,0,0,0.4)",
834	  display: "flex",
835	  alignItems: "center",
836	  justifyContent: "center",
837	  zIndex: 100,
838	  padding: 20,
839	};
840	const modalBox: React.CSSProperties = {
841	  background: "#fff",
842	  borderRadius: 10,
843	  padding: 24,
844	  maxWidth: 1200,
845	  width: "100%",
846	  maxHeight: "92vh",
847	  overflow: "auto",
848	  boxShadow: "0 20px 60px rgba(0,0,0,0.15)",
849	};
850	const durationBanner: React.CSSProperties = {
851	  display: "flex",
852	  alignItems: "center",
853	  justifyContent: "center",
854	  gap: 0,
855	  background: "#f9fafb",
856	  border: "1px solid #e5e7eb",
857	  borderRadius: 8,
858	  padding: "14px 20px",
859	  marginBottom: 20,

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,


Now find the end of the file to add the new styles:


900	  padding: "5px 8px",
901	  marginBottom: 5,
902	};
903	const miniLabel: React.CSSProperties = {
904	  fontSize: 10,
905	  color: "#6b7280",
906	};
907

  padding: "5px 8px",
  marginBottom: 5,
};
const miniLabel: React.CSSProperties = {
  fontSize: 10,
  color: "#6b7280",
};


const miniLabel: React.CSSProperties = {
  fontSize: 10,
  color: "#6b7280",
};

const miniLabel: React.CSSProperties = {
  fontSize: 10,
  color: "#6b7280",
};
const tabBtn: React.CSSProperties = {
  padding: "10px 20px",
  fontSize: 13,
  fontWeight: 600,
  background: "none",
  border: "none",
  borderBottom: "2px solid transparent",
  color: "#9ca3af",
  cursor: "pointer",
  marginBottom: -2,
};
const tabActive: React.CSSProperties = {
  color: "#2563eb",
  borderBottomColor: "#2563eb",
};
const speakerBtn: React.CSSProperties = {
  padding: "14px 24px",
  background: "#fff",
  border: "1px solid #e5e7eb",
  borderRadius: 10,
  cursor: "pointer",
  textAlign: "left",
  minWidth: 140,
};
const speakerBtnActive: React.CSSProperties = {
  background: "#2563eb",
  borderColor: "#2563eb",
  color: "#fff",
};
const audioGrid: React.CSSProperties = {
  display: "grid",
  gridTemplateColumns: "repeat(4, 1fr)",
  gap: 8,
};
const gridCard: React.CSSProperties = {
  background: "#fff",
  border: "1px solid #e5e7eb",
  borderRadius: 6,
  padding: "6px 8px",
};


const miniLabel: React.CSSProperties = {
  fontSize: 10,
  color: "#6b7280",
};
const tabBtn: React.CSSProperties = {
  padding: "10px 20px",
  fontSize: 13,
  fontWeight: 600,
  background: "none",
  border: "none",
  borderBottom: "2px solid transparent",
  color: "#9ca3af",
  cursor: "pointer",
  marginBottom: -2,
};
const tabActive: React.CSSProperties = {
  color: "#2563eb",
  borderBottomColor: "#2563eb",
};
const speakerBtn: React.CSSProperties = {
  padding: "14px 24px",
  background: "#fff",
  border: "1px solid #e5e7eb",
  borderRadius: 10,
  cursor: "pointer",
  textAlign: "left",
  minWidth: 140,
};
const speakerBtnActive: React.CSSProperties = {
  background: "#2563eb",
  borderColor: "#2563eb",
  color: "#fff",
};
const audioGrid: React.CSSProperties = {
  display: "grid",
  gridTemplateColumns: "repeat(4, 1fr)",
  gap: 8,
};
const gridCard: React.CSSProperties = {
  background: "#fff",
  border: "1px solid #e5e7eb",
  borderRadius: 6,
  padding: "6px 8px",
};

+  borderBottom: "2px solid transparent",

+const tabActive: React.CSSProperties = {

+const speakerBtn: React.CSSProperties = {

+const speakerBtnActive: React.CSSProperties = {

+const audioGrid: React.CSSProperties = {

+  gridTemplateColumns: "repeat(4, 1fr)",

+const gridCard: React.CSSProperties = {


Build and deploy:


Bash

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

Build Next.js


┌ ○ /                                     5.71 kB        83.8 kB
├ ○ /404                                  180 B          78.3 kB
├ ƒ /api/final-audio                      0 B            78.1 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


vercel deploy --prod 2>&1 | grep -E "ready|error"

Deploy to Vercel


    "readyState": "READY",
  "message": "Deployment karthyani-dashboard-m5jizmdpj-charans-projects-9fb8fade.vercel.app ready.",


Deployed. The dashboard now has two tabs at the top:

**Final Audio** (default tab):
- Shows speaker buttons: jagan, kohli, lavanya, rgv
- Each button shows done/total count
- Click a speaker → loads all their cleaned + overlap_cleaned clips below
- Duration banner shows total audio duration + file count
- 4-column grid with all audio players, each showing duration, type (clean/ov-clean), and source job name

**Jobs** tab:
- Same as before — job list with status, progress, etc.


remove rgv_1 clean_002 clip


set -a && source .env && set +a && python3 -c "
import sys;sys.path.insert(0,'.');import r2
r2.s3.delete_object(Bucket=r2.R2_BUCKET, Key='rgv_1/cleaned/clean_0002.wav')
print('deleted rgv_1/cleaned/clean_0002.wav from R2')
"

Delete clean_0002.wav from R2


Error: Exit code 1
/bin/bash: line 5: .env: No such file or directory


cd /home/ubuntu/karthyani_pipeline/worker && set -a && source .env && set +a && python3 -c "
import sys;sys.path.insert(0,'.');import r2
r2.s3.delete_object(Bucket=r2.R2_BUCKET, Key='rgv_1/cleaned/clean_0002.wav')
print('deleted rgv_1/cleaned/clean_0002.wav')
"

Delete clean_0002.wav from R2


deleted rgv_1/cleaned/clean_0002.wav


Done.


what is status of solo for remiang Traceback (most recent call last):
  File "/home/ubuntu/karthyani_pipeline/worker/worker.py", line 295, in process_job
    n_cleaned = step_solospeech(job)
  File "/home/ubuntu/karthyani_pipeline/worker/worker.py", line 193, in step_solospeech
    gpu_runner.ssh_run(
  File "/home/ubuntu/karthyani_pipeline/worker/vast_runner.py", line 154, in ssh_run
    raise RuntimeError(
RuntimeError: ssh command failed (rc=1):
STDOUT: attention mode is flash
[1/4] resolving pending clips for rgv_5
  total: 37, already done: 0, pending: 37

STDERR: tps://github.com/speechbrain/speechbrain/releases/tag/v1.0.0
  from speechbrain.pretrained.interfaces import Pretrained
Traceback (most recent call last):
  File "/tmp/run_job.py", line 215, in <module>
    main()
  File "/tmp/run_job.py", line 105, in main
    s3.download_file(R2_BUCKET, f"{JOB_NAME}/main_speaker_ref.wav", str(ref_local))
  File "/opt/conda/lib/python3.11/site-packages/botocore/context.py", line 123, in wrapper
    return func(*args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^
  File "/opt/conda/lib/python3.11/site-packages/boto3/s3/inject.py", line 223, in download_file
    return transfer.download_file(
           ^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/conda/lib/python3.11/site-packages/boto3/s3/transfer.py", line 484, in download_file
    future.result()
  File "/opt/conda/lib/python3.11/site-packages/s3transfer/futures.py", line 111, in result
    return self._coordinator.result()
           ^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/conda/lib/python3.11/site-packages/s3transfer/futures.py", line 287, in result
    raise self._exception
  File "/opt/conda/lib/python3.11/site-packages/s3transfer/tasks.py", line 272, in _main
    self._submit(transfer_future=transfer_future, **kwargs)
  File "/opt/conda/lib/python3.11/site-packages/s3transfer/download.py", line 359, in _submit
    response = client.head_object(
               ^^^^^^^^^^^^^^^^^^^
  File "/opt/conda/lib/python3.11/site-packages/botocore/client.py", line 602


rgv_5 failed because `main_speaker_ref.wav` doesn't exist in R2. Let me check and fix:


set -a && source .env && set +a && python3 -c "
import sys;sys.path.insert(0,'.');import r2
print('ref exists:', r2.exists('rgv_5/main_speaker_ref.wav'))
print('clip exists:', r2.exists('rgv_5/clip.wav'))
"

Check if ref file exists


ref exists: False
clip exists: True


No reference file. Need to generate it from the diarization data. Let me create it:


set -a && source .env && set +a && python3 -c "
import sys, json, tempfile; sys.path.insert(0,'.')
import r2, soundfile as sf

# Download diarize.json and clip.wav
work = '/tmp/fix_rgv5'
import os; os.makedirs(work, exist_ok=True)
r2.download_file('rgv_5/diarize.json', f'{work}/diarize.json')
r2.download_file('rgv_5/clip.wav', f'{work}/clip.wav')

with open(f'{work}/diarize.json') as f:
    diar = json.load(f)['output']['diarization']

target = 'SPEAKER_01'
# Find longest clean target turn
longest = max(
    ((t['end']-t['start'], t['start'], t['end']) for t in diar if t['speaker']==target),
    default=(0,0,0)
)
print(f'longest target turn: {longest[0]:.1f}s ({longest[1]:.1f}-{longest[2]:.1f})')

a, sr = sf.read(f'{work}/clip.wav')
i0 = int(round(longest[1]*sr))
i1 = int(round(longest[2]*sr))
ref_path = f'{work}/main_speaker_ref.wav'
sf.write(ref_path, a[i0:i1], sr, subtype='PCM_16')
print(f'wrote ref: {(i1-i0)/sr:.1f}s')

r2.upload_file(ref_path, 'rgv_5/main_speaker_ref.wav')
print('uploaded to R2')
"

Generate and upload missing ref file for rgv_5


longest target turn: 28.5s (2327.5-2356.0)
wrote ref: 28.5s
uploaded to R2


Now let me check the other remaining jobs and retry:


set -a && source .env && set +a && python3 -c "
import sys;sys.path.insert(0,'.');import db
rows = db.query(\"SELECT name, status, error FROM jobs WHERE status IN ('failed','awaiting_solospeech')\")
for r in rows:
    err = (r['error'] or '')[:100]
    print(f'{r[\"name\"]:15s} {r[\"status\"]:25s} {err}')
"

Check remaining solospeech jobs


rgv_1           awaiting_solospeech
kohli_1         awaiting_solospeech
kohli_3         awaiting_solospeech
rgv_5           failed                    Traceback (most recent call last):
  File "/home/ubuntu/karthyani_pipeline/worker/worker.py", line 2


set -a && source .env && set +a && python3 -c "
import sys;sys.path.insert(0,'.');import db

# Retry rgv_5
rows = db.query(\"SELECT id FROM jobs WHERE name='rgv_5'\")
db.execute(\"UPDATE jobs SET status='awaiting_solospeech', error=NULL, failed_at_status=NULL, worker_id=NULL WHERE id=?\", [rows[0]['id']])
print('rgv_5: retried')

# Check ref files exist for other jobs
import r2
for name in ['rgv_1','kohli_1','kohli_3']:
    has_ref = r2.exists(f'{name}/main_speaker_ref.wav')
    print(f'{name}: ref={has_ref}')
"

Retry rgv_5 and check other ref files


rgv_5: retried
rgv_1: ref=True
kohli_1: ref=True
kohli_3: ref=True


for i in 1 2; do echo "=== Worker $i ==="; tail -5 /tmp/worker_logs/worker_$i.log; echo; done

Check workers


=== Worker 1 ===
[07:28:04] worker booted (id=0321-dsm2-nvdgxa100-prxmx70052-2198394-2c4fdb)
[07:28:07] === job rgv_1 (start status=awaiting_solospeech) ===
[07:28:07]   ▶ solospeech…
[07:28:09]   trying offer 34292007 (A100 SXM4 ram=81920MB $0.789/hr)
[07:28:10]   instance_id=34520129

=== Worker 2 ===

[07:35:16] === job kohli_1 (start status=awaiting_solospeech) ===
[07:35:16]   ▶ solospeech…
[07:35:17]   trying offer 34292012 (A100 SXM4 ram=81920MB $0.789/hr)
[07:35:19]   instance_id=34520488


Workers are running rgv_1 and kohli_1. rgv_5 and kohli_3 will be picked up when one finishes. rgv_5's missing ref file is fixed now.

4 jobs remaining: rgv_1 (77/289 left), rgv_5, kohli_1, kohli_3. All have ref files. Workers are on A100 SXM4 80GB.


raceback (most recent call last):
  File "/home/ubuntu/karthyani_pipeline/worker/worker.py", line 295, in process_job
    n_cleaned = step_solospeech(job)
  File "/home/ubuntu/karthyani_pipeline/worker/worker.py", line 177, in step_solospeech
    inst = gpu_runner.wait_for_ready(instance_id)
  File "/home/ubuntu/karthyani_pipeline/worker/vast_runner.py", line 119, in wait_for_ready
    raise TimeoutError(f"vast instance {instance_id} not ready after {timeout}s. last={last}")
TimeoutError: vast instance 34520129 not ready after 900s. last={'actual_status': 'loading', 'bundle_id': None, 'bw_nvlink': 300.0, 'client_run_time': 1.1, 'compute_cap': 800, 'cpu_arch': 'amd64', 'cpu_cores': 128, 'cpu_cores_effective': 32.0, 'cpu_name': 'AMD EPYC 7742 64-Core Processor', 'cpu_ram': 515797, 'cpu_util': 0.0, 'credit_balance': None, 'credit_discount': None, 'credit_discount_max': 0.0, 'cuda_max_good': 12.8, 'cur_state': 'stopped', 'direct_port_count': 13750, 'direct_port_end': -1, 'direct_port_start': -1, 'disk_bw': 2722.0, 'disk_name': 'nvme', 'disk_space': 60.0, 'disk_usage': -1, 'disk_util': -1, 'dlperf': 117.17604920399262, 'dlperf_per_dphtotal': 145.86230191368378, 'dph_base': 0.7866666666666666, 'dph_total': 0.8033333333333332, 'driver_version': '570.124.06', 'duration': 15515642.124974012, 'end_date': 1791322629.0, 'external': False, 'extra_env': [], 'flops_per_dphtotal': 19.410960995850623, 'geolocation': 'France, FR', 'gpu_arch': 'nvidia', 'gpu_display_active': False, 'gpu_frac': 0.25, 'gpu_lanes': 16, 'gpu_mem_bw': 1672.1, 'gpu_name': 'A100 SXM4', 'gpu_ram': 81920, 'gpu_temp': None, 'gpu_totalram': 81920, 'gpu_util': None, 'has_avx': 1, 'host_id': 432374, 'host_run_time': 2592000.0, 'hosting_type': None, 'id': 34520129, 'image_args': [], 'image_runtype': 'ssh', 'image_uuid': 'pytorch/pytorch:2.4.1-cuda12.4-cudnn9-devel', 'inet_down': 853.3, 'inet_down_billed': None, 'inet_down_cost': 0.0026041666666666665, 'inet_up': 934.3, 'inet_up_billed': None, 'inet_up_cost': 0.003


Same Czechia/France hosts keep getting stuck at "loading". Let me blacklist those unreliable hosts and retry:


set -a && source .env && set +a

# Kill workers, destroy instances
kill $(ps aux | grep 'worker.py' | grep -v grep | awk '{print $2}') 2>/dev/null
python3 -c "
import requests, os
key = os.environ['VAST_API_KEY']
r = requests.get(f'https://console.vast.ai/api/v0/instances/?owner=me&api_key={key}', timeout=30)
for inst in r.json().get('instances', []):
    requests.delete(f'https://console.vast.ai/api/v0/instances/{inst[\"id\"]}/?api_key={key}', timeout=30)
    print(f'destroyed {inst[\"id\"]}')
"
python3 -c "import sys;sys.path.insert(0,'.');import db;db.execute('UPDATE jobs SET worker_id=NULL, gpu_instance_id=NULL WHERE worker_id IS NOT NULL OR gpu_instance_id IS NOT NULL')"

# Retry failed
python3 -c "
import sys;sys.path.insert(0,'.');import db
rows = db.query(\"SELECT id,name FROM jobs WHERE status='failed'\")
for r in rows:
    db.execute(\"UPDATE jobs SET status='awaiting_solospeech', error=NULL, failed_at_status=NULL, worker_id=NULL WHERE id=?\", [r['id']])
    print(f'{r[\"name\"]}: retried')
"

Kill workers, clean up, retry


destroyed 34520488
destroyed 34521226
rgv_1: retried


Now let me find reliable hosts — filter for `inet_down >= 1000` and `reliability >= 0.98`:


set -a && source .env && set +a && python3 -c "
import requests, json, os
key = os.environ['VAST_API_KEY']
q = json.dumps({'gpu_ram':{'gte':80000},'num_gpus':{'eq':1},'rentable':{'eq':True},'reliability2':{'gte':0.98},'inet_down':{'gte':1000},'disk_space':{'gte':60},'dph_total':{'lte':3.0},'order':[['dph_total','asc']],'limit':15})
r = requests.get(f'https://console.vast.ai/api/v0/bundles/?api_key={key}&q={q}', timeout=30)
for o in r.json().get('offers',[]):
    print(f'{o[\"id\"]:10d} {o[\"gpu_name\"]:15s} {o[\"gpu_ram\"]:6d}MB \${o[\"dph_total\"]:.3f}/hr rel={o[\"reliability2\"]:.2f} inet={o[\"inet_down\"]:.0f} geo={o.get(\"geolocation\",\"?\")}')"

Find reliable GPU offers


  34343201 RTX PRO 6000 WS  97887MB $0.909/hr rel=0.99 inet=1870 geo=Hong Kong, HK
  29019330 A100 SXM4        81920MB $1.069/hr rel=1.00 inet=7266 geo=Czechia, CZ
  32303624 A100 PCIE        81920MB $1.202/hr rel=0.99 inet=7754 geo=United Kingdom, GB
  33404530 RTX PRO 6000 S   97887MB $1.202/hr rel=0.99 inet=6782 geo=, US
  32304446 A100 PCIE        81920MB $1.202/hr rel=0.99 inet=8894 geo=United Kingdom, GB
  32305040 A100 PCIE        81920MB $1.202/hr rel=0.99 inet=8082 geo=United Kingdom, GB
  32304393 A100 PCIE        81920MB $1.202/hr rel=0.99 inet=8448 geo=United Kingdom, GB
  32602760 RTX PRO 6000 WS  97887MB $1.205/hr rel=1.00 inet=9027 geo=Czechia, CZ
  30780848 RTX PRO 6000 WS  97887MB $1.334/hr rel=1.00 inet=1868 geo=Japan, JP
  28250049 H100 SXM         81559MB $1.469/hr rel=1.00 inet=9166 geo=France, FR
  31971201 H100 SXM         81559MB $1.564/hr rel=1.00 inet=66644 geo=District of Columbia, US
  27666234 A100 SXM4        81920MB $1.852/hr rel=1.00 inet=6398 geo=Massachusetts, US
  31183516 H200 NVL        143771MB $2.613/hr rel=1.00 inet=9199 geo=Czechia, CZ
  26129861 H200 NVL        143771MB $2.936/hr rel=1.00 inet=4490 geo=Bulgaria, BG


Good options. Let me update the search to require higher reliability:


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

"""vast.ai GPU spinner. Search offers, create instance, ssh in, run job, destroy.

API docs: https://vast.ai/docs/api/
"""
import os, json, time, shlex, subprocess
from pathlib import Path
import requests

VAST_KEY = os.environ["VAST_API_KEY"]
VAST_BASE = "https://console.vast.ai/api/v0"

# We use a CUDA + PyTorch base image so SoloSpeech setup is faster.
# pytorch/pytorch image already has python + torch + cuda runtime.
IMAGE = os.environ.get("VAST_IMAGE", "pytorch/pytorch:2.4.1-cuda12.4-cudnn9-devel")
DISK_GB = int(os.environ.get("VAST_DISK_GB", "60"))

SSH_PRIV = os.environ.get("VAST_SSH_PRIV_PATH", "/root/.ssh/vast_id_ed25519")

def _req(method, path, **kwargs):
    params = kwargs.pop("params", {})
    params["api_key"] = VAST_KEY
    r = requests.request(method, f"{VAST_BASE}{path}",
                         params=params, timeout=60, **kwargs)
    if r.status_code >= 400:
        raise RuntimeError(f"vast {method} {path} {r.status_code}: {r.text[:500]}")
    return r.json()

def ensure_ssh_key() -> str:
    """Ensure local SSH key exists. Returns the public key string."""
    pub_path = SSH_PRIV + ".pub"
    if not Path(pub_path).exists():
        Path(SSH_PRIV).parent.mkdir(parents=True, exist_ok=True)
        subprocess.run(
            ["ssh-keygen", "-t", "ed25519", "-N", "", "-f", SSH_PRIV],
            check=True, capture_output=True,
        )
    return Path(pub_path).read_text().strip()

def search_offers(max_price=3.00) -> list:
    """Find cheapest H100/A100 80GB offers."""
    q = {
        "gpu_ram": {"gte": 80000},
        "num_gpus": {"eq": 1},
        "rentable": {"eq": True},
        "reliability2": {"gte": 0.90},
        "disk_space": {"gte": DISK_GB},
        "dph_total": {"lte": max_price},
        "order": [["dph_total", "asc"]],
        "limit": 15,
    }
    r = _req("GET", "/bundles/", params={"q": json.dumps(q)})
    offers = r.get("offers", [])
    # Prefer H100 > A100, filter out non-standard GPUs
    preferred = [o for o in offers if any(g in o["gpu_name"] for g in ("H100", "A100"))]
    if preferred:
        return preferred
    if offers:
        return offers
    raise RuntimeError(f"no 80GB+ GPU offers under ${max_price}/hr")

def search_offer(min_vram_mb=70000, max_price=1.20) -> dict:
    return search_offers(min_vram_mb, max_price)[0]

def create_instance(offer_id: int, pub_key: str) -> int:
    """Create a vast instance from an offer. Returns the instance id."""
    body = {
        "client_id": "me",
        "image": IMAGE,
        "disk": DISK_GB,
        "label": f"karthyani-{int(time.time())}",
        "onstart": (
            f"#!/bin/bash\n"
            f"mkdir -p /root/.ssh\n"
            f"echo '{pub_key}' >> /root/.ssh/authorized_keys\n"
            f"chmod 600 /root/.ssh/authorized_keys\n"
        ),
        "runtype": "ssh",
    }
    r = _req("PUT", f"/asks/{offer_id}/", json=body)
    if not r.get("success"):
        raise RuntimeError(f"create_instance failed: {r}")
    return r["new_contract"]

def get_instance(instance_id: int) -> dict:
    r = _req("GET", f"/instances/{instance_id}/")
    return r.get("instances") or r

def list_instances() -> list:
    r = _req("GET", "/instances/", params={"owner": "me"})
    return r.get("instances", [])

def wait_for_ready(instance_id: int, timeout=900) -> dict:
    """Wait until the instance is running and SSH is reachable."""
    deadline = time.time() + timeout
    last = None
    while time.time() < deadline:
        try:
            inst = get_instance(instance_id)
            if isinstance(inst, list):
                inst = inst[0] if inst else None
            if inst:
                last = inst
                status = inst.get("actual_status") or inst.get("intended_status")
                if status == "running" and inst.get("ssh_host") and inst.get("ssh_port"):
                    # Probe SSH
                    if _ssh_probe(inst):
                        return inst
        except Exception as e:
            print(f"  wait_for_ready: {e}")
        time.sleep(10)
    raise TimeoutError(f"vast instance {instance_id} not ready after {timeout}s. last={last}")

def _ssh_conn_str(inst: dict) -> str:
    return f"ssh -p {inst['ssh_port']} root@{inst['ssh_host']}"

def _ssh_probe(inst: dict) -> bool:
    try:
        r = subprocess.run(
            ["ssh", "-p", str(inst["ssh_port"]),
             "-i", SSH_PRIV,
             "-o", "StrictHostKeyChecking=no",
             "-o", "UserKnownHostsFile=/dev/null",
             "-o", "ConnectTimeout=5",
             "-o", "BatchMode=yes",
             f"root@{inst['ssh_host']}",
             "echo ok"],
            capture_output=True, text=True, timeout=15,
        )
        return r.returncode == 0
    except Exception:
        return False

def ssh_run(inst: dict, cmd: str, timeout=3600):
    parts = ["ssh", "-p", str(inst["ssh_port"]),
             "-i", SSH_PRIV,
             "-o", "StrictHostKeyChecking=no",
             "-o", "UserKnownHostsFile=/dev/null",
             "-o", "ServerAliveInterval=30",
             f"root@{inst['ssh_host']}",
             cmd]
    r = subprocess.run(parts, capture_output=True, text=True, timeout=timeout)
    if r.returncode != 0:
        raise RuntimeError(
            f"ssh command failed (rc={r.returncode}):\n"
            f"STDOUT: {r.stdout[-2000:] if r.stdout else '(empty)'}\n"
            f"STDERR: {r.stderr[-2000:] if r.stderr else '(empty)'}"
        )
    return r

def scp_to(inst: dict, local: str, remote: str):
    parts = ["scp", "-P", str(inst["ssh_port"]),
             "-i", SSH_PRIV,
             "-o", "StrictHostKeyChecking=no",
             "-o", "UserKnownHostsFile=/dev/null",
             local, f"root@{inst['ssh_host']}:{remote}"]
    subprocess.run(parts, check=True, capture_output=True, text=True)

def destroy_instance(instance_id: int):
    try:
        _req("DELETE", f"/instances/{instance_id}/")
    except Exception as e:
        print(f"  destroy failed: {e}")


set -a && source .env && set +a
mkdir -p /tmp/worker_logs
for i in 1 2; do
  nohup .venv/bin/python -u worker.py > /tmp/worker_logs/worker_$i.log 2>&1 &
  echo "Worker $i PID=$!"
done

Start workers with stricter filter


Worker 1 PID=2231403
Worker 2 PID=2231404


for i in 1 2; do echo "=== Worker $i ==="; tail -5 /tmp/worker_logs/worker_$i.log; echo; done

Check workers


=== Worker 1 ===
[07:45:47] worker booted (id=0321-dsm2-nvdgxa100-prxmx70052-2231403-55d2cc)
[07:45:50] === job rgv_1 (start status=awaiting_solospeech) ===
[07:45:50]   ▶ solospeech…
[07:45:51]   trying offer 29019330 (A100 SXM4 ram=81920MB $1.069/hr)
[07:45:53]   instance_id=34521381

=== Worker 2 ===
[07:45:47] worker booted (id=0321-dsm2-nvdgxa100-prxmx70052-2231404-4d0f4f)
[07:45:51] === job rgv_5 (start status=awaiting_solospeech) ===
[07:45:51]   ▶ solospeech…
[07:45:52]   trying offer 29019330 (A100 SXM4 ram=81920MB $1.069/hr)
[07:45:53]   instance_id=34521382


Both on the Czechia A100 SXM4 with 100% reliability. These should boot fine. 4 jobs queued: rgv_1, rgv_5, kohli_1, kohli_3.


remove clean_0000.wav of kohli3 from r2


set -a && source .env && set +a && python3 -c "
import sys;sys.path.insert(0,'.');import r2
r2.s3.delete_object(Bucket=r2.R2_BUCKET, Key='kohli_3/cleaned/clean_0000.wav')
print('deleted kohli_3/cleaned/clean_0000.wav')
"

Delete clean_0000.wav from R2


deleted kohli_3/cleaned/clean_0000.wav


Done.


and in final audio dont keep things which are less than 1sec or 1 sec


38	    return res.json({ speakers: summary });
39	  }
40
41	  // Return all audio for a specific speaker
42	  const group = groups[speaker];
43	  if (!group) return res.status(404).json({ error: "speaker not found" });
44
45	  const allFiles: { name: string; job: string; url: string; duration: number; type: string }[] = [];
46
47	  await Promise.all(
48	    group.jobs.map(async (jobName: string) => {
49	      const [cleanedItems, overlapCleanedItems] = await Promise.all([
50	        listKeysWithSizes(`${jobName}/cleaned/`),
51	        listKeysWithSizes(`${jobName}/overlap_cleaned/`),
52	      ]);
53
54	      const processItems = async (items: { key: string; size: number }[], type: string) => {
55	        const wavs = items.filter(i => i.key.endsWith(".wav"));
56	        await Promise.all(
57	          wavs.map(async item => {
58	            const url = await presign(item.key);
59	            allFiles.push({
60	              name: item.key.split("/").pop()!,
61	              job: jobName,
62	              url,
63	              duration: Math.round(wavDuration(item.size) * 10) / 10,
64	              type,
65	            });
66	          })
67	        );

    return res.json({ speakers: summary });
  }

  // Return all audio for a specific speaker
  const group = groups[speaker];
  if (!group) return res.status(404).json({ error: "speaker not found" });

  const allFiles: { name: string; job: string; url: string; duration: number; type: string }[] = [];

  await Promise.all(
    group.jobs.map(async (jobName: string) => {
      const [cleanedItems, overlapCleanedItems] = await Promise.all([
        listKeysWithSizes(`${jobName}/cleaned/`),
        listKeysWithSizes(`${jobName}/overlap_cleaned/`),
      ]);

      const processItems = async (items: { key: string; size: number }[], type: string) => {
        const wavs = items.filter(i => i.key.endsWith(".wav"));
        await Promise.all(
          wavs.map(async item => {
            const url = await presign(item.key);
            allFiles.push({
              name: item.key.split("/").pop()!,
              job: jobName,
              url,
              duration: Math.round(wavDuration(item.size) * 10) / 10,
              type,
            });
          })
        );


Edit

      const processItems = async (items: { key: string; size: number }[], type: string) => {
        const wavs = items.filter(i => i.key.endsWith(".wav"));
        await Promise.all(
          wavs.map(async item => {
            const url = await presign(item.key);
            allFiles.push({
              name: item.key.split("/").pop()!,
              job: jobName,
              url,
              duration: Math.round(wavDuration(item.size) * 10) / 10,
              type,
            });
          })
        );

      const processItems = async (items: { key: string; size: number }[], type: string) => {
        const wavs = items.filter(i => i.key.endsWith(".wav") && wavDuration(i.size) > 1);
        await Promise.all(
          wavs.map(async item => {
            const url = await presign(item.key);
            allFiles.push({
              name: item.key.split("/").pop()!,
              job: jobName,
              url,
              duration: Math.round(wavDuration(item.size) * 10) / 10,
              type,
            });
          })
        );


The file /home/ubuntu/karthyani_pipeline/dashboard/pages/api/final-audio.ts has been updated successfully.

      const processItems = async (items: { key: string; size: number }[], type: string) => {
        const wavs = items.filter(i => i.key.endsWith(".wav") && wavDuration(i.size) > 1);
        await Promise.all(
          wavs.map(async item => {
            const url = await presign(item.key);
            allFiles.push({
              name: item.key.split("/").pop()!,
              job: jobName,
              url,
              duration: Math.round(wavDuration(item.size) * 10) / 10,
              type,
            });
          })
        );

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

function wavDuration(bytes: number): number {
  return Math.max(0, (bytes - 44) / 32000);
}

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

  const speaker = req.query.speaker as string | undefined;

  // Get all jobs
  const result = await turso.execute(
    "SELECT name, status, r2_prefix FROM jobs ORDER BY name"
  );
  const jobs = result.rows as any[];

  // Group jobs by speaker prefix (everything before last _N)
  const groups: Record<string, { jobs: string[]; statuses: string[] }> = {};
  for (const j of jobs) {
    const prefix = j.name.replace(/_\d+$/, "");
    if (!groups[prefix]) groups[prefix] = { jobs: [], statuses: [] };
    groups[prefix].jobs.push(j.name);
    groups[prefix].statuses.push(j.status);
  }

  // If no speaker specified, return summary
  if (!speaker) {
    const summary = Object.entries(groups).map(([name, g]) => ({
      name,
      jobCount: g.jobs.length,
      doneCount: g.statuses.filter(s => s === "done").length,
      processingCount: g.statuses.filter(s => s !== "done" && s !== "failed").length,
    }));
    return res.json({ speakers: summary });
  }

  // Return all audio for a specific speaker
  const group = groups[speaker];
  if (!group) return res.status(404).json({ error: "speaker not found" });

  const allFiles: { name: string; job: string; url: string; duration: number; type: string }[] = [];

  await Promise.all(
    group.jobs.map(async (jobName: string) => {
      const [cleanedItems, overlapCleanedItems] = await Promise.all([
        listKeysWithSizes(`${jobName}/cleaned/`),
        listKeysWithSizes(`${jobName}/overlap_cleaned/`),
      ]);

      const processItems = async (items: { key: string; size: number }[], type: string) => {
        const wavs = items.filter(i => i.key.endsWith(".wav"));
        await Promise.all(
          wavs.map(async item => {
            const url = await presign(item.key);
            allFiles.push({
              name: item.key.split("/").pop()!,
              job: jobName,
              url,
              duration: Math.round(wavDuration(item.size) * 10) / 10,
              type,
            });
          })
        );
      };

      await Promise.all([
        processItems(cleanedItems, "clean"),
        processItems(overlapCleanedItems, "overlap_cleaned"),
      ]);
    })
  );

  // Sort by job name then file name
  allFiles.sort((a, b) => a.job.localeCompare(b.job) || a.name.localeCompare(b.name));

  const totalDuration = Math.round(allFiles.reduce((s, f) => s + f.duration, 0) * 10) / 10;

  return res.json({
    speaker,
    totalFiles: allFiles.length,
    totalDuration,
    files: allFiles,
  });
}

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

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


Bash

cd /home/ubuntu/karthyani_pipeline/dashboard && vercel deploy --prod 2>&1 | grep -E "ready|error"

Deploy dashboard


    "readyState": "READY",
  "message": "Deployment karthyani-dashboard-lhze9iu3p-charans-projects-9fb8fade.vercel.app ready.",


Done. Final Audio now filters out any clips that are 1 second or less.


remove rgv1/clean_003 file


cd /home/ubuntu/karthyani_pipeline/worker && set -a && source .env && set +a && python3 -c "
import sys;sys.path.insert(0,'.');import r2
r2.s3.delete_object(Bucket=r2.R2_BUCKET, Key='rgv_1/cleaned/clean_0003.wav')
print('deleted')
"

Delete rgv_1/cleaned/clean_0003.wav


deleted


and in final in dashbaord keep rgv ,jagan ,kohli,lvanya


The speaker names are auto-generated from job name prefixes. Let me fix the order in the API:


Edit

    const summary = Object.entries(groups).map(([name, g]) => ({
      name,
      jobCount: g.jobs.length,
      doneCount: g.statuses.filter(s => s === "done").length,
      processingCount: g.statuses.filter(s => s !== "done" && s !== "failed").length,
    }));
    return res.json({ speakers: summary });

    const SPEAKER_ORDER = ["rgv", "jagan", "kohli", "lavanya"];
    const summary = Object.entries(groups).map(([name, g]) => ({
      name,
      jobCount: g.jobs.length,
      doneCount: g.statuses.filter(s => s === "done").length,
      processingCount: g.statuses.filter(s => s !== "done" && s !== "failed").length,
    }));
    summary.sort((a, b) => {
      const ai = SPEAKER_ORDER.indexOf(a.name);
      const bi = SPEAKER_ORDER.indexOf(b.name);
      return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
    });
    return res.json({ speakers: summary });


    const SPEAKER_ORDER = ["rgv", "jagan", "kohli", "lavanya"];
    const summary = Object.entries(groups).map(([name, g]) => ({
      name,
      jobCount: g.jobs.length,
      doneCount: g.statuses.filter(s => s === "done").length,
      processingCount: g.statuses.filter(s => s !== "done" && s !== "failed").length,
    }));
    summary.sort((a, b) => {
      const ai = SPEAKER_ORDER.indexOf(a.name);
      const bi = SPEAKER_ORDER.indexOf(b.name);
      return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
    });
    return res.json({ speakers: summary });

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

function wavDuration(bytes: number): number {
  return Math.max(0, (bytes - 44) / 32000);
}

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

  const speaker = req.query.speaker as string | undefined;

  // Get all jobs
  const result = await turso.execute(
    "SELECT name, status, r2_prefix FROM jobs ORDER BY name"
  );
  const jobs = result.rows as any[];

  // Group jobs by speaker prefix (everything before last _N)
  const groups: Record<string, { jobs: string[]; statuses: string[] }> = {};
  for (const j of jobs) {
    const prefix = j.name.replace(/_\d+$/, "");
    if (!groups[prefix]) groups[prefix] = { jobs: [], statuses: [] };
    groups[prefix].jobs.push(j.name);
    groups[prefix].statuses.push(j.status);
  }

  // If no speaker specified, return summary
  if (!speaker) {
    const summary = Object.entries(groups).map(([name, g]) => ({
      name,
      jobCount: g.jobs.length,
      doneCount: g.statuses.filter(s => s === "done").length,
      processingCount: g.statuses.filter(s => s !== "done" && s !== "failed").length,
    }));
    return res.json({ speakers: summary });
  }

  // Return all audio for a specific speaker
  const group = groups[speaker];
  if (!group) return res.status(404).json({ error: "speaker not found" });

  const allFiles: { name: string; job: string; url: string; duration: number; type: string }[] = [];

  await Promise.all(
    group.jobs.map(async (jobName: string) => {
      const [cleanedItems, overlapCleanedItems] = await Promise.all([
        listKeysWithSizes(`${jobName}/cleaned/`),
        listKeysWithSizes(`${jobName}/overlap_cleaned/`),
      ]);

      const processItems = async (items: { key: string; size: number }[], type: string) => {
        const wavs = items.filter(i => i.key.endsWith(".wav") && wavDuration(i.size) > 1);
        await Promise.all(
          wavs.map(async item => {
            const url = await presign(item.key);
            allFiles.push({
              name: item.key.split("/").pop()!,
              job: jobName,
              url,
              duration: Math.round(wavDuration(item.size) * 10) / 10,
              type,
            });
          })
        );
      };

      await Promise.all([
        processItems(cleanedItems, "clean"),
        processItems(overlapCleanedItems, "overlap_cleaned"),
      ]);
    })
  );

  // Sort by job name then file name
  allFiles.sort((a, b) => a.job.localeCompare(b.job) || a.name.localeCompare(b.name));

  const totalDuration = Math.round(allFiles.reduce((s, f) => s + f.duration, 0) * 10) / 10;

  return res.json({
    speaker,
    totalFiles: allFiles.length,
    totalDuration,
    files: allFiles,
  });
}

+    const SPEAKER_ORDER = ["rgv", "jagan", "kohli", "lavanya"];

+      const ai = SPEAKER_ORDER.indexOf(a.name);

+      const bi = SPEAKER_ORDER.indexOf(b.name);


Bash

cd /home/ubuntu/karthyani_pipeline/dashboard && vercel deploy --prod 2>&1 | grep -E "ready|error"

Deploy dashboard


    "readyState": "READY",
  "message": "Deployment karthyani-dashboard-3hnk7q1u0-charans-projects-9fb8fade.vercel.app ready.",


Done. Final Audio tab now shows speakers in order: **rgv, jagan, kohli, lavanya**.


remove rgv1_clean_001


cd /home/ubuntu/karthyani_pipeline/worker && set -a && source .env && set +a && python3 -c "
import sys;sys.path.insert(0,'.');import r2
r2.s3.delete_object(Bucket=r2.R2_BUCKET, Key='rgv_1/cleaned/clean_0001.wav')
print('deleted')
"

Delete rgv_1/cleaned/clean_0001.wav


status of gpu runs is those runnoing or stick


for i in 1 2; do echo "=== Worker $i ==="; tail -5 /tmp/worker_logs/worker_$i.log; echo; done

Check worker logs


=== Worker 1 ===
[07:45:47] worker booted (id=0321-dsm2-nvdgxa100-prxmx70052-2231403-55d2cc)
[07:45:50] === job rgv_1 (start status=awaiting_solospeech) ===
[07:45:50]   ▶ solospeech…
[07:45:51]   trying offer 29019330 (A100 SXM4 ram=81920MB $1.069/hr)
[07:45:53]   instance_id=34521381

=== Worker 2 ===
[07:45:51] === job rgv_5 (start status=awaiting_solospeech) ===
[07:45:51]   ▶ solospeech…
[07:45:52]   trying offer 29019330 (A100 SXM4 ram=81920MB $1.069/hr)
[07:45:53]   instance_id=34521382
[07:46:20]   ssh ready: root@ssh9.vast.ai:11382


Worker 2 (rgv_5) is SSH'd in and running. Worker 1 (rgv_1) hasn't reached SSH ready yet. Let me check:


set -a && source .env && set +a && python3 -c "
import requests, os
key = os.environ['VAST_API_KEY']
r = requests.get(f'https://console.vast.ai/api/v0/instances/?owner=me&api_key={key}', timeout=30)
for inst in r.json().get('instances', []):
    msg = (inst.get('status_msg') or '')[:80]
    print(f'{inst[\"id\"]} status={inst.get(\"actual_status\"):10s} gpu={inst.get(\"gpu_name\")} msg={msg or \"ok\"}')"

Check vast instances


34521381 status=loading    gpu=A100 SXM4 msg=ok
34521382 status=running    gpu=A100 SXM4 msg=success, running pytorch/pytorch_2.4.1-cuda12.4-cudnn9-devel/ssh


- **34521382** (rgv_5) — **running**, actively processing SoloSpeech
- **34521381** (rgv_1) — still **loading** (pulling Docker image), no errors

Both healthy, not stuck. rgv_1 is just slow to pull the image. Once it boots, it'll process the remaining 77/289 clips quickly.


there 2 or more jobs check which need gpu


set -a && source .env && set +a && python3 -c "
import sys;sys.path.insert(0,'.');import db
rows = db.query(\"SELECT name, status FROM jobs WHERE status NOT IN ('done','failed') ORDER BY name\")
for r in rows:
    print(f'{r[\"name\"]:15s} {r[\"status\"]}')
"

Check non-done jobs


kohli_1         awaiting_solospeech
kohli_3         awaiting_solospeech
rgv_1           awaiting_solospeech
rgv_5           awaiting_solospeech


4 jobs need GPU: **rgv_1, rgv_5, kohli_1, kohli_3**. Only 2 workers running, so rgv_1 and rgv_5 are on GPUs now. kohli_1 and kohli_3 will run once those finish.


is thoe running i dont see any progress


for i in 1 2; do echo "=== Worker $i ==="; tail -8 /tmp/worker_logs/worker_$i.log; echo; done

Check worker logs


=== Worker 1 ===
[07:45:47] worker booted (id=0321-dsm2-nvdgxa100-prxmx70052-2231403-55d2cc)
[07:45:50] === job rgv_1 (start status=awaiting_solospeech) ===
[07:45:50]   ▶ solospeech…
[07:45:51]   trying offer 29019330 (A100 SXM4 ram=81920MB $1.069/hr)
[07:45:53]   instance_id=34521381

=== Worker 2 ===
[07:45:47] worker booted (id=0321-dsm2-nvdgxa100-prxmx70052-2231404-4d0f4f)
[07:45:51] === job rgv_5 (start status=awaiting_solospeech) ===
[07:45:51]   ▶ solospeech…
[07:45:52]   trying offer 29019330 (A100 SXM4 ram=81920MB $1.069/hr)
[07:45:53]   instance_id=34521382
[07:46:20]   ssh ready: root@ssh9.vast.ai:11382


Worker 1 (rgv_1) never reached SSH ready — stuck at loading for 20+ min. Worker 2 (rgv_5) SSH'd in at 07:46 and has been running setup/processing for ~15 min. Let me check progress:


set -a && source .env && set +a && python3 -c "
import sys;sys.path.insert(0,'.');import r2

# rgv_5 progress
ov5 = len([k for k in r2.list_keys('rgv_5/overlap_cleaned/') if k.endswith('.wav')])
tot5 = len([k for k in r2.list_keys('rgv_5/overlap_notcleaned/') if k.endswith('.wav')])
print(f'rgv_5: {ov5}/{tot5} overlap cleaned')

# rgv_1 progress
ov1 = len([k for k in r2.list_keys('rgv_1/overlap_cleaned/') if k.endswith('.wav')])
tot1 = len([k for k in r2.list_keys('rgv_1/overlap_notcleaned/') if k.endswith('.wav')])
print(f'rgv_1: {ov1}/{tot1} overlap cleaned')
"

Check R2 progress for both jobs


rgv_5: 14/37 overlap cleaned
rgv_1: 212/289 overlap cleaned


**rgv_5: 14/37 done** — actively processing, uploading clips to R2. Working fine.

**rgv_1: 212/289** — same as before, Worker 1 is still stuck at loading. Let me kill that instance and get a new one:


set -a && source .env && set +a

# Kill only worker 1, keep worker 2 running
kill 2231403 2>/dev/null

# Destroy the stuck instance
python3 -c "
import requests, os
key = os.environ['VAST_API_KEY']
requests.delete(f'https://console.vast.ai/api/v0/instances/34521381/?api_key={key}', timeout=30)
print('destroyed stuck instance 34521381')
"

# Clear rgv_1 lock
python3 -c "
import sys;sys.path.insert(0,'.');import db
rows = db.query(\"SELECT id FROM jobs WHERE name='rgv_1'\")
db.execute('UPDATE jobs SET worker_id=NULL, gpu_instance_id=NULL, error=NULL, failed_at_status=NULL, status=\"awaiting_solospeech\" WHERE id=?', [rows[0]['id']])
print('rgv_1 released')
"

# Start a new worker
nohup .venv/bin/python -u worker.py > /tmp/worker_logs/worker_1.log 2>&1 &
echo "Worker 1 PID=$!"

Kill stuck worker, restart for rgv_1


destroyed stuck instance 34521381
Traceback (most recent call last):
  File "<string>", line 4, in <module>
  File "/home/ubuntu/karthyani_pipeline/worker/./db.py", line 25, in execute
    raise RuntimeError(f"DB error: {res}")
RuntimeError: DB error: {'type': 'error', 'error': {'message': 'SQLite input error: no such column: awaiting_solospeech (at offset 96)', 'code': 'SQL_INPUT_ERROR'}}
Worker 1 PID=2260578


set -a && source .env && set +a && python3 -c "
import sys;sys.path.insert(0,'.');import db
rows = db.query(\"SELECT id FROM jobs WHERE name='rgv_1'\")
db.execute(\"UPDATE jobs SET worker_id=NULL, gpu_instance_id=NULL, error=NULL, failed_at_status=NULL, status='awaiting_solospeech' WHERE id=?\", [rows[0]['id']])
print('rgv_1 released')
"

Fix rgv_1 status


rgv_1 released


Check new worker 1


[08:01:06] worker booted (id=0321-dsm2-nvdgxa100-prxmx70052-2260578-3f6ce4)
[08:01:09] === job kohli_3 (start status=awaiting_solospeech) ===
[08:01:09]   ▶ solospeech…
[08:01:10]   trying offer 32303621 (A100 PCIE ram=81920MB $1.202/hr)
[08:01:11]   instance_id=34522455


Worker 1 picked up kohli_3 instead (rgv_1 still had stale lock when it booted). Let me start another worker for rgv_1:


set -a && source .env && set +a
nohup .venv/bin/python -u worker.py > /tmp/worker_logs/worker_3.log 2>&1 &
echo "Worker 3 PID=$!"

Start worker 3 for rgv_1


Worker 3 PID=2261005


Check worker 3


[08:01:19] worker booted (id=0321-dsm2-nvdgxa100-prxmx70052-2261005-e8cdd7)


Check worker 3 after pickup


[08:01:19] worker booted (id=0321-dsm2-nvdgxa100-prxmx70052-2261005-e8cdd7)
[08:01:22] === job rgv_1 (start status=awaiting_solospeech) ===
[08:01:22]   ▶ solospeech…


Now 3 workers running:
- **Worker 2**: rgv_5 — 14/37 done, actively processing
- **Worker 1**: kohli_3 — just started on A100 PCIE
- **Worker 3**: rgv_1 — just started, only 77/289 remaining

kohli_1 will be picked up when one finishes.


check status again


for i in 1 2 3; do echo "=== Worker $i ==="; tail -5 /tmp/worker_logs/worker_$i.log; echo; done

Check worker logs