
When I started building a transcription app, exporting looked like one of the easiest features.
The transcript was already stored in the database. I only needed to turn it into a text file and send it to the browser.
That worked for TXT.
Then I added subtitles, editable speaker names, timestamps, DOCX files, and automatic saving. Suddenly, exporting was no longer a small utility function. It became a consistency problem.
The difficult question was not:
How do I create an SRT file?
It was:
How do I guarantee that every export contains the latest version of the transcript?
This post covers the design decisions that helped make the export workflow more reliable.
Start with structured transcript data
A transcript should not be stored as one large string if it needs timestamps, speakers, subtitles, or synchronized playback.
A more useful structure is an ordered list of segments:
type TranscriptSegment = {
id: string;
startTimeMs: number;
endTimeMs: number;
speakerId: string | null;
text: string;
};
type Speaker = {
id: string;
name: string;
};
type Transcript = {
id: string;
version: number;
durationMs: number;
speakers: Speaker[];
segments: TranscriptSegment[];
};
This structure provides enough information to generate several outputs from the same source:
- TXT
- Markdown
- SRT
- VTT
- CSV
- JSON
- DOCX
It also keeps the editing interface and export pipeline aligned. The same segment used to highlight the current sentence during playback can later become a subtitle cue.
Keep time values in milliseconds
It is tempting to store timestamps as strings such as 00:04:12.500.
That format is readable, but it is inconvenient for calculations.
Milliseconds are easier to compare, sort, validate, and convert:
const segment: TranscriptSegment = {
id: "segment-42",
startTimeMs: 252_500,
endTimeMs: 258_200,
speakerId: "speaker-2",
text: "We should move the release to Friday."
};
Formatting should happen only when an output requires it.
For SRT:
function formatSrtTime(milliseconds: number): string {
const totalSeconds = Math.floor(milliseconds / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const ms = milliseconds % 1000;
return [
String(hours).padStart(2, "0"),
String(minutes).padStart(2, "0"),
String(seconds).padStart(2, "0")
].join(":") + `,${String(ms).padStart(3, "0")}`;
}
For WebVTT, the comma becomes a period:
function formatVttTime(milliseconds: number): string {
return formatSrtTime(milliseconds).replace(",", ".");
}
Keeping one internal time representation prevents conversion bugs from spreading through the application.
Validate segments before generating files
Speech-recognition output is not always perfectly clean.
A segment may have an invalid duration. Two segments may overlap. Editing operations may accidentally leave an empty segment in the transcript.
The export layer should not assume that every input is valid.
function validateSegments(segments: TranscriptSegment[]): string[] {
const errors: string[] = [];
for (let index = 0; index < segments.length; index++) {
const segment = segments[index];
if (!segment.text.trim()) {
errors.push(`Segment ${segment.id} has no text.`);
}
if (segment.startTimeMs < 0) {
errors.push(`Segment ${segment.id} starts before zero.`);
}
if (segment.endTimeMs <= segment.startTimeMs) {
errors.push(`Segment ${segment.id} has an invalid duration.`);
}
const next = segments[index + 1];
if (next && segment.startTimeMs > next.startTimeMs) {
errors.push("Segments are not ordered by start time.");
}
}
return errors;
}
Whether overlapping segments should be rejected depends on the product.
Overlaps may be valid when two people speak simultaneously. For simple subtitle exports, however, they may produce poor results. In that case, the exporter can adjust timings or warn the user.
The important point is to make this behavior explicit.
Separate transcript data from presentation data
The editor may display speaker badges, confidence indicators, highlighted search terms, or paragraph breaks.
Those details should not leak into the export logic.
Exporters should receive clean domain data:
type ExportContext = {
transcript: Transcript;
includeSpeakers: boolean;
includeTimestamps: boolean;
};
A TXT exporter can then decide how to represent it:
function exportTxt(context: ExportContext): string {
const speakerMap = new Map(
context.transcript.speakers.map(speaker => [
speaker.id,
speaker.name
])
);
return context.transcript.segments
.map(segment => {
const parts: string[] = [];
if (context.includeTimestamps) {
parts.push(`[${formatReadableTime(segment.startTimeMs)}]`);
}
if (context.includeSpeakers && segment.speakerId) {
const speakerName =
speakerMap.get(segment.speakerId) ?? "Unknown speaker";
parts.push(`${speakerName}:`);
}
parts.push(segment.text.trim());
return parts.join(" ");
})
.join("\n\n");
}
This keeps the exporter independent of React components, CSS classes, and editor state.
Save before export
This was the source of one of the most confusing bugs in the app.
A user edited a sentence and immediately clicked Export. The downloaded file contained the previous version.
The editor used debounced autosaving:
const saveChanges = debounce(async (segments) => {
await updateTranscript(segments);
}, 1000);
The visible editor state was newer than the saved server state. The export endpoint generated its file from the database, so the most recent edits were missing.
The solution was to make pending changes flushable.
type PendingSave = {
segments: TranscriptSegment[];
resolve: () => void;
reject: (error: unknown) => void;
};
Before starting an export, the client now waits for the current save operation:
async function handleExport(format: ExportFormat): Promise<void> {
await transcriptEditor.flushPendingChanges();
const response = await fetch(
`/api/transcripts/${transcriptId}/exports/${format}`,
{
method: "POST",
headers: {
"Content-Type": "application/json"
}
}
);
if (!response.ok) {
throw new Error("The export could not be generated.");
}
const blob = await response.blob();
downloadBlob(blob, createFilename(format));
}
The export button also displays a temporary state:
- Saving changes
- Preparing export
- Downloading
- Export complete
- Export failed
This is more useful than showing one generic loading spinner.
Add version checks
Waiting for a save solves most cases, but versioning makes the system safer.
Each successful transcript update increments a version number:
type TranscriptUpdateResponse = {
transcriptId: string;
version: number;
updatedAt: string;
};
The client includes that version when requesting an export:
await fetch(`/api/transcripts/${transcriptId}/exports/srt`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
expectedVersion: transcriptVersion
})
});
The server verifies that it is exporting the expected version:
if (transcript.version !== request.expectedVersion) {
return new Response(
JSON.stringify({
error: "TRANSCRIPT_VERSION_CONFLICT",
currentVersion: transcript.version
}),
{
status: 409,
headers: {
"Content-Type": "application/json"
}
}
);
}
This protects against stale browser tabs, concurrent editing, and race conditions between saving and exporting.
Generate SRT from segments
Once the data is validated, SRT generation is straightforward:
function exportSrt(transcript: Transcript): string {
return transcript.segments
.filter(segment => segment.text.trim())
.map((segment, index) => {
return [
index + 1,
`${formatSrtTime(segment.startTimeMs)} --> ${formatSrtTime(
segment.endTimeMs
)}`,
segment.text.trim()
].join("\n");
})
.join("\n\n");
}
A basic result looks like this:
1
00:00:02,400 --> 00:00:05,700
Thanks for joining the meeting.
2
00:00:06,100 --> 00:00:10,200
Today we need to finalize the release date.
Real subtitle exports may need additional processing.
Very long transcript segments should be split into shorter cues. Extremely short segments may need to be merged. Line lengths may need limits for readability.
These transformations should happen in a dedicated subtitle-normalization stage rather than modifying the original transcript.
const subtitleSegments = normalizeForSubtitles(
transcript.segments,
{
maxCharactersPerCue: 84,
minimumDurationMs: 800,
maximumDurationMs: 7_000
}
);
The transcript remains unchanged, while the subtitle output is optimized for its own use case.
Treat every export format as an adapter
I originally wrote one large export function with several conditional branches:
if (format === "txt") {
// ...
} else if (format === "srt") {
// ...
} else if (format === "json") {
// ...
}
It became difficult to test as formats were added.
A small adapter interface worked better:
type ExportResult = {
content: Uint8Array;
contentType: string;
extension: string;
};
interface TranscriptExporter {
export(context: ExportContext): Promise<ExportResult>;
}
Each format has its own implementation:
const exporters: Record<ExportFormat, TranscriptExporter> = {
txt: new TxtExporter(),
srt: new SrtExporter(),
vtt: new VttExporter(),
json: new JsonExporter(),
csv: new CsvExporter(),
docx: new DocxExporter(),
pdf: new PdfExporter()
};
The API handler stays small:
const exporter = exporters[format];
if (!exporter) {
return new Response("Unsupported export format", {
status: 400
});
}
const result = await exporter.export(context);
This also makes unit testing much simpler.
Use the same model for audio and video
The input may be an MP3 interview or an MP4 webinar, but the output model does not need to change.
After media processing, both become:
- a duration
- a list of timed transcript segments
- optional speaker data
- editable text
For users, there may be separate entry points such as MP3 to Transcript for audio and MP4 to Transcript for video. Internally, however, both can use the same editor, validation rules, and exporters.
Keeping the post-processing workflow format-independent reduces duplication and makes new input formats easier to support.
Test the generated files, not only the functions
A unit test that checks whether an SRT string contains a timestamp is useful, but it is not enough.
Export tests should cover complete files:
describe("SrtExporter", () => {
it("exports ordered subtitle cues", async () => {
const result = await exporter.export(testContext);
const content = new TextDecoder().decode(result.content);
expect(content).toContain(
"00:00:02,400 --> 00:00:05,700"
);
expect(content).toContain(
"Thanks for joining the meeting."
);
});
});
I also keep a small set of manual test transcripts:
- one speaker
- multiple speakers
- Unicode characters
- right-to-left text
- empty segments
- overlapping speech
- a multi-hour recording
- very long paragraphs
- timestamps near an hour boundary
Generated DOCX and PDF files should be opened in real viewers. Subtitle files should be imported into at least one video player or editor.
A file can be technically generated without being practically usable.
What I would design differently today
I would treat exporting as part of the core transcript architecture from the beginning.
That means:
- Store structured, time-aligned segments.
- Use milliseconds internally.
- Keep editor UI data out of the domain model.
- Validate before exporting.
- Flush pending edits first.
- Check transcript versions.
- Give each output format its own adapter.
- Test actual generated files.
Exporting is not simply the last button in the workflow.
It is the point where users expect the application to return a trustworthy copy of their work. If the file contains stale text, broken timestamps, or incorrect speaker names, the rest of the transcription experience no longer matters.
The most reliable export pipeline is the one that treats consistency as a feature rather than an implementation detail.
Top comments (0)