Our content security policy allows exactly two script sources: our own origin and Google's tag manager. That one line quietly ruled out every subtitle library we would normally reach for. No CDN, no npm bundle, no WASM — if it isn't served from our own Worker, the browser refuses to load it.
So we wrote the tools ourselves. This post is about what that constraint actually forced, and the parts that turned out to be less work than expected.
Why the policy is that strict
The site is a single Cloudflare Worker serving static HTML plus one transcription endpoint backed by Workers AI. There is no build step that bundles third-party client code, and no supply chain to audit beyond "what we wrote". Tightening script-src to 'self' plus the analytics host was the cheapest way to make that claim true rather than aspirational.
The cost is that anything running in the browser has to be written by hand.
SRT and VTT parsing is string handling
This is the part people overestimate. SRT and VTT are close enough that one parser handles both:
1
00:00:01,000 --> 00:00:04,000
First line
2
00:00:04,500 --> 00:00:07,200
Second line
VTT differs in three ways that matter:
- it starts with a
WEBVTTheader line - timestamps use a period before milliseconds instead of a comma
- cue identifiers are optional and cue settings can follow the timestamp
So the parse is: strip a leading WEBVTT line, split the remainder on blank lines, and for each block look for a line containing -->. Everything before it is either a cue number or a cue id; everything after is text, kept byte for byte.
function parseCues(raw) {
const body = raw.replace(/^\uFEFF/, '').replace(/^WEBVTT[^\n]*\n+/, '');
return body.split(/\n{2,}/).map(block => {
const lines = block.split(/\r?\n/);
const arrowAt = lines.findIndex(l => l.includes('-->'));
if (arrowAt === -1) return null;
const [startRaw, endRaw] = lines[arrowAt].split('-->');
return {
start: parseTime(startRaw.trim()),
end: parseTime(endRaw.trim().split(/\s+/)[0]),
text: lines.slice(arrowAt + 1).join('\n'),
};
}).filter(Boolean);
}
function parseTime(s) {
const m = s.match(/(?:(\d+):)?(\d{1,2}):(\d{2})[.,](\d{1,3})/);
if (!m) return NaN;
const [, h = 0, mi, se, ms] = m;
return (+h) * 3600 + (+mi) * 60 + (+se) + (+ms.padEnd(3, '0')) / 1000;
}
That's most of a converter. Writing VTT is the same structure with a header and periods.
The genuinely useful part isn't conversion, though — it's validation. Once cues are parsed into objects you can check things a converter silently ignores:
- numbering that skips or repeats
- cues in non-monotonic order
- overlapping cues
- zero-length or negative-duration cues
- lines long enough to overflow a subtitle safe area
Reporting these without touching the file is more valuable than rewriting it, because the file usually came from somewhere else and the user needs to know what's wrong with it.
Shifting timestamps is one addend
After trimming audio, every cue is late by the same amount. The fix is not editing cues one at a time:
const shift = cue => ({ ...cue, start: cue.start + d, end: cue.end + d });
The only care needed is at the boundaries — a negative result after shifting back has to be clamped to zero, and rounding should happen once at write time, not per operation, or error accumulates across a chain of edits.
Cleaning a transcript is regex passes plus a counter
Stripping timings from a transcript is a sequence of independent passes rather than one clever expression:
const passes = [
raw => raw.replace(/^WEBVTT[^\n]*$/gm, ''),
raw => raw.replace(/^\d+$/gm, ''), // cue numbers
raw => raw.replace(/^\s*\d{1,2}:\d{2}:\d{2}[.,]\d{1,3}\s*-->.*$/gm, ''),
raw => raw.replace(/^\[[^\]]*\]$/gm, ''), // bracketed notes
raw => raw.replace(/^[A-Z][A-Z\s]+:\s?/gm, ''), // SPEAKER:
raw => raw.replace(/\n{3,}/g, '\n\n'),
];
Filler-word removal is the one pass that has to be optional and loudly labelled. It changes what was said. If the transcript is evidence, an interview quote, or anything where exact wording carries weight, leaving it off is the correct choice — and a tool that doesn't make that obvious is doing harm.
Word count and speaking time come free once the text is a single string: split on whitespace for words, divide by the measured rate for time. We measure rate against the file duration rather than assuming a constant, because the number people quote (words per minute from a transcript) is usually wrong for exactly that reason.
Audio: decode, copy, write a WAV header
The Web Audio API covers this without a library. decodeAudioData gives you PCM channels; trimming is copying a range of samples; joining is concatenating ranges into one buffer.
const buf = await ctx.decodeAudioData(arrayBuffer);
const from = Math.floor(startSec * buf.sampleRate);
const to = Math.floor(endSec * buf.sampleRate);
const out = ctx.createBuffer(buf.numberOfChannels, to - from, buf.sampleRate);
for (let ch = 0; ch < buf.numberOfChannels; ch++) {
out.copyToChannel(buf.getChannelData(ch).subarray(from, to), ch);
}
Export is the interesting constraint. MP3 needs an encoder, which means a library, which the CSP forbids. WAV needs a 44-byte header and 16-bit signed samples — no encoder at all:
function writeWavHeader(dv, samples, sampleRate, channels) {
const bytes = samples * 2;
dv.setUint32(0, 0x52494646, false); // 'RIFF'
dv.setUint32(4, 36 + bytes, true);
dv.setUint32(8, 0x57415645, false); // 'WAVE'
dv.setUint32(12, 0x666d7420, false); // 'fmt '
dv.setUint32(16, 16, true);
dv.setUint16(20, 1, true); // PCM
dv.setUint16(22, channels, true);
dv.setUint32(24, sampleRate, true);
dv.setUint32(28, sampleRate * channels * 2, true);
dv.setUint16(32, channels * 2, true);
dv.setUint16(34, 16, true);
dv.setUint32(36, 0x64617461, false); // 'data'
dv.setUint32(40, bytes, true);
}
WAV is larger, but it's lossless, universally readable, and needs zero dependencies. For "cut ninety seconds of silence off the front so the transcription stops failing", that's the right trade.
The part worth emphasising
Everything above runs on data that never leaves the machine. No upload, no account, no watermark. That isn't a marketing position, it's an architectural consequence: the tools are static HTML plus local JavaScript, so there is nothing to send anywhere. You can verify it — open devtools, watch the network panel, and count the requests. There won't be any beyond the page and analytics.
If you're building something similar under a strict CSP, the pattern that worked for us:
- Parse into plain objects before doing anything else. Every operation after that is a pure function on that array.
- Separate "read and report" from "write and change". Most users want the report.
- Make destructive transformations explicit and off by default.
- Prefer formats with hand-writable containers. WAV over MP3, plain text over anything compressed.
Try the tools: https://transcriptreel.com/tools?utm_source=devto&utm_medium=article&utm_campaign=tools_launch&utm_content=tools
Written guides: https://transcriptreel.com/blog
Top comments (0)