Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 21 additions & 9 deletions backend/app/workers/transcription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,24 @@ const capability: JobCapability = {
const mongo = await auth.getResource("mongo");
const transcriptionResource = await getTranscriptionResource(auth);

const processSequence = async (sequence: any) => {
const processSequence = async (sequence: any, batchInfo?: { current: number; total: number }) => {
if (!sequence || sequence.state !== "ready") {
return { status: "skipped", reason: "Sequence not found or not ready" };
}

await job.updateProgress({ stage: "processing", sequenceId: sequence._id.toString() });
const progressBase = {
sequenceId: sequence._id.toString(),
timeRange: {
start: sequence.start,
end: sequence.end,
},
...(batchInfo && { sequence: batchInfo }),
};

await job.updateProgress({
...progressBase,
stage: "processing",
});

// 2. Mark sequence as processing
await mongo({
Expand All @@ -38,7 +50,7 @@ const capability: JobCapability = {
});

try {
await job.updateProgress({ stage: "fetching_chunks", sequenceId: sequence._id.toString() });
await job.updateProgress({ ...progressBase, stage: "fetching_chunks" });
// 3. Get all chunks for this sequence using the range
const chunks = await mongo({
action: "find",
Expand All @@ -54,11 +66,11 @@ const capability: JobCapability = {
throw new Error("No chunks found for sequence range");
}

await job.updateProgress({ stage: "combining_audio", chunkCount: chunks.length });
await job.updateProgress({ ...progressBase, stage: "combining_audio", chunkCount: chunks.length });
// 4. Combine chunks into one audio file
const combinedAudio = await combineChunks(chunks);

await job.updateProgress({ stage: "transcribing", audioSize: combinedAudio.length });
await job.updateProgress({ ...progressBase, stage: "transcribing", audioSize: combinedAudio.length });
// 5. Call transcription API
const transcript = await transcriptionResource({
action: "transcribe",
Expand All @@ -72,7 +84,7 @@ const capability: JobCapability = {
const filteredSegments = filterSegments(segments);

if (filteredSegments.length === 0) {
await job.updateProgress({ stage: "empty_result" });
await job.updateProgress({ ...progressBase, stage: "empty_result" });
// No speech detected after filtering
await mongo({
action: "updateOne",
Expand All @@ -85,7 +97,7 @@ const capability: JobCapability = {
}

const duration = filteredSegments[filteredSegments.length - 1].end;
await job.updateProgress({ stage: "saving_result", duration });
await job.updateProgress({ ...progressBase, stage: "saving_result", duration });

// 7. Save transcription result
const transcriptionDoc = {
Expand Down Expand Up @@ -131,7 +143,7 @@ const capability: JobCapability = {
update: { $set: { state: "completed", updatedAt: new Date() } },
});

await job.updateProgress({ stage: "completed" });
await job.updateProgress({ ...progressBase, stage: "completed" });
return { status: "success", result: "transcribed" };

} catch (error) {
Expand Down Expand Up @@ -168,8 +180,8 @@ const capability: JobCapability = {
let processedCount = 0;
const total = readySequences.length;
for (const sequence of readySequences) {
await processSequence(sequence);
processedCount++;
await processSequence(sequence, { current: processedCount, total });
await job.updateProgress({ processed: processedCount, total });
}
return { status: "success", processed: processedCount };
Expand Down
61 changes: 61 additions & 0 deletions frontend/src/pages/JobDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,67 @@ export default function JobDetailPage() {
</Card>
)}

{job.progress && (
<Card>
<CardHeader>
<CardTitle>Progress</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{job.progress.stage && (
<div>
<div className="text-sm text-muted-foreground mb-1">Stage</div>
<div className="font-medium capitalize">{job.progress.stage.replace(/_/g, " ")}</div>
</div>
)}
{job.progress.sequence && (
<div>
<div className="text-sm text-muted-foreground mb-1">Sequence</div>
<div className="font-medium">
{job.progress.sequence.current} of {job.progress.sequence.total}
</div>
</div>
)}
{typeof job.progress.processed === "number" && typeof job.progress.total === "number" && (
<div>
<div className="text-sm text-muted-foreground mb-1">Processed</div>
<div className="font-medium">
{job.progress.processed} / {job.progress.total} ({Math.round((job.progress.processed / job.progress.total) * 100)}%)
</div>
</div>
)}
{job.progress.timeRange && (
<div>
<div className="text-sm text-muted-foreground mb-1">Time Range</div>
<div className="font-medium">
{format(new Date(job.progress.timeRange.start), "PPpp")}
<span className="text-muted-foreground mx-2">→</span>
{format(new Date(job.progress.timeRange.end), "PPpp")}
</div>
</div>
)}
{job.progress.duration && (
<div>
<div className="text-sm text-muted-foreground mb-1">Audio Duration</div>
<div className="font-medium">
{Math.floor(job.progress.duration / 60)}m {Math.round(job.progress.duration % 60)}s
</div>
</div>
)}
{job.progress.chunkCount && (
<div>
<div className="text-sm text-muted-foreground mb-1">Chunks</div>
<div className="font-medium">{job.progress.chunkCount}</div>
</div>
)}
{job.progress.audioSize && (
<div>
<div className="text-sm text-muted-foreground mb-1">Audio Size</div>
<div className="font-medium">{(job.progress.audioSize / 1024 / 1024).toFixed(1)} MB</div>
</div>
)}
</CardContent>
</Card>
)}

</div>
</div>
Expand Down
147 changes: 132 additions & 15 deletions frontend/src/pages/JobsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,14 @@ export default function JobsPage() {
return `${minutes}m ${seconds}s`;
};

const formatETASeconds = (seconds: number): string => {
if (seconds < 60) return "< 1m";
if (seconds < 3600) return `~${Math.round(seconds / 60)}m`;
const hours = Math.floor(seconds / 3600);
const mins = Math.round((seconds % 3600) / 60);
return `~${hours}h ${mins}m`;
};

const getProgressPercentage = (progress: any): number | null => {
if (!progress || typeof progress !== "object") return null;
if (typeof progress.progress === "number") return progress.progress;
Expand All @@ -211,6 +219,48 @@ export default function JobsPage() {
return null;
};

const calculateETA = (job: JobInfo): string | null => {
if (job.state !== "active") return null;
const progress = job.progress;
if (!progress?.processed || !progress?.total || !job.processedOn) return null;

const elapsed = (currentTime - job.processedOn) / 1000; // seconds
if (elapsed < 5) return "Calculating...";

const processed = progress.processed;
const total = progress.total;
const remaining = total - processed;

if (remaining <= 0) return null;

const rate = processed / elapsed; // items per second
if (rate <= 0) return null;

const etaSeconds = remaining / rate;
return formatETASeconds(etaSeconds);
};

const TRANSCRIPTION_STAGES = ["processing", "fetching_chunks", "combining_audio", "transcribing", "saving_result", "completed"] as const;
const STAGE_LABELS: Record<string, string> = {
processing: "Starting...",
fetching_chunks: "Fetching audio",
combining_audio: "Combining audio",
transcribing: "Transcribing",
saving_result: "Saving",
completed: "Done",
empty_result: "No speech detected",
};

const getStageProgress = (stage: string): { current: number; total: number; label: string } | null => {
const idx = TRANSCRIPTION_STAGES.indexOf(stage as any);
if (idx === -1) return null;
return {
current: idx + 1,
total: TRANSCRIPTION_STAGES.length,
label: STAGE_LABELS[stage] || stage,
};
};

const handleCancelAll = async () => {
if (
!confirm(
Expand Down Expand Up @@ -424,28 +474,95 @@ export default function JobsPage() {
</TableCell>
<TableCell>
{job.progress ? (
<div className="space-y-2">
<div className="space-y-1">
{(() => {
const percentage = getProgressPercentage(job.progress);
const eta = calculateETA(job);
const hasProcessedTotal = typeof job.progress?.processed === "number" && typeof job.progress?.total === "number";
const stageProgress = job.progress?.stage ? getStageProgress(job.progress.stage) : null;
const timeRange = job.progress?.timeRange as { start: string; end: string } | undefined;
const sequenceInfo = job.progress?.sequence as { current: number; total: number } | undefined;
const chunkCount = job.progress?.chunkCount as number | undefined;
const audioDuration = job.progress?.duration as number | undefined;
const otherFields = typeof job.progress === "object"
? Object.entries(job.progress).filter(([k]) =>
!["processed", "total", "stage", "sequenceId", "timeRange", "sequence", "chunkCount", "duration", "audioSize"].includes(k)
)
: [];

return (
<>
{percentage !== null && (
<Progress value={percentage} className="h-2" />
{percentage !== null ? (
<div className="flex items-center gap-2">
<Progress value={percentage} className="h-2 flex-1" />
<span className="text-xs text-muted-foreground whitespace-nowrap">
{Math.round(percentage)}%
</span>
</div>
) : stageProgress && (
<div className="flex items-center gap-2">
<Progress value={(stageProgress.current / stageProgress.total) * 100} className="h-2 flex-1" />
<span className="text-xs text-muted-foreground whitespace-nowrap">
{stageProgress.current}/{stageProgress.total}
</span>
</div>
)}
{sequenceInfo && (
<div className="text-xs font-medium">
Loop {sequenceInfo.current} of {sequenceInfo.total}
</div>
)}
{timeRange && (
<div className="text-xs text-muted-foreground">
<span className="opacity-70">Range:</span>{" "}
{format(new Date(timeRange.start), "MMM d HH:mm:ss")} → {format(new Date(timeRange.end), "HH:mm:ss")}
</div>
)}
<div className="text-xs space-y-1">
{typeof job.progress === "object" ? (
Object.entries(job.progress)
.slice(0, 3)
.map(([k, v]) => (
{stageProgress && (
<div className="text-xs text-muted-foreground">
{stageProgress.label}
</div>
)}
{hasProcessedTotal && !sequenceInfo && (
<div className="text-xs font-medium">
{job.progress.processed} / {job.progress.total}
</div>
)}
{(chunkCount || audioDuration) && (
<div className="text-xs text-muted-foreground flex gap-2">
{chunkCount && <span>{chunkCount} chunks</span>}
{audioDuration && (
<span>
{Math.floor(audioDuration / 60)}m {Math.round(audioDuration % 60)}s audio
</span>
)}
</div>
)}
{eta && (
<div className="text-xs text-muted-foreground">
ETA: {eta}
</div>
)}
{otherFields.length > 0 && (
<div className="text-xs text-muted-foreground space-y-0.5">
{otherFields.slice(0, 2).map(([k, v]) => {
let displayValue = String(v);
// Format duration/audioSize as readable values
if (k === "duration" && typeof v === "number") {
const mins = Math.floor(v / 60);
const secs = Math.round(v % 60);
displayValue = `${mins}m ${secs}s`;
} else if (k === "audioSize" && typeof v === "number") {
displayValue = `${(v / 1024 / 1024).toFixed(1)} MB`;
}
return (
<div key={k}>
<span className="opacity-70">{k}:</span>{" "}
{String(v)}
<span className="opacity-70">{k}:</span> {displayValue}
</div>
))
) : (
<span>{String(job.progress)}</span>
)}
</div>
);
})}
</div>
)}
</>
);
})()}
Expand Down