<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Bowhard]]></title><description><![CDATA[Bowhard]]></description><link>https://bowhard.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Wed, 28 Oct 2026 14:40:02 GMT</lastBuildDate><atom:link href="https://bowhard.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[SRT vs VTT: the differences that actually break your subtitles]]></title><description><![CDATA[SRT and WebVTT look almost identical. That is exactly why they break: the two formats share the visual shape of "timestamp, newline, text", so a file that…]]></description><link>https://bowhard.hashnode.dev/srt-vs-vtt-the-differences-that-actually-break-your-subtitles</link><guid isPermaLink="true">https://bowhard.hashnode.dev/srt-vs-vtt-the-differences-that-actually-break-your-subtitles</guid><category><![CDATA[webdev]]></category><category><![CDATA[video]]></category><category><![CDATA[JavaScript]]></category><dc:creator><![CDATA[Bowhard]]></dc:creator><pubDate>Sat, 10 Oct 2026 22:55:55 GMT</pubDate><content:encoded><![CDATA[<p>SRT and WebVTT look almost identical. That is exactly why they break: the two formats share the visual shape of "timestamp, newline, text", so a file that is <em>nearly</em> valid in one of them usually fails in the other instead of failing loudly. Here are the differences that actually cost you an evening, in the order they tend to bite.</p>
<h2>Punctuation in fractional seconds</h2>
<pre><code>SRT:  00:00:01,240
VTT:  00:00:01.240
</code></pre>
<p>SRT uses a <strong>comma</strong> before the milliseconds; WebVTT uses a <strong>dot</strong>. Everything else in the timestamp — two-digit hours, minutes and seconds, exactly three digits of milliseconds — is the same.</p>
<p>This one character is the most common reason a subtitle track silently does not appear. A <code>.vtt</code> file with commas is not merely unusual, it is invalid: the parser reads <code>00:00:01,240</code> as text rather than as a cue timestamp, and a player is under no obligation to tell you that it skipped it. You get a track that loads, shows nothing, and logs nothing. Conversely, an <code>.srt</code> file with dots will be rejected by players that validate strictly.</p>
<p>WebVTT adds one more flexibility that SRT does not have: the hours field may be omitted, so <code>01:24.000</code> is a legal VTT timestamp meaning one minute twenty-four seconds. SRT always wants all three components. If you are generating VTT by hand, this is a nice shortcut; if you are writing a converter, it is a case you must handle on input and normalise on output.</p>
<h2>Cue numbering</h2>
<p>Every SRT cue starts with an index:</p>
<pre><code>1
00:00:01,240 --&gt; 00:00:04,760
The first line of dialogue goes here.

2
00:00:04,900 --&gt; 00:00:08,100
And the second cue starts after a blank line.
</code></pre>
<p>WebVTT does not use sequence numbers. The block may optionally carry an <em>identifier</em> before the timestamp — a string, not a counter — and it is used for referencing the cue from a script or from CSS, not for ordering. Writing <code>1</code>, <code>2</code>, <code>3</code> in a VTT file is tolerated by lenient parsers because such a line is a valid identifier, and that tolerance is a trap: the file works in the browser you tested it in and falls apart in the one you did not.</p>
<p>The practical consequence for conversion is that VTT → SRT has to <em>invent</em> numbering, and SRT → VTT has to <em>remove</em> it. A converter that just swaps commas for dots and leaves the numbers in place produces a file that mostly works and occasionally shows a stray digit on screen. (If you are converting, that is one of the things our free <a href="https://bowhard.ru/subtitry">SRT ↔ VTT converter</a> handles.)</p>
<h2>The WEBVTT header is mandatory</h2>
<p>A WebVTT file must begin with the line:</p>
<pre><code>WEBVTT
</code></pre>
<p>followed by a blank line before the first cue. A UTF-8 byte-order mark in front of it is allowed; anything else is not. Some players accept a file without the header, which is how people ship broken VTT for months without noticing.</p>
<p>After the header you may add header <em>metadata</em> — key/value lines such as <code>Kind: captions</code> or <code>Language: en</code>. Those are legal before the first blank line and nowhere else. Copy them into an SRT file and they become the first subtitle the viewer sees.</p>
<h2>Service blocks are not subtitles</h2>
<p>WebVTT has three block types that carry no dialogue at all:</p>
<ul>
<li><strong><code>NOTE</code></strong> — a comment block. Everything until the next blank line is ignored by the player. Purely for humans.</li>
<li><strong><code>STYLE</code></strong> — CSS that applies to the cues that follow. This is how WebVTT does styling; SRT has no styling mechanism beyond crude <code>&lt;b&gt;</code>/<code>&lt;i&gt;</code> tags.</li>
<li><strong><code>REGION</code></strong> — a named screen area that cues can be positioned into.</li>
</ul>
<p>These are the reason a naive converter produces a video with a CSS rule burned across the bottom of the frame. When a <code>NOTE</code> or <code>STYLE</code> block is copied into SRT, it stops being a comment and becomes text, because SRT has no concept of either one. Converting VTT → SRT has to delete those blocks, not translate them.</p>
<p>The same applies to <strong>cue settings</strong>, which sit on the timestamp line itself:</p>
<pre><code>00:00:01.240 --&gt; 00:00:04.760 align:middle line:90%
</code></pre>
<p><code>align</code>, <code>line</code>, <code>position</code>, <code>size</code> and <code>vertical</code> are WebVTT positioning instructions. SRT cannot express any of them. If your conversion keeps the tail of the timestamp line, every cue shows <code>align:middle line:90%</code> on screen next to the dialogue. The only correct behaviour for SRT output is to drop them — the text keeps its content and loses its positioning, which is the best that the format allows.</p>
<h2>Timecode shift: the fix for a re-cut video</h2>
<p>Subtitles almost never break because of punctuation; they break because the video changed. You added a three-second logo at the front, or removed an intro, and now every cue is late or early by a constant offset.</p>
<p>It is worth understanding that a shift is a <strong>single constant applied to every timestamp in the file</strong>. Subtracting 3.5 seconds from all cues is not the same as moving the first cue: if the audio was re-cut in the middle as well, no constant will save you, and you need to re-transcribe. But for a uniform intro/outro change — by far the common case — one offset is exactly right.</p>
<p>Two details that matter in practice:</p>
<ul>
<li><strong>Negative results.</strong> If you shift a file 30 seconds earlier and a cue starts at 12 seconds, the arithmetic gives a negative timestamp. Clamping to <code>00:00:00,000</code> is the sane behaviour; a converter that writes <code>-00:00:18</code> produces a file that no player will load.</li>
<li><strong>Finding the offset.</strong> Do not guess. Pick a sentence you can hear clearly, note the timecode it <em>should</em> have, note the timecode it <em>has</em>, and use the difference. Our converter applies one offset, positive or negative, to all cues at once.</li>
</ul>
<h2>The 42-characters-per-line norm</h2>
<p>Readability guidelines for subtitles are old and convergent: <strong>up to 42 characters per line, at most two lines per cue</strong>. The number comes from broadcast practice — it is roughly what a viewer can read at a glance without stopping to parse the line, and it keeps the text inside the safe area on a 16:9 frame.</p>
<p>Two lines is not a stylistic choice either. A three-line cue covers a meaningful part of the picture, and the third line is very often not read at all.</p>
<p>In practice this is the difference between subtitles and a transcript pasted onto video. A mechanical conversion from JSON with per-word timings, or from a paragraph-based SRT, produces lines that are 90 characters long and get clipped at the frame edge. Both our converter and our transcription service wrap at 42 characters by default — the converter has the limit as a configurable option, and the SRT that the <a href="https://bowhard.ru/rasshifrovka">transcription service</a> produces is already wrapped to it.</p>
<h2>Smaller things that still break files</h2>
<ul>
<li><strong>Empty cues.</strong> A block with a timestamp, a blank line and no text is legal, and useless: it flashes an empty caption and, in some converters, shifts the numbering downstream. Converting should drop them and renumber the rest.</li>
<li><strong>Encoding.</strong> Both formats are text and both are expected to be UTF-8. A Windows-1251 SRT that displayed fine in VLC becomes a screen of replacement characters in a browser player.</li>
<li><strong>Line endings.</strong> CRLF and LF are both accepted in practice, but mixing them inside one file is a reliable way to make one parser in a chain unhappy; a converter should preserve what the source used.</li>
<li><strong>Overlapping cues.</strong> SRT has no mechanism for two cues on screen at once in a defined order. Merging an SRT with a second one usually produces overlapping blocks that the player resolves in whatever order the file happens to be in.</li>
</ul>
<h2>Which format to use</h2>
<ul>
<li><strong>SRT</strong> when the consumer is a person's software: desktop players, video editors, broadcast tooling, most offline workflows. It is the lowest common denominator and almost nothing refuses it.</li>
<li><strong>VTT</strong> when the consumer is a web page: HTML5 <code>&lt;track&gt;</code> elements, browser players, most online video platforms. You also get styling and positioning if you need them.</li>
</ul>
<p>So the workflow that holds up is: generate SRT once from the audio with word-level timings, keep the JSON for anything that needs a re-cut, convert to VTT for the web version, and shift the timecodes rather than regenerating when the edit only touches the beginning or the end.</p>
<p>If you want to see the differences rather than read about them, our converter runs entirely in the browser — the file is never uploaded, the page keeps working offline once it has loaded, and both the SRT and VTT sides of the equation are visible in the same text box:</p>
<ul>
<li><a href="https://bowhard.ru/subtitry">Subtitle converter: SRT ↔ VTT, timecode shift, 42-character wrap</a></li>
<li><a href="https://bowhard.ru/rasshifrovka">Speech-to-text: get SRT, plain text and word-level JSON from audio or video</a></li>
<li><a href="https://bowhard.ru/api">HTTP API for batch processing</a></li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Speech-to-text and text-to-speech on Yandex SpeechKit from one Python stdlib file]]></title><description><![CDATA[The problem was mundane. On one side, meeting recordings: an mp3 of an hour or an hour and a half, out of which I needed text and subtitles. On the other…]]></description><link>https://bowhard.hashnode.dev/speech-to-text-and-text-to-speech-on-yandex-speechkit-from-one-python-stdlib-file</link><guid isPermaLink="true">https://bowhard.hashnode.dev/speech-to-text-and-text-to-speech-on-yandex-speechkit-from-one-python-stdlib-file</guid><category><![CDATA[Python]]></category><category><![CDATA[Speech Recognition]]></category><category><![CDATA[text to speech]]></category><category><![CDATA[api]]></category><dc:creator><![CDATA[Bowhard]]></dc:creator><pubDate>Sat, 10 Oct 2026 22:01:05 GMT</pubDate><content:encoded><![CDATA[<p>The problem was mundane. On one side, meeting recordings: an mp3 of an hour or an hour and a half, out of which I needed text and subtitles. On the other, the reverse operation: I have text, I need a voice, because some clients listen rather than read. I did not want to buy a separate SaaS for this — the recordings are internal, and paying a subscription for them costs more than paying for the actual minutes.</p>
<p>The constraints I set for myself up front:</p>
<ul>
<li>one VPS, no external queues or brokers;</li>
<li>no dependencies beyond the system <code>ffmpeg</code>/<code>ffprobe</code> — so that an update means replacing one file and restarting a systemd unit;</li>
<li>recognition and synthesis go straight to Yandex SpeechKit v3, with no middleware;</li>
<li>user files are not kept longer than a day;</li>
<li>cost has to be predictable: the service must not run at a loss because someone else's script uploads hour-long recordings all night.</li>
</ul>
<p>What came out is a single Python 3.12 file of roughly 2,200 lines: <code>http.server</code> with threads, <code>sqlite3</code> as the job queue, <code>urllib</code> for everything external, <code>ffmpeg</code> for conversion. Below is what turned out to be non-obvious in this construction. Not a tutorial — a field notes collection of the places it bites.</p>
<h2>Why the standard library was enough</h2>
<p>The stock <code>ThreadingHTTPServer</code> cannot cap the number of concurrent connections: on a slow client it happily spawns thread after thread until file descriptors run out. I needed my own subclass — with a pool of 32 connection slots and a 120-second request read timeout. Everything else is ordinary routing: file upload via <code>multipart</code>, a JSON API for job status, result delivery, and the payment provider's webhook.</p>
<p>The queue is hand-rolled too, and it is surprisingly boring: a <code>jobs</code> table in SQLite, two worker threads, the statuses <code>queued → processing → done|error</code>, and a <code>stage</code> field holding a human-readable string like "converting" or "recognizing". Here <code>sqlite3</code> works as an ordinary job log rather than a high-load database: one row written per job, one row read per status poll.</p>
<p>What that bought in practice: deploying the service is <code>scp</code> of one file plus <code>systemctl restart</code>. No <code>pip install</code>, no migrations, no "your library version is wrong".</p>
<p>The public REST API is documented at <a href="https://bowhard.ru/api">bowhard.ru/api</a>, and since I am not the only one who wanted to drive this from a script, there are small clients for <a href="https://github.com/VavilkinAlex/bowhard-speech">Python and Node</a> — that is how the transcription and text-to-speech services at <a href="https://bowhard.ru/rasshifrovka">bowhard.ru/rasshifrovka</a> and <a href="https://bowhard.ru/ozvuchka">bowhard.ru/ozvuchka</a> are consumed from automation.</p>
<h2>Recognition: asynchronous, because the file is long</h2>
<p>SpeechKit can do synchronous recognition, but that is for short utterances. An hour-long recording means <code>recognizeFileAsync</code>: send a job, get an <code>operationId</code>, poll the status until it is <code>done</code>. Poll every two seconds, with a three-hour deadline. Going below two seconds is pointless — the operation cannot have finished anyway, and you are just hammering the API for nothing.</p>
<p>The input to SpeechKit comes in two flavours, and the choice between them is not cosmetic:</p>
<ul>
<li><code>content</code> — audio as base64 inside the request body. Fine for small files, but base64 inflates the size by about a third, and holding an encoded hour-long recording in memory is not a great idea;</li>
<li><code>uri</code> — a link to an object in S3-compatible storage. We adopted a rule: files from 8 MB up go to Object Storage, anything smaller goes as base64. The threshold was picked from process memory: 8 MB in base64 is roughly an 11 MB string, which is perfectly safe for two workers.</li>
</ul>
<p>The result of <code>getRecognition</code> is not JSON but a stream of JSON lines, one object per line — that is the first non-obvious thing. It contains both the plain recognized words and a <code>finalRefinement.normalizedText.alternatives</code> block. The words are needed for timings, but they carry no punctuation and no capital letters. The finished text with punctuation lives in the <code>text</code> field of that same <code>normalizedText</code> block. So we take the text from there, the timings from the word array, and splice the two into a single result.</p>
<p>Subtitles are assembled from that same result. SRT is a format that forgives a lot except two things: over-long lines and overlapping timecodes. We break the line by words, never letting it exceed 42 characters, and wrap only at a word boundary. A subtitle block looks like this:</p>
<ul>
<li>the block number line — <code>12</code>;</li>
<li>the time line — <code>00:01:04,320 --&gt; 00:01:07,880</code>, with milliseconds separated by a comma, as the format requires;</li>
<li>one or two lines of text, split by words and no longer than 42 characters each — <code>and we decided not to touch that part</code>.</li>
</ul>
<p>The timecodes come straight from the words, so the subtitles end up denser than when you slice by sentences, and they do not drift on fast speech. The user gets three files: the text, the <code>.srt</code>, and a <code>json</code> with the words and their timings — the last one is for anyone who wants word highlighting in a player.</p>
<h2>Text-to-speech: 240 characters that nearly broke everything</h2>
<p>Synthesis in v3 is called through the <code>utteranceSynthesis</code> method, and it has a limit on text length that the documentation says approximately nothing about. It emerged empirically: 250 characters pass, 500 come back as a refusal reading "Too long text". And the refusal does not always arrive straight away: sometimes it looks like a dropped connection, and had I not been logging the response body, I would have hunted for the cause for a long time.</p>
<p>The fix is to cut the text into chunks of 240 characters, and to do it on sentence boundaries rather than by a character counter. It is inaudible, and the price is computed from the source character count, so the splitting does not affect the cost at all.</p>
<p>Then comes the pleasant part: MP3 is a sequence of independent frames, so the synthesized chunks are joined by plain byte concatenation, with no re-encoding and no <code>ffmpeg</code>. But if you cut the text crudely, mid-sentence, you can hear an unnatural pause at the seam — which is why the splitting happens at periods, question marks and exclamation marks.</p>
<p>Two more things about synthesis. First: the v1 API costs roughly twice as much as v3 for the same result, so you should go straight to v3. Second: the speed parameter is accepted in the range 0.5–2.0, and anything outside it has to be clamped on your side, otherwise you get an error in the middle of a long text. SpeechKit has fifteen voices — from the neutral "Alena" to the conversational "Zakhar" — and for internal recordings the difference between them is far more noticeable than for advertising ones.</p>
<h2>The queue, the ceilings, and the money fuse</h2>
<p>The nastiest class of bug in a service like this is not a crash but quietly burning money. So the ceilings are set at every level:</p>
<ul>
<li>no more than three jobs in flight per visitor;</li>
<li>no more than 600 MB of uploads per day from one address;</li>
<li>no more than 10 link imports per hour;</li>
<li>the job directory cannot exceed 6 GB — past that, new jobs are refused until cleanup runs;</li>
<li>and the main fuse: a daily cost ceiling. If the total recognition cost for the day exceeds a configured threshold, the service stops accepting new jobs and says so plainly, instead of running at a loss until morning.</li>
</ul>
<p>A separate trap in the same area is balance accounting. Transcription is measured in minutes of audio, text-to-speech in characters. It is natural to keep both in one credits table, but if you compute usage as one combined sum you get nonsense: minutes get eaten by synthesis, characters by transcription, and the balance goes negative out of nowhere. Usage has to be counted separately per kind of work, and the interface has to show two independent balances. In the code this story survives as a comment — as a reminder.</p>
<h2>Link import: SSRF is not only about the URL</h2>
<p>A "paste a link to a file" feature saves the user time and creates a whole class of risk. Ours is narrowed to two providers: public Yandex Disk files (via <code>cloud-api.yandex.net</code>) and Dropbox. The list of trusted hosts is an allowlist, not a denylist, and the check is repeated after redirects: a provider may send you to another domain, and that is fine, but only if that domain is on the list too.</p>
<p>Timeouts are split as well: 30 seconds for the provider's response and 600 seconds for the download itself, otherwise the service turns into a free relay for other people's slow files. Plus a per-address attempt counter. A dedicated self-test command exercises these scenarios: <code>selftest_links</code>, <code>selftest_sign</code>, <code>selftest_net</code> — three buttons that ask "check that the outside world is still what we think it is".</p>
<h2>Payments: we do not trust the webhook</h2>
<p>Payment goes through YooKassa, and there is exactly one rule here that must not be broken: a webhook is not confirmation of a payment. It is only a reason to re-check. The scheme is this: the payment is created in the database up front with a "created" status; on notification the service makes a separate <code>GET /payments/{id}</code> request to the payment provider's API, looks at the real status, and only then credits the balance.</p>
<p>The crediting itself must happen exactly once, because webhooks repeat. Instead of "read the flag, write the credit", a conditional update is used: <code>UPDATE payments SET paid_at = ? WHERE id = ? AND paid_at IS NULL</code>, and the credit is granted only if that statement actually changed a row. This is cheaper and more reliable than any lock in the application: the race between two simultaneous webhooks is settled by the database.</p>
<h2>Storage: signing SigV4 by hand</h2>
<p>Files from 8 MB up go to S3-compatible Object Storage. I did not want to pull in an SDK for four operations (<code>PUT</code>, <code>GET</code>, <code>DELETE</code>, <code>HEAD</code>), so the AWS SigV4 signature is assembled by hand on <code>urllib</code> — and that turned out to be the most capricious part of the whole project.</p>
<p>Two traps, both diagnosed equally unpleasantly — just a <code>403</code> with no explanation:</p>
<ol>
<li><code>x-amz-content-sha256</code> has to appear both in the canonical headers and in the <code>SignedHeaders</code> list. The header is sent, the hash is computed, and the signature still does not match, because it is missing from the list being signed.</li>
<li>Sub-resources such as <code>?lifecycle</code> are written in the canonical request as <code>lifecycle=</code> — with an empty value. Leave them as they are and you get <code>SignatureDoesNotMatch</code> on a request that looks externally flawless.</li>
</ol>
<p>Once the signature is assembled, though, everything else is trivial: put the object — hand SpeechKit the link — delete the object after cleanup. Job files live for 24 hours: that promise is written on the service page, which means the background cleanup has to honour it literally rather than "eventually". Statistics records are kept for 90 days — enough to understand load and compute cost.</p>
<h2>What I would do differently</h2>
<p>The SpeechKit client should have been extracted into a separate module from week one: right now the recognition, synthesis and queue logic all live in one file, and that is convenient exactly until the moment you want to run synthesis from a separate script. Second — progress for long operations: today it shows the stage and a speed estimate based on already-processed jobs, but an honest completion fraction for an hour-long recording would be more useful. Third — move the free-tier limits into config rather than code: those are the ones you want to change more often than anything else.</p>
<p>Otherwise the construction "one file, stdlib, SQLite and system <code>ffmpeg</code>" has held up better than I expected: the service handles recordings up to four hours, recognizing an hour-long file takes less time than the recording itself lasts, and updating it requires neither a build nor dependencies.</p>
<p>If you want to drive this from an automation platform rather than from code, there are ready-made <a href="https://github.com/VavilkinAlex/n8n-bowhard-templates">n8n workflow templates</a> for both transcription and synthesis. And if you build something similar, the most expensive time will go not into SpeechKit but into the S3 signature and the balance accounting — start with those.</p>
]]></content:encoded></item></channel></rss>