npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@verifyhash/subtitle-tools

v0.1.0

Published

Client-side subtitle timing/format toolkit core (shift, linear resync, SRT/VTT convert). Pure, zero-dependency, no DOM, no network. Staged incubator scaffold.

Readme

subtitle-tools

A client-side subtitle timing and format toolkit — shift, resync, and convert subtitle files (.srt, .vtt, .ass/.ssa) with everything running locally. No upload, no signup, no transcription upsell.

Status: staged incubator core (v0.1.0). The pure computation core implements parse, serialize, timeShift, linearResync, renumber, convert (SRT ⇄ VTT), fixOverlaps, merge, split, and analyzeReadingSpeed, all covered by byte-exact golden vectors. See DECISION.md for why this candidate was chosen. Go-live on its own domain is a later, human-gated proposal; nothing here is wired to a domain, deployed, or published.

Install

npm install @verifyhash/subtitle-tools

Package name: @verifyhash/subtitle-tools is a placeholder scope — the final published name is the owner's call and may change before the first real publish. Nothing here is published yet.

Zero runtime dependencies. Ships core.js (CommonJS), TypeScript types (index.d.ts), this README, and the MIT LICENSE.

const st = require('@verifyhash/subtitle-tools');
// (during local development: require('./core.js'))

const srt = `1
00:00:01,000 --> 00:00:03,000
Hello, world!

2
00:00:04,000 --> 00:00:06,000
Second line.
`;

// Shift the whole track 2.5s later, then convert SRT -> WebVTT:
const shifted = st.timeShift(srt, 2500);
const vtt = st.convert(shifted, 'srt', 'vtt');
console.log(vtt);
// WEBVTT
//
// 1
// 00:00:03.500 --> 00:00:05.500
// Hello, world!
// ...

// Reports return plain objects, not text:
const report = st.analyzeReadingSpeed(srt, { maxCps: 17 });
console.log(report.summary); // { total: 2, tooFastCount: 0, worstCps: ... }

Every function is pure (string/array in, string or plain report object out) — no DOM, no network, no filesystem. See the API table below for the full surface.

Who it's for

  • Anyone who downloaded subtitles that are a few seconds early or late and wants to shift the whole file to match.
  • People whose subtitles slowly drift out of sync because the video and the subtitle file came from different frame rates (23.976 vs 25 fps) — fixed by a two-anchor linear resync.
  • Developers/creators who have .srt files but need WebVTT (.vtt) for HTML5 <track> or YouTube.

The wedge vs. existing online tools (HappyScribe, Maestra, VEED, GoTranscript, SubShifter): this is single-purpose, runs entirely in the browser so files never leave the device, and never funnels into a paid transcription product.

API (core.js)

All functions are pure — string(s) in, string/object out. No DOM, no network, no filesystem. The named constants NAME, VERSION, FORMATS (the eight readable formats) and FRAME_RATES are also exported. Full TypeScript signatures live in index.d.ts.

| Function | Purpose | |---|---| | parse(text, format?) | Parse a subtitle file into [{ index, startMs, endMs, text }] cues. Reads SRT (HH:MM:SS,mmm) and VTT (HH:MM:SS.mmm, skipping the WEBVTT header and NOTE/STYLE/REGION blocks); auto-detects when format is omitted; throws an honest Error on malformed input. | | serialize(cues, format) | The inverse of parse. Emits LF-normalized SRT or VTT; parse → serialize round-trips a well-formed file byte-for-byte. | | timeShift(text, offsetMs) | Add a constant offset to every cue (positive = later, negative = earlier); resulting negative times clamp to 0. Returns the same format it was given. | | shiftAfter(text, fromMs, offsetMs) | Partial-region shift: apply timeShift's constant offset (with the same clamp-to-0) only to cues whose start is >= fromMs, leaving earlier cues exactly as authored — for tracks that stay synced until an ad-break/scene then drift. A cue whose start === fromMs is included (>= pin). fromMs = 0 is byte-identical to a whole-file timeShift(text, offsetMs); a fromMs past the last cue's start matches nothing and returns the input unchanged. Format (SRT/VTT) preserved like timeShift. | | linearResync(text, anchorA, anchorB) | Two-anchor stretch/compress for frame-rate drift a single offset can't fix. Each anchor is {oldMs, newMs}; derives scale = (newB−newA)/(oldB−oldA) and offset = newA − scale·oldA, then applies t' = round(scale·t + offset) to every cue start/end. Preserves format, clamps negative results to 0, and is byte-identical to serialize(parse(text)) for no-op anchors. Throws an honest Error when oldA == oldB (undefined scale) or an anchor is missing oldMs/newMs. | | renumber(text) | Re-sequence SRT cue index numbers to 1..N in document order, fixing gaps or out-of-order numbering. WebVTT cue identifiers are optional and need not be sequential integers, so for VTT this is an honest format-preserving no-op (round-trip, identifiers untouched). | | convert(text, fromFormat, toFormat) | Convert between formats, SRT ⇄ VTT both directions, preserving cue text, order, and blank-line separation. Also reads (one-way, into SRT/VTT) ass/ssa, microdvd (needs fps), sbv, and ttml/dfxp (see parseTtml); those write directions stay refused with an honest unsupported toFormat error because their styling/layout can't be synthesized from a plain cue. lrc works BOTH directions (see parseLrc/serializeLrc): with toFormat = 'lrc' the return is an object { text, warning } (not a bare string) whose warning honestly states that cue end times were dropped — LRC has no end field, so the loss is surfaced, never silent. | | parseLrc(text, { maxDisplayMs = 8000 }) | Read an LRC lyrics file into the [{ index, startMs, endMs, text }] cue model (plus start/end aliases). LRC is start-only ([mm:ss.xx] stamps; centiseconds, also accepts .xxx ms), and a single line may carry several stamps (a repeated lyric) — each becomes its own cue sharing that line's text. Pinned end rule: after all stamps are expanded and cues sorted ascending by start, each cue's end = the next cue's start, capped so no cue lasts longer than maxDisplayMs (default 8000 ms); the last cue (no successor) gets exactly the cap; equal starts give a zero-duration cue (never inverted). Metadata tags [ar:] [ti:] [al:] [by:] (and any other non-timestamp bracket tag) are ignored. [offset:+N]/[offset:-N] is honored as a global ms shift with the sign pinned: positive = shift starts LATER (start += offset); a start driven below 0 clamps to 0. Empty or whitespace-only input returns [] (never throws). Pure: string in, array out. | | serializeLrc(cues) | Write cues to LRC lyrics text — [mm:ss.xx]text, one start stamp per line. Ends are dropped because LRC cannot represent them (this is why convert(…, 'lrc') returns a warning). parseLrc → serializeLrc → parseLrc preserves the start times (ends are re-synthesized by the pinned rule on re-parse). Minutes are not rolled into hours (70:00.00 for a 70-minute track); sub-centisecond precision is truncated to LRC's resolution. Pure. | | parseTtml(text) | Read TTML / DFXP (Timed Text Markup Language; .ttml/.dfxp/.xml, root <tt>) into the [{ index, startMs, endMs, text }] cue model — used by convert(text, 'ttml'\|'dfxp', 'srt'\|'vtt'). Cues come from <p> paragraphs (typically under <body><div>); each must carry begin, plus an end from either end or dur (end wins if both; else endMs = begin + dur). Pinned supported timing forms (anything else throws an honest Error, never a guess): clock HH:MM:SS with optional .fraction seconds (the common .mmm is exact); frame HH:MM:SS:FF (FF converted with the document frame rate — ttp:frameRate on <tt>, default 30); and offset <n> + {ms, h, m, s, f} (e.g. 5s, 1200ms). Tick offsets (…t) and any other shape are not supported and throw. Inline <br/> (and <br>/<br />) becomes a newline; <span>/styling wrappers are stripped to plain text (SRT/VTT can't carry TTML styling — dropped honestly, never emitted literally); XML comments are ignored and character references (&amp;, &#160;, &#xA0;) are decoded. A <p> with no begin, or with neither end nor dur, throws. An empty document (no <p>) returns [] without throwing. READ only — TTML write is out of scope: to='ttml' stays refused by the honest unsupported toFormat error. Pure: string in, array out; no DOM, network, or filesystem — all string/regex math. | | fixOverlaps(text, { minGapMs = 0 }) | Trim overlapping cue end times so no cue extends past the next cue's start, optionally enforcing a minGapMs silent gap before each following cue. Only end times move (starts and text are untouched), and each cue is clamped against its immediate successor. Pinned degenerate rule: an end is never clamped below its own start — if the target would invert the cue, the end is left exactly at the start (a documented zero-duration cue) rather than a negative-duration one. Preserves format; a file with no overlaps and minGapMs 0 round-trips byte-identical to serialize(parse(text)). | | merge(textA, textB, { offsetMs = 0, format = 'srt' }) | Combine two subtitle files into one — e.g. stitching the two halves of a movie's subtitles, or folding a signs/songs track into the dialogue track. Each input may be SRT or VTT (auto-detected; .ass/.ssa are rejected with the same honest error as convert). Optionally shifts every cue of textB by offsetMs before combining (reuses timeShift semantics — negatives clamp to 0; use it when the second half starts at 00:00:00 in its own file). Concatenates both tracks, stable-sorts by start time (ties keep every A cue before every B cue), renumbers sequentially 1..N, and serializes to format. An empty input contributes zero cues, so merging onto an empty file is identity (plus renumber). Overlaps are not auto-resolved — apply fixOverlaps to the result if you want them trimmed. | | split(text, atMs, { rebaseSecond = true }) | Split one subtitle file into two at a timestamp — the inverse of merge. Returns [partA, partB]. A cue is assigned wholly to the part its start falls in and is never split mid-text: partA keeps every cue whose startMs < atMs, partB keeps the rest (a cue whose start === atMs goes to partB — the boundary is a >= pin). A straddling cue (starts before atMs, ends after it) stays wholly in partA with its end intact. Each part is renumbered 1..N and serialized in the input's format (SRT vs VTT auto-detected); an empty part serializes to the honest empty document (a lone LF for SRT, the WEBVTT header for VTT). rebaseSecond (default true) shifts partB by -atMs (reusing timeShift semantics — negatives clamp to 0) so the second file starts near 00:00; false keeps absolute times. This makes split the inverse of merge(partA, partB, { offsetMs: atMs }). | | splitLongCues(text, { maxChars, maxDurationMs, minGapMs = 100 }) | Split any cue that is too long — text length > maxChars (code points, newlines excluded, per the analyzeReadingSpeed counting rule) or duration > maxDurationMs — into a sequence of cues. The split point follows a pinned boundary priority: (1) a sentence end (. / ? / ! ) nearest the text midpoint, then (2) a hard line break (\n), then (3) the space nearest the midpoint. The original cue's span is allocated proportionally to each resulting part's text length, and the gap between parts is opened by reusing enforceGap (its min-gap definition is the single source — minGapMs is passed straight through, minDurationMs:0 so it only carves the gap and never extends a part). A cue that cannot be split compliantly — a single unbreakable word, or any split whose parts cannot each carry the min gap — is left alone (original timing + text, never mangled or partially split) and returned in the residual list. The whole cue list is renumbered 1..N (reusing renumber) after splitting. Returns { text, residual }. Distinct from split (which cuts a file in two at a wall-clock time and never divides a cue's text). Honest edges: empty input → empty output + empty residual; when no cue was actually split the caller's exact bytes are returned unchanged (byte-identity), even though residual may list unsplittable over-long cues. Format (SRT vs VTT) is auto-detected and preserved. | | stripFormatting(text, { removeTags = true, removeSdh = false, collapseWhitespace = true }) | Clean up cue text without touching timings. removeTags strips HTML/font tags (<i>, <b>, <font …>) and residual ASS {…} override blocks ({\an8}, {\i1}); collapseWhitespace collapses internal whitespace runs (including the newlines between wrapped lines) to a single space and trims. Opt-in removeSdh drops a cue only when its cleaned text is purely SDH — one or more bracketed sound descriptions ([music], (laughs)) or a standalone ALL-CAPS speaker label (JOHN:, MAN 2:, no lowercase, optional trailing colon). A cue that mixes dialogue with a bracketed tag ([door creaks] Who's there?) is kept intact — real dialogue is never dropped. Any cue emptied by cleanup (e.g. a cue that was only <i></i> or {\an8}) is removed and the survivors are renumbered 1..N (reusing renumber). Format (SRT vs VTT) and cue timings are preserved. Honest limit of removeSdh: a one-word all-caps shout used as dialogue (NO, STOP — no trailing punctuation) matches the label rule and would be removed, which is exactly why it is opt-in; leave it off for tracks that use all-caps for emphasis (STOP!, with punctuation, is already kept). | | analyzeReadingSpeed(text, { maxCps = 17 }) | Pure report (never mutates, returns no subtitle text) of subtitle reading speed. Reuses parse to build the cue model, then returns { perCue: [{ index, chars, durationMs, cps, tooFast }], summary: { total, tooFastCount, worstCps } }, where cps = chars / (durationMs / 1000) and tooFast is strictly cps > maxCps (a cue exactly at maxCps is not flagged). A zero- or inverted-duration cue with text yields cps === Infinity (flagged) — never a hidden NaN or silent divide-by-zero; an empty cue yields cps 0 (not flagged). Pinned character-count rule: chars is the number of Unicode code points in the cue text with newline separators removed — line breaks are layout and are not counted, spaces and punctuation are counted, and each CJK ideograph and each astral (surrogate-pair) code point counts as exactly one. Honest limit: it weights every glyph equally and does not down-weight CJK, so for CJK subtitles pass a lower maxCps (professional guidance is ~9 CPS for CJK vs ~17 for Latin). | | parseAss(text) | Read Advanced SubStation Alpha (.ass) / SubStation Alpha (.ssa) into the [{ index, startMs, endMs, text }] cue model. Only the [Events] section is read; columns are located by name from its Format: line; {\…} override tags are stripped and \N/\h become newline/space; Comment: lines are dropped. Throws honestly on a missing [Events]/Format, a Dialogue before its Format, a malformed timestamp, or zero cues. Styling is intentionally discarded (SRT/VTT can't carry it) — a one-way read. | | parseMicroDvd(text, fps) | Read MicroDVD ({startFrame}{endFrame}text) into cues. fps is required (frame → ms uses round(frame*1000/fps)); {…} style tokens are dropped and \| line-break becomes a newline. | | parseSbv(text) | Read YouTube SBV (.sbv, H:MM:SS.mmm,H:MM:SS.mmm) into cues. | | serializeSbv(cues) | Write cues to SBV text (uses startMs/endMs/text; the cue index is ignored, as SBV has none). | | serializeTtml(cues, opts?) | Write cues to a minimal TTML document (<tt><body><div><p …>); opts is reserved and currently unused. | | resyncByFrameRate(text, fromFps, toFps) | Rescale every timecode as if a track authored for fromFps were played at toFps (scale = fromFps/toFps, delegated to linearResync with a pinned zero offset). Equal frame rates return the input byte-for-byte (exact identity); any zero/negative/non-finite fps throws. FRAME_RATES exports the common set (23.976, 24, 25, 29.97, 30). | | dedupeCues(text, { caseInsensitive = false }) | Collapse runs of adjacent cues whose trimmed text is identical into one cue spanning first-start…last-end (the classic OCR/ASR repeated-line artifact). A differing cue between two duplicates breaks the run; non-adjacent duplicates are left alone; survivors are renumbered 1..N. caseInsensitive affects only the merge decision, never the retained text. Empty input returns unchanged. | | enforceGap(text, { minGapMs = 120, minDurationMs = 0 }) | Guarantee at least minGapMs of silence before each following cue by pulling the earlier cue's end earlier, never below minDurationMs (so a cue is never trimmed to nothing). Returns { text, flagged: [{ index, achievedGapMs }] } listing cues that could not reach the target gap without violating minDurationMs. | | enforceDuration(text, { minMs = 1000, maxMs = 7000 }) | Clamp each cue's duration into [minMs, maxMs] (extending short cues, trimming long ones) without creating an overlap with the next cue. Returns { text, findings: [{ cueIndex, kind }] } recording each adjustment and any residual that a neighbour blocked. | | removeSdh(text, { brackets = true, parens = true, speakerLabels = true, musicNotes = true }) | Strip inline SDH from cue text while keeping the timing: bracketed [door creaks] and parenthetical (laughs) descriptions, leading ALL-CAPS speaker labels (JOHN:), and music-note markers (). Each category is a toggle. A cue reduced to nothing (e.g. a pure (music) line) is dropped and survivors are renumbered 1..N; a cue mixing dialogue with a tag keeps the dialogue. Distinct from stripFormatting's opt-in whole-cue removeSdh drop: this one edits within a line. Honest limits: (1) a genuinely all-caps shout used as dialogue (STOP) can match the speaker-label rule — turn speakerLabels off for such tracks. (2) bracket/paren removal is a flat, non-nesting regex (each matches up to its first closing delimiter), so cleanly-formed SDH is stripped exactly, but malformed nested or adjacent-doubled markup can leave one residual delimiter — [[NOISE]] Hi] Hi, and a label whose brackets wrap punctuation ([MAN (v.o.)]:) removes the bracket but leaves the trailing :. It never drops real dialogue; it just doesn't tidy the stray delimiter. | | toTranscript(text, { joinCues = false, dedupeConsecutive = true }) | Flatten a subtitle track into a plain-text transcript (timings and cue numbers removed). joinCues joins all cues with spaces into flowing paragraphs vs. one cue per line; dedupeConsecutive drops back-to-back identical lines. Empty input returns ''. | | rebalanceLines(text, { maxCharsPerLine = 42, maxLines = 2 }) | Re-wrap each cue's text to at most maxCharsPerLine characters across at most maxLines lines, balancing line lengths. Returns { text, flagged: [{ index, longWord, overflow }] } where longWord marks a single unbreakable word longer than the limit and overflow marks a cue that still exceeds maxLines after wrapping. | | qualityReport(text, { maxCps = 21, minGapMs = 120, minMs = 1000, maxMs = 7000, maxChars = 42, maxLines = 2 }) | Pure QC report aggregating reading speed, inter-cue gaps, durations, and line length/count against professional defaults. Returns { findings: [{ cueIndex, kind, detail }], counts: { <kind>: n }, total }; cueIndex is the 0-based position, detail is a human string (e.g. 'CPS 28.4 > 21'), and findings are deterministically sorted. Read-only — never edits the track. | | combineBilingual(primaryText, secondaryText, { order = 'primary-first' }) | Overlay two single-language tracks into one bilingual track (both languages shown together per cue), output in the primary's format. order controls which language appears on top. | | autoFix(text, opts?) | Run a configurable cleanup pipeline — fixOverlapsenforceGapenforceDurationrebalanceLines — and return { output, changes: [{ cueIndex, op, detail }] }. Every pass is on by default; set a pass key to false to skip it, or to an options object to enable it and pass thresholds through (e.g. { enforceGap: { minGapMs: 150 }, rebalanceLines: false }). changes logs every mutation and every honest residual in pass-then-cue order. |

On read, parse strips a leading byte-order mark (U+FEFF) and normalizes CRLF (and lone CR) line endings to LF. Overlapping cues are preserved exactly as authored — never silently reordered or merged, including after a linearResync rescale.

How to run the tests

One command, from this directory:

npm test

This runs the scaffold check plus the golden-vector suite (test/scaffold.test.js then test/golden.test.js) as a single command. The scaffold check verifies the core module loads, exposes the function surface, validates resync anchors with honest errors, and stays pure (no DOM, browser-global, network, or filesystem references in the executable source). The golden vectors pin byte-exact round-trip identity for a canonical SRT and VTT file, +2500ms / -1500ms shifts (including the negative-clamp case), SRT⇄VTT conversion, a two-anchor linearResync (a real scale != 1 frame-rate drift, an identity round-trip, a negative-clamp, and both honest throws), SRT renumber to 1..N plus the VTT no-op, a malformed-input throw, BOM+CRLF normalization, and an overlapping-cue pair. It also runs test/reading-speed.test.js, which pins hand-verified analyzeReadingSpeed CPS vectors (the exactly-at-max vs just-over boundary, the zero-duration Infinity case, an empty cue, multi-line and CJK/astral character counting, and the summary totals). It exits 0 on success. There are no dependencies to install.

The suite also runs the newer-core golden vectors added after the original set: test/lrc-vectors.test.js (parseLrc/serializeLrc — the start-only end-synthesis rule, multi-stamp lines, the [offset:] sign, the start-preserving round-trip), test/sdh-vectors.test.js (removeSdh — inline bracket/paren/label/music-note stripping and each toggle), test/split-long-cues.test.js (splitLongCues — proportional splitting and the unsplittable-residual case), plus test/ttml.test.js / test/ttml-serialize.test.js, test/enforce-duration.test.js, and the autofix/dedupe/bilingual vector files. Finally, test/adversarial-deep.test.js attacks those same SUBDEEP cores at the edges: a 12,000-cue linear-time round-trip, mixed CRLF/LF/CR endings, TTML edge XML (entities, self-closing <br/>, namespaced attrs, clock/offset/frame timing) and serializeTtml's & < > escaping, enforceDuration extension that must touch (never overlap) the next cue, nested ASS override tags, degenerate split/merge boundaries, removeSdh on nested/adjacent brackets and a punctuation-bearing label, parseLrc malformed stamps + a clamping negative offset, and a degenerate splitLongCues character floor — every assertion pins an exact expected output.

Staged SEO scaffold & go-live host substitution

The UI ships with a staged SEO scaffold so it is ready to rank the moment it goes live, but no live host is baked in anywhere — nothing here is wired to a domain. The files involved:

  • index.html — carries a <link rel="canonical"> plus Open Graph / Twitter Card <meta> tags.
  • sitemap.xml — lists only the page(s) that exist today (/).
  • robots.txt — allows all crawlers and points at the sitemap.

Every absolute URL in those three files uses the literal placeholder token {{SITE_ORIGIN}} (an origin with no trailing slash, e.g. https://example.com). Root-relative asset references (style.css, app.js) need no edit.

Go-live host substitution (owner/supervisor step at deploy time): replace every {{SITE_ORIGIN}} with the real origin once the domain is chosen — for example, from the project directory:

# ORIGIN must have NO trailing slash, e.g. https://subtitletiming.app
ORIGIN="https://YOUR-DOMAIN"
grep -rl '{{SITE_ORIGIN}}' index.html sitemap.xml robots.txt \
  | xargs sed -i "s#{{SITE_ORIGIN}}#$ORIGIN#g"

After substitution, confirm nothing is left: grep -rn '{{SITE_ORIGIN}}' . should print nothing. The og:image tag references {{SITE_ORIGIN}}/og-card.png — add a 1200×630 og-card.png at go-live, or delete the two og:image / twitter:image lines if no social card is wanted. See GO-LIVE.md for the full deploy packet.

License

MIT.