Allow txt export with timestampts
This commit is contained in:
@@ -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
@@ -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() ?? "";
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user