Allow txt export with timestampts

This commit is contained in:
Lucas Winther
2026-07-17 14:14:43 +02:00
parent a5cfd219f6
commit 770cfc9ff7
3 changed files with 121 additions and 2 deletions
+22 -1
View File
@@ -1,5 +1,10 @@
import { useEffect, useState } from "react";
import { exportFilename, transcriptToSrt, transcriptToTxt } from "../lib/export";
import {
exportFilename,
transcriptToSrt,
transcriptToTimestampedTxt,
transcriptToTxt,
} from "../lib/export";
import type { TranscriptChunk } from "../worker/protocol";
interface TranscriptViewProps {
@@ -121,6 +126,22 @@ export function TranscriptView({
.srt
</button>
)}
{result && result.chunks.length > 0 && (
<button
type="button"
className="copy-button"
title="Text with timestamps"
onClick={() =>
download(
transcriptToTimestampedTxt(result.chunks),
exportFilename(exportName, "timestamps.txt"),
"text/plain",
)
}
>
.txt+ts
</button>
)}
<button
type="button"
className="copy-button"
+30 -1
View File
@@ -59,10 +59,39 @@ export function transcriptToSrt(chunks: TranscriptChunk[]): string {
return cues.length === 0 ? "" : `${cues.join("\n\n")}\n`;
}
/** Seconds → compact clock timestamp, e.g. 3.21 → "00:03.2" (mm:ss.s). */
export function formatClockTimestamp(seconds: number): string {
const totalTenths = Math.max(0, Math.round(seconds * 10));
const tenths = totalTenths % 600;
const m = (totalTenths - tenths) / 600;
return `${String(m).padStart(2, "0")}:${(tenths / 10).toFixed(1).padStart(4, "0")}`;
}
/**
* Timestamped chunks → plain text, one line per chunk:
* "[00:03.2 – 00:07.9] text". A null end time — Whisper sometimes never
* closes the last segment — omits the range end: "[00:03.2] text".
* Empty-text chunks are skipped, mirroring transcriptToSrt.
*/
export function transcriptToTimestampedTxt(chunks: TranscriptChunk[]): string {
const lines = chunks
.filter((chunk) => chunk.text.trim() !== "")
.map((chunk) => {
const [start, end] = chunk.timestamp;
const range =
end === null
? formatClockTimestamp(start)
: `${formatClockTimestamp(start)} – ${formatClockTimestamp(end)}`;
return `[${range}] ${chunk.text.trim()}`;
});
return lines.length === 0 ? "" : `${lines.join("\n")}\n`;
}
/**
* Download filename for an export: the source file's base name with the
* extension swapped ("talk.m4a" → "talk.srt"), or "transcript.<ext>" when no
* usable source name is known.
* usable source name is known. `ext` may be compound ("timestamps.txt") so
* sibling exports of the same format get distinct names.
*/
export function exportFilename(sourceName: string | null | undefined, ext: string): string {
const trimmed = sourceName?.trim() ?? "";
+69
View File
@@ -1,8 +1,10 @@
import { describe, expect, test } from "bun:test";
import {
exportFilename,
formatClockTimestamp,
formatSrtTimestamp,
transcriptToSrt,
transcriptToTimestampedTxt,
transcriptToTxt,
} from "../src/lib/export";
import type { TranscriptChunk } from "../src/worker/protocol";
@@ -100,6 +102,64 @@ describe("transcriptToSrt", () => {
});
});
describe("formatClockTimestamp", () => {
test("zero", () => {
expect(formatClockTimestamp(0)).toBe("00:00.0");
});
test("pads minutes and seconds", () => {
expect(formatClockTimestamp(3.21)).toBe("00:03.2");
expect(formatClockTimestamp(65.4)).toBe("01:05.4");
});
test("rounding to tenths carries into the minutes", () => {
expect(formatClockTimestamp(59.96)).toBe("01:00.0");
});
test("minutes keep growing past an hour", () => {
expect(formatClockTimestamp(3661.5)).toBe("61:01.5");
});
test("clamps negatives to zero", () => {
expect(formatClockTimestamp(-1)).toBe("00:00.0");
});
});
describe("transcriptToTimestampedTxt", () => {
const chunk = (
text: string,
start: number,
end: number | null,
): TranscriptChunk => ({ text, timestamp: [start, end] });
test("one bracketed range per line, trailing newline", () => {
const txt = transcriptToTimestampedTxt([
chunk(" Hello.", 3.21, 7.9),
chunk(" World.", 7.9, 12),
]);
expect(txt).toBe("[00:03.2 – 00:07.9] Hello.\n[00:07.9 – 00:12.0] World.\n");
});
test("null end omits the range end", () => {
expect(transcriptToTimestampedTxt([chunk("Tail", 10, null)])).toBe(
"[00:10.0] Tail\n",
);
});
test("skips empty and whitespace-only chunks", () => {
const txt = transcriptToTimestampedTxt([
chunk(" ", 0, 1),
chunk("Kept", 1, 2),
]);
expect(txt).toBe("[00:01.0 – 00:02.0] Kept\n");
});
test("no captioned chunks yields an empty string", () => {
expect(transcriptToTimestampedTxt([])).toBe("");
expect(transcriptToTimestampedTxt([chunk(" ", 0, 1)])).toBe("");
});
});
describe("exportFilename", () => {
test("swaps the extension", () => {
expect(exportFilename("talk.m4a", "srt")).toBe("talk.srt");
@@ -120,6 +180,15 @@ describe("exportFilename", () => {
expect(exportFilename(".bashrc", "txt")).toBe(".bashrc.txt");
});
test("compound extension keeps timestamped exports distinct", () => {
expect(exportFilename("talk.m4a", "timestamps.txt")).toBe(
"talk.timestamps.txt",
);
expect(exportFilename(null, "timestamps.txt")).toBe(
"transcript.timestamps.txt",
);
});
test("null, undefined, and blank fall back to transcript.*", () => {
expect(exportFilename(null, "srt")).toBe("transcript.srt");
expect(exportFilename(undefined, "txt")).toBe("transcript.txt");