Skip to content

Instantly share code, notes, and snippets.

@h8rt3rmin8r
Created July 21, 2026 09:11
Show Gist options
  • Select an option

  • Save h8rt3rmin8r/2773b55fc12bb0a666f2f903195f8c57 to your computer and use it in GitHub Desktop.

Select an option

Save h8rt3rmin8r/2773b55fc12bb0a666f2f903195f8c57 to your computer and use it in GitHub Desktop.
Parse a SubRip (.srt) subtitle file into structured JSON (default) or CSV.
<#
.SYNOPSIS
Parse a SubRip (.srt) subtitle file into structured JSON (default) or CSV.
.DESCRIPTION
Reads a SubRip Text (.srt) file, parses every subtitle cue into a uniform
record, and emits the collection as JSON (default) or CSV. The output is
intended for downstream tooling: search, diffing, transcript analysis, or
reshaping into other subtitle formats.
Each cue becomes one object with a fixed, standard set of fields:
index the cue's sequence number as it appears in the file
time_start normalized start timecode, HH:MM:SS,mmm
time_end normalized end timecode, HH:MM:SS,mmm
ms_start start time in whole milliseconds (derived)
ms_end end time in whole milliseconds (derived)
ms_duration ms_end minus ms_start (derived)
speaker extracted speaker label, or null (heuristic; see below)
content the cue text, multiple lines joined with LF, tags preserved
x1, x2, y1, y2 optional SubRip display coordinates, or null
SubRip coverage. The parser follows the de facto SubRip specification: a
file has no header or footer; cues are separated by a blank line; each cue
is an integer index line, a timecode line of the form
"HH:MM:SS,mmm --> HH:MM:SS,mmm", and one or more text lines. It tolerates
the real-world variations the format accumulated over the years: a leading
UTF-8 byte-order mark, CRLF or lone-CR line endings, trailing whitespace on
lines, a period used in place of the millisecond comma, short (under three
digit) millisecond fields, and the optional bounding-box coordinate suffix
on the timecode line ("X1:left X2:right Y1:top Y2:bottom"). Inline styling
tags (bold, italic, underline, font color) are preserved verbatim in the
content field rather than stripped, since they are part of the cue.
Speaker extraction. The "Name: dialogue" prefix seen in Zoom, Teams, and
Otter transcripts is NOT part of the SubRip format; it is a transcript
convention. This script detects it heuristically and, when found, lifts the
label into the speaker field and removes it from content. The heuristic
requires the colon to be followed by whitespace (so time-like text such as
"at 3:30" is not misread) and rejects labels containing "?" or "!". It can
still misfire on genuine lines like "Note: ..."; pass -NoSpeaker to turn it
off entirely and keep every cue's text intact.
Output destination. By default the result is written to a file beside the
input with the same base name and a .json (or .csv) extension. Writing will
NOT overwrite an existing output file unless -Force is given. Pass
-StreamOutput to skip the file and send the payload to standard output for
piping. All files this script writes are UTF-8 with no BOM and LF endings.
Constraints. Requires PowerShell 7 or later (it uses ConvertTo-Json
-AsArray to guarantee array-shaped output even for a single cue). Supports
-WhatIf and -Confirm for the file-write step. Exit codes: 0 success,
1 a parse or write check failed (for example, no valid cues, or the output
exists and -Force was not supplied), 2 an environment precondition failed
(the input file is missing or is not a .srt file).
.PARAMETER Path
Path to the input subtitle file. Must exist and end with the .srt
extension. The path is read literally, so names containing wildcard
metacharacters or awkward whitespace are handled verbatim.
Alias: p
.PARAMETER Csv
Emit CSV instead of the default JSON. The columns are the same fixed field
set as the JSON objects, quoted per RFC 4180 so that commas and embedded
line breaks in the content survive intact.
Alias: c
.PARAMETER StreamOutput
Skip writing a file and send the payload to standard output for pipeline
processing (for example, piping into ConvertFrom-Json). Suppresses the
informational progress messages so only the payload reaches the stream.
Alias: stdout
.PARAMETER Force
Overwrite the output file if it already exists. Without this switch the
script refuses to clobber an existing file and exits 1. Ignored when
-StreamOutput is used, since no file is written.
Alias: f
.PARAMETER NoSpeaker
Disable the "Name: dialogue" speaker-extraction heuristic. Every cue's text
is kept verbatim in content and the speaker field is null throughout.
Alias: n
.PARAMETER Quiet
Suppress informational and success messages. Warnings and errors still
emit. Implied while streaming to standard output.
Alias: q
.PARAMETER Help
Print this help text to the terminal.
Alias: h
.EXAMPLE
.\ConvertFrom-Srt.ps1 interview.srt
Parses interview.srt and writes interview.json beside it (the most common
invocation).
.EXAMPLE
.\ConvertFrom-Srt.ps1 -Path interview.srt -Csv -Force
Writes interview.csv, overwriting any existing file of that name.
.EXAMPLE
.\ConvertFrom-Srt.ps1 interview.srt -StreamOutput | ConvertFrom-Json |
Where-Object speaker -eq 'William Thompson'
Streams the JSON to the pipeline and filters cues down to a single speaker
without ever touching disk.
.EXAMPLE
.\ConvertFrom-Srt.ps1 captions.srt -NoSpeaker -stdout
Streams JSON with speaker detection disabled, so a leading "Word:" in any
cue is left as part of the text.
.NOTES
Exit codes: 0 success, 1 parse or write check failed, 2 environment
precondition failed. Companion contract: srt-json.schema.json.
#>
[CmdletBinding(SupportsShouldProcess=$true,ConfirmImpact='Medium',DefaultParameterSetName='Default')]
Param(
[Parameter(Mandatory=$true,Position=0,ParameterSetName='Default')]
[Alias("p")]
[string]$Path,
[Parameter(Mandatory=$false,ParameterSetName='Default')]
[Alias("c")]
[Switch]$Csv,
[Parameter(Mandatory=$false,ParameterSetName='Default')]
[Alias("stdout")]
[Switch]$StreamOutput,
[Parameter(Mandatory=$false,ParameterSetName='Default')]
[Alias("f")]
[Switch]$Force,
[Parameter(Mandatory=$false,ParameterSetName='Default')]
[Alias("n")]
[Switch]$NoSpeaker,
[Parameter(Mandatory=$false,ParameterSetName='Default')]
[Alias("q")]
[Switch]$Quiet,
[Parameter(Mandatory=$true,ParameterSetName='HelpText')]
[Alias("h")]
[Switch]$Help
)
#_______________________________________________________________________________
## Declare Functions
function Assert-PSVersion {
[CmdletBinding()]
Param(
[Parameter(Mandatory=$false)]
[version]$Minimum = '7.0'
)
$current = $PSVersionTable.PSVersion
if ($current -lt $Minimum) {
Write-Host ("ALERT: PowerShell {0}+ required; running {1}. Relaunch with 'pwsh'." -f $Minimum, $current) -ForegroundColor Red
exit 2
}
}
function Write-Log {
[CmdletBinding()]
Param(
[Parameter(Mandatory=$true,Position=0)]
[string]$Message,
[Parameter(Mandatory=$false)]
[ValidateSet('Info','Success','Warn','Error','Debug')]
[string]$Level = 'Info',
[Parameter(Mandatory=$false)]
[string]$Source = $null
)
if ($script:LogSilent -and $Level -ne 'Error') { return }
if ($script:LogQuiet -and (@('Info','Success','Debug') -contains $Level)) { return }
$stamp = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss.fff')
$tag = if ($Source) { "[$Source] " } else { '' }
$label = $Level.ToUpper().PadRight(7)
$color = switch ($Level) {
'Info' { 'Gray' }
'Success' { 'Green' }
'Warn' { 'Yellow' }
'Error' { 'Red' }
'Debug' { 'DarkGray' }
}
Write-Host ("{0} {1}{2} {3}" -f $stamp, $tag, $label, $Message) -ForegroundColor $color
}
function ConvertTo-SrtMilliseconds {
# Parse a single SubRip timecode (HH:MM:SS,mmm, comma or period, with
# a tolerant 1-to-3 digit millisecond field) into whole milliseconds.
[CmdletBinding()]
Param(
[Parameter(Mandatory=$true)]
[string]$Timecode
)
$m = [regex]::Match($Timecode, '(\d{1,3}):(\d{1,2}):(\d{1,2})[,.](\d{1,3})')
if (-not $m.Success) {
throw "Unparseable timecode: '$Timecode'"
}
$h = [int]$m.Groups[1].Value
$mn = [int]$m.Groups[2].Value
$s = [int]$m.Groups[3].Value
$ms = [int]($m.Groups[4].Value.PadRight(3, '0'))
return ($h * 3600000) + ($mn * 60000) + ($s * 1000) + $ms
}
function Format-SrtTimecode {
# Render whole milliseconds back to the canonical HH:MM:SS,mmm form.
[CmdletBinding()]
Param(
[Parameter(Mandatory=$true)]
[int]$Milliseconds
)
$h = [math]::Floor($Milliseconds / 3600000)
$rem = $Milliseconds % 3600000
$mn = [math]::Floor($rem / 60000)
$rem = $rem % 60000
$s = [math]::Floor($rem / 1000)
$ms = $rem % 1000
return ('{0:00}:{1:00}:{2:00},{3:000}' -f $h, $mn, $s, $ms)
}
function Split-SrtSpeaker {
# Heuristically lift a leading "Name: dialogue" transcript label out of
# the cue text. Returns a hashtable with Speaker (string or $null) and
# Content (the text with the label removed when one was found).
[CmdletBinding()]
Param(
[Parameter(Mandatory=$true)]
[AllowEmptyCollection()]
[string[]]$TextLines
)
$tail = @()
if ($TextLines.Count -gt 1) { $tail = @($TextLines[1..($TextLines.Count - 1)]) }
$first = if ($TextLines.Count -ge 1) { $TextLines[0] } else { '' }
# Same-line label: colon must be followed by whitespace, dialogue after.
$a = [regex]::Match($first, '^(?<sp>[\p{L}][^:\r\n]{0,48}?):[ \t]+(?<rest>\S.*)$')
if ($a.Success -and ($a.Groups['sp'].Value -notmatch '[?!]')) {
$lines = @($a.Groups['rest'].Value) + $tail
return @{ Speaker = $a.Groups['sp'].Value.Trim(); Content = ($lines -join "`n") }
}
# Label alone on the first line, dialogue on the following lines.
$b = [regex]::Match($first, '^(?<sp>[\p{L}][^:\r\n]{0,48}?):[ \t]*$')
if ($b.Success -and ($b.Groups['sp'].Value -notmatch '[?!]') -and ($tail.Count -gt 0)) {
return @{ Speaker = $b.Groups['sp'].Value.Trim(); Content = ($tail -join "`n") }
}
return @{ Speaker = $null; Content = ($TextLines -join "`n") }
}
function ConvertFrom-SrtText {
# Parse raw SubRip text into an ordered array of cue objects.
[CmdletBinding()]
Param(
[Parameter(Mandatory=$true)]
[string]$Text,
[Parameter(Mandatory=$false)]
[switch]$ExtractSpeaker
)
# Strip a UTF-8 BOM and normalize all line endings to LF.
if ($Text.Length -gt 0 -and $Text[0] -eq [char]0xFEFF) {
$Text = $Text.Substring(1)
}
$Text = $Text -replace "`r`n", "`n"
$Text = $Text -replace "`r", "`n"
# Drop trailing whitespace per line so blank-line splitting is reliable.
$Text = (($Text -split "`n") | ForEach-Object { $_.TrimEnd() }) -join "`n"
$blocks = $Text.Trim("`n") -split "`n`n+"
$cues = New-Object System.Collections.Generic.List[object]
$auto = 0
foreach ($block in $blocks) {
if ([string]::IsNullOrWhiteSpace($block)) { continue }
$auto++
$lines = $block -split "`n"
# Locate the timecode line (the one carrying the arrow separator).
$arrowIdx = -1
for ($i = 0; $i -lt $lines.Count; $i++) {
if ($lines[$i] -match '-->') { $arrowIdx = $i; break }
}
if ($arrowIdx -lt 0) {
throw "Cue $auto has no timecode line (missing '-->'): '$($lines -join ' / ')'"
}
$tc = $lines[$arrowIdx]
$tm = [regex]::Match($tc, '(\d{1,3}:\d{1,2}:\d{1,2}[,.]\d{1,3})\s*-->\s*(\d{1,3}:\d{1,2}:\d{1,2}[,.]\d{1,3})')
if (-not $tm.Success) {
throw "Cue $auto has an unparseable timecode line: '$tc'"
}
$startMs = ConvertTo-SrtMilliseconds -Timecode $tm.Groups[1].Value
$endMs = ConvertTo-SrtMilliseconds -Timecode $tm.Groups[2].Value
# Index: the last numeric line before the timecode, else auto number.
$index = $auto
if ($arrowIdx -ge 1) {
$before = @($lines[0..($arrowIdx - 1)] | Where-Object { $_ -match '^\s*\d+\s*$' })
if ($before.Count -gt 0) { $index = [int]($before[-1].Trim()) }
}
# Optional display coordinates on the timecode line.
$x1 = $x2 = $y1 = $y2 = $null
if ($tc -match 'X1:\s*(\d+)') { $x1 = [int]$Matches[1] }
if ($tc -match 'X2:\s*(\d+)') { $x2 = [int]$Matches[1] }
if ($tc -match 'Y1:\s*(\d+)') { $y1 = [int]$Matches[1] }
if ($tc -match 'Y2:\s*(\d+)') { $y2 = [int]$Matches[1] }
# Text is every line after the timecode line.
$textLines = @()
if ($arrowIdx -lt ($lines.Count - 1)) {
$textLines = @($lines[($arrowIdx + 1)..($lines.Count - 1)])
}
$speaker = $null
$content = ($textLines -join "`n")
if ($ExtractSpeaker) {
$split = Split-SrtSpeaker -TextLines $textLines
$speaker = $split.Speaker
$content = $split.Content
}
$cues.Add([pscustomobject][ordered]@{
index = $index
time_start = (Format-SrtTimecode -Milliseconds $startMs)
time_end = (Format-SrtTimecode -Milliseconds $endMs)
ms_start = $startMs
ms_end = $endMs
ms_duration = ($endMs - $startMs)
speaker = $speaker
content = $content
x1 = $x1
x2 = $x2
y1 = $y1
y2 = $y2
})
}
return $cues.ToArray()
}
#_______________________________________________________________________________
## Declare Variables and Arrays
$script:LogQuiet = $false
$script:LogSilent = $false
$ThisScriptPath = $MyInvocation.MyCommand.Path
#_______________________________________________________________________________
## Execute Operations
# Catch help text requests
if (($Help) -or ($PSCmdlet.ParameterSetName -eq 'HelpText')) {
Get-Help $ThisScriptPath -Detailed
exit 0
}
Assert-PSVersion -Minimum '7.0'
# Streaming to stdout implies quiet so only the payload reaches the stream.
if ($Quiet -or $StreamOutput) { $script:LogQuiet = $true }
# Environment preconditions: the input must exist and be a .srt file.
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
Write-Host "ALERT: input file not found: $Path" -ForegroundColor Red
exit 2
}
$fullInput = (Get-Item -LiteralPath $Path).FullName
if ([System.IO.Path]::GetExtension($fullInput).ToLowerInvariant() -ne '.srt') {
Write-Host "ALERT: input must be a .srt file: $fullInput" -ForegroundColor Red
exit 2
}
$raw = Get-Content -LiteralPath $fullInput -Raw -Encoding utf8
if ([string]::IsNullOrWhiteSpace($raw)) {
Write-Error "Input file is empty: $fullInput"
exit 1
}
Write-Log "Parsing $fullInput" -Level Info -Source 'srt'
$cues = ConvertFrom-SrtText -Text $raw -ExtractSpeaker:(-not $NoSpeaker)
if ($cues.Count -lt 1) {
Write-Error "No valid subtitle cues found in: $fullInput"
exit 1
}
Write-Log ("Parsed {0} cue(s)" -f $cues.Count) -Level Success -Source 'srt'
# Build the payload in the requested format.
if ($Csv) {
$payload = (($cues | ConvertTo-Csv -NoTypeInformation) -join "`n")
} else {
$payload = ($cues | ConvertTo-Json -Depth 5 -AsArray)
}
# Emit: stream to stdout, or write a file beside the input.
if ($StreamOutput) {
Write-Output $payload
exit 0
}
$ext = if ($Csv) { 'csv' } else { 'json' }
$outDir = [System.IO.Path]::GetDirectoryName($fullInput)
$outBase = [System.IO.Path]::GetFileNameWithoutExtension($fullInput)
$outPath = [System.IO.Path]::Combine($outDir, "$outBase.$ext")
if ((Test-Path -LiteralPath $outPath) -and (-not $Force)) {
Write-Error "Output already exists: $outPath. Re-run with -Force to overwrite."
exit 1
}
if ($PSCmdlet.ShouldProcess($outPath, 'Write output file')) {
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($outPath, ($payload + "`n"), $utf8NoBom)
Write-Log ("Wrote {0} cue(s) to {1}" -f $cues.Count, $outPath) -Level Success -Source 'srt'
}
exit 0
#_______________________________________________________________________________
## End of script
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://schemas.shruggie.tech/data/srt-json.schema.json",
"title": "SubRip cue array (ConvertFrom-Srt.ps1 output)",
"description": "Describes the JSON produced by ConvertFrom-Srt.ps1: the top-level value is an array, and each element is one parsed SubRip (.srt) subtitle cue. Every cue carries the same fixed set of fields, so the array is a uniform table that maps one-to-one onto the CSV output. Timecodes are normalized to the canonical HH:MM:SS,mmm form, and each cue also exposes its times pre-converted to whole milliseconds for arithmetic and sorting. Fields that a given cue does not carry (a speaker label, display coordinates) are present as JSON null rather than being omitted, so the field set is identical for every element.",
"type": "array",
"items": {
"$ref": "#/$defs/cue"
},
"$defs": {
"cue": {
"type": "object",
"title": "SubRip cue",
"description": "A single subtitle entry from the source file: one sequence number, one start/end time pair, and the text shown during that interval, plus derived and optional fields.",
"additionalProperties": false,
"required": [
"index",
"time_start",
"time_end",
"ms_start",
"ms_end",
"ms_duration",
"speaker",
"content",
"x1",
"x2",
"y1",
"y2"
],
"properties": {
"index": {
"type": "integer",
"minimum": 0,
"description": "The cue's sequence number, taken verbatim from the numeric line at the top of the block in the source file. In a well-formed SubRip file these run 1, 2, 3, ... in order, but the value here is whatever the file actually stated; the parser does not renumber. If a block had no readable numeric line, this falls back to the cue's ordinal position in the file (1 for the first cue, and so on).",
"examples": [1, 2, 47]
},
"time_start": {
"type": "string",
"pattern": "^\\d{2,}:[0-5]\\d:[0-5]\\d,\\d{3}$",
"description": "The moment the subtitle appears, normalized to the canonical SubRip timecode form HH:MM:SS,mmm (hours, minutes, seconds, then a comma and exactly three digits of milliseconds). Normalization means: the millisecond separator is always a comma even if the source used a period, hours are zero-padded to at least two digits, and short millisecond fields in the source are right-padded to three digits. Note the comma decimal separator, which is what distinguishes SubRip from WebVTT (which uses a period).",
"examples": ["00:00:01,000", "01:23:04,000"]
},
"time_end": {
"type": "string",
"pattern": "^\\d{2,}:[0-5]\\d:[0-5]\\d,\\d{3}$",
"description": "The moment the subtitle disappears, in the same normalized HH:MM:SS,mmm form as time_start. In a well-formed cue this is at or after time_start.",
"examples": ["00:00:04,000", "01:23:07,500"]
},
"ms_start": {
"type": "integer",
"minimum": 0,
"description": "time_start expressed as a single integer count of milliseconds from zero (hours*3600000 + minutes*60000 + seconds*1000 + milliseconds). Provided so downstream code can sort, compare, offset, or difference cue times without re-parsing the timecode string.",
"examples": [1000, 4984000]
},
"ms_end": {
"type": "integer",
"minimum": 0,
"description": "time_end expressed as whole milliseconds from zero, computed the same way as ms_start.",
"examples": [4000, 4987500]
},
"ms_duration": {
"type": "integer",
"description": "How long the subtitle is on screen, in milliseconds: ms_end minus ms_start. This is normally non-negative. It can be negative only if the source cue's end time precedes its start time, which is a defect in the source file rather than in the conversion; the value is reported as-is so such defects remain visible.",
"examples": [3000, 3750]
},
"speaker": {
"type": ["string", "null"],
"description": "The speaker label when the cue text began with a 'Name: dialogue' prefix, with the label lifted out and the 'Name: ' removed from content. This prefix is a transcript convention (Zoom, Teams, Otter, and similar), NOT part of the SubRip format itself, so extraction is heuristic: it fires only when a colon is immediately followed by whitespace (so time-of-day text such as 'meet at 3:30' is not misread) and the candidate label contains no '?' or '!'. It is null when no label was detected, and null for every cue when the converter was run with -NoSpeaker. Because the heuristic can still misfire on genuine lines like 'Note: ...', treat this field as best-effort rather than authoritative.",
"examples": ["Narrator", "Speaker 1", null]
},
"content": {
"type": "string",
"description": "The subtitle text for this cue. When the source cue spanned several lines, those lines are joined with a single line feed (\\n) in this one string. Inline SubRip styling tags (<b>, <i>, <u>, and <font color=\"...\">) are preserved verbatim rather than stripped, since they are part of the displayed text. When a speaker label was extracted into the speaker field, the leading 'Name: ' is removed from here; otherwise the text is left exactly as written. May be an empty string if the source cue had no text lines.",
"examples": [
"Plain subtitle text.",
"A caption that wraps onto\na second line.",
"<i>Emphasized text.</i>"
]
},
"x1": {
"type": ["integer", "null"],
"minimum": 0,
"description": "Left edge, in pixels, of the optional SubRip display bounding box. SubRip allows a timecode line to carry a position suffix of the form 'X1:left X2:right Y1:top Y2:bottom'; when present, its four values populate x1/x2/y1/y2. This is an optional and rarely used part of the format, so the value is null for the common case where the cue had no coordinates.",
"examples": [40, null]
},
"x2": {
"type": ["integer", "null"],
"minimum": 0,
"description": "Right edge, in pixels, of the optional SubRip display bounding box (the X2 value of an 'X1:.. X2:.. Y1:.. Y2:..' suffix). Null when the cue carried no coordinates.",
"examples": [600, null]
},
"y1": {
"type": ["integer", "null"],
"minimum": 0,
"description": "Top edge, in pixels, of the optional SubRip display bounding box (the Y1 value). Null when the cue carried no coordinates.",
"examples": [20, null]
},
"y2": {
"type": ["integer", "null"],
"minimum": 0,
"description": "Bottom edge, in pixels, of the optional SubRip display bounding box (the Y2 value). Null when the cue carried no coordinates.",
"examples": [50, null]
}
}
}
},
"examples": [
[
{
"index": 1,
"time_start": "00:00:01,000",
"time_end": "00:00:04,000",
"ms_start": 1000,
"ms_end": 4000,
"ms_duration": 3000,
"speaker": "Narrator",
"content": "The sample file begins here.",
"x1": null,
"x2": null,
"y1": null,
"y2": null
},
{
"index": 2,
"time_start": "00:00:04,500",
"time_end": "00:00:08,250",
"ms_start": 4500,
"ms_end": 8250,
"ms_duration": 3750,
"speaker": null,
"content": "A second line of subtitle text,\nspanning two rows.",
"x1": null,
"x2": null,
"y1": null,
"y2": null
}
]
]
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment