Subtitle Text Analysis with subtools

2026-03-20

Overview

subtools reads and manipulates video subtitle files from a variety of formats (SubRip .srt, WebVTT .vtt, SubStation Alpha .ass/.ssa, SubViewer .sub, MicroDVD .sub) and exposes them as tidy tibbles ready for text analysis.

This vignette walks through:

  1. Reading subtitle files
  2. Exploring and cleaning subtitle objects
  3. Combining subtitles from multiple files
  4. Adjusting timecodes
  5. Tokenising and analysing text with tidytext
  6. Analysing dialogue across a TV series

1. Reading subtitles

From a file

read_subtitles() is the main entry point. It auto-detects the file format from the extension and returns a subtitles object — a tibble with four core columns: ID, Timecode_in, Timecode_out, and Text_content.

f_srt <- system.file("extdata", "ex_subrip.srt", package = "subtools")
subs <- read_subtitles(file = f_srt)
subs
#> # A tibble: 6 × 4
#>   ID    Timecode_in Timecode_out Text_content                                   
#>   <chr> <time>      <time>       <chr>                                          
#> 1 1     00'22.5"    00'24.1"     Lorem ipsum dolor sit amet, consectetur adipis…
#> 2 2     00'25.5"    00'27.1"     Donec eu nisl commodo, elementum dui ut, gravi…
#> 3 3     00'28.7"    00'29.1"     Nulla aliquam,                                 
#> 4 4     00'29.9"    00'31.1"     nibh cursus interdum volutpat,                 
#> 5 5     00'31.9"    00'33.1"     dolor lacus hendrerit tellus, vel faucibus jus…
#> 6 6     00'33.9"    00'34.8"     Suspendisse potenti.

The same call works for every supported format. Use format = "auto" (default) or supply the format explicitly.

f_vtt <- system.file("extdata", "ex_webvtt.vtt", package = "subtools")
read_subtitles(file = f_vtt, format = "webvtt")
#> # A tibble: 3 × 4
#>   ID              Timecode_in Timecode_out Text_content                         
#>   <chr>           <time>      <time>       <chr>                                
#> 1 1               00'01"      00'04"       Never drink liquid nitrogen.         
#> 2 2               00'05"      00'09"       — It will perforate your stomach. — …
#> 3 A dangerous cue 00'11"      00'14"       Dès Noël où un zéphyr haï me vêt de …
f_ass <- system.file("extdata", "ex_substation.ass", package = "subtools")
read_subtitles(file = f_ass, format = "substation")
#> # A tibble: 6 × 4
#>   ID    Timecode_in Timecode_out Text_content                                   
#>   <chr> <time>      <time>       <chr>                                          
#> 1 1     00'22.5"    00'24.1"     Lorem ipsum dolor sit amet, consectetur adipis…
#> 2 2     00'25.5"    00'27.1"     Donec eu nisl commodo, elementum dui ut, gravi…
#> 3 3     00'28.7"    00'29.1"     Nulla aliquam,                                 
#> 4 4     00'29.9"    00'31.1"     nibh cursus interdum volutpat,                 
#> 5 5     00'31.9"    00'33.1"     dolor lacus hendrerit tellus, vel faucibus jus…
#> 6 6     00'33.9"    00'34.8"     Suspendisse potenti.

Attaching metadata at read time

Any descriptive information — season, episode, source, language — can be attached as a one-row tibble via the metadata argument. The values are repeated for every subtitle line, keeping the tidy structure intact.

subs_meta <- read_subtitles(
  file = f_srt,
  metadata = tibble::tibble(Season = 1L, Episode = 3L, Language = "en")
)
subs_meta
#> # A tibble: 6 × 7
#>   ID    Timecode_in Timecode_out Text_content            Season Episode Language
#>   <chr> <time>      <time>       <chr>                    <int>   <int> <chr>   
#> 1 1     00'22.5"    00'24.1"     Lorem ipsum dolor sit …      1       3 en      
#> 2 2     00'25.5"    00'27.1"     Donec eu nisl commodo,…      1       3 en      
#> 3 3     00'28.7"    00'29.1"     Nulla aliquam,               1       3 en      
#> 4 4     00'29.9"    00'31.1"     nibh cursus interdum v…      1       3 en      
#> 5 5     00'31.9"    00'33.1"     dolor lacus hendrerit …      1       3 en      
#> 6 6     00'33.9"    00'34.8"     Suspendisse potenti.         1       3 en

Metadata columns travel with the object through all subtools operations.

From a character vector

as_subtitle() parses an in-memory character vector, which is useful when the subtitle text is already loaded or generated programmatically.

raw <- c(
  "1",
  "00:00:01,000 --> 00:00:03,500",
  "Hello, world.",
  "",
  "2",
  "00:00:04,000 --> 00:00:06,000",
  "This is subtools."
)
as_subtitle(x = raw, format = "srt")
#> # A tibble: 2 × 4
#>   ID    Timecode_in Timecode_out Text_content     
#>   <chr> <time>      <time>       <chr>            
#> 1 1     00'01"      00'03.5"     Hello, world.    
#> 2 2     00'04"      00'06.0"     This is subtools.

2. Exploring the subtitles object

Quick summary

get_subtitles_info() prints a compact summary: line count, overall duration, and attached metadata fields.

s <- read_subtitles(
  file = system.file("extdata", "ex_subrip.srt", package = "subtools")
)
get_subtitles_info(x = s)
#> subtitles object
#>   Text lines: 6
#>   Duration: 00:00:12.3
#>   Metadata: 0

Raw text extraction

get_raw_text() collapses all subtitle lines into a single character string, useful when passing the whole transcript to external Natural Language Processing tools.

transcript <- get_raw_text(x = s)
transcript
#> [1] "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec eu nisl commodo, elementum dui ut, gravida orci. Nulla aliquam, nibh cursus interdum volutpat, dolor lacus hendrerit tellus, vel faucibus justo nisi quis felis. Suspendisse potenti."

# One line per subtitle, separated by newlines
cat(get_raw_text(x = s, collapse = "\n"))
#> Lorem ipsum dolor sit amet, consectetur adipiscing elit.
#> Donec eu nisl commodo, elementum dui ut, gravida orci.
#> Nulla aliquam,
#> nibh cursus interdum volutpat,
#> dolor lacus hendrerit tellus, vel faucibus justo nisi quis felis.
#> Suspendisse potenti.

Accessing individual columns

Because a subtitles object is a tibble, all dplyr verbs work directly:

library(dplyr)

# Lines spoken after the first 30 seconds
s |>
  filter(Timecode_in > hms::as_hms("00:00:30"))
#> # A tibble: 2 × 4
#>   ID    Timecode_in Timecode_out Text_content                                   
#>   <chr> <time>      <time>       <chr>                                          
#> 1 5     00'31.9"    00'33.1"     dolor lacus hendrerit tellus, vel faucibus jus…
#> 2 6     00'33.9"    00'34.8"     Suspendisse potenti.

# Duration of each subtitle cue (in seconds)
s |>
  mutate(duration_s = as.numeric(Timecode_out - Timecode_in)) |>
  select(ID, Text_content, duration_s)
#> # A tibble: 6 × 3
#>   ID    Text_content                                                  duration_s
#>   <chr> <chr>                                                              <dbl>
#> 1 1     Lorem ipsum dolor sit amet, consectetur adipiscing elit.           1.60 
#> 2 2     Donec eu nisl commodo, elementum dui ut, gravida orci.             1.60 
#> 3 3     Nulla aliquam,                                                     0.400
#> 4 4     nibh cursus interdum volutpat,                                     1.20 
#> 5 5     dolor lacus hendrerit tellus, vel faucibus justo nisi quis f…      1.20 
#> 6 6     Suspendisse potenti.                                               0.900

3. Cleaning subtitles

Subtitle files frequently contain formatting tags, closed-caption descriptions, and other non-speech artefacts that should be removed before text analysis.

Remove formatting tags

clean_tags() strips HTML-style tags (used in SRT and WebVTT) and curly-brace override blocks (used in SubStation Alpha).

tagged <- as_subtitle(
  x = c(
    "1",
    "00:00:01,000 --> 00:00:03,000",
    "<i>This is <b>important</b>.</i>",
    "",
    "2",
    "00:00:04,000 --> 00:00:06,000",
    "<font color=\"red\">Warning!</font>"
  ),
  format = "srt",
  clean.tags = FALSE   # keep tags so we can demonstrate cleaning
)
tagged$Text_content
#> [1] "<i>This is <b>important</b>.</i>"    "<font color=\"red\">Warning!</font>"

clean_tags(x = tagged)$Text_content
#> [1] "This is important." "Warning!"

Remove closed captions

clean_captions() removes text enclosed in parentheses or square brackets — typically sound descriptions and speaker identifiers used in accessibility captions.

bb <- read_subtitles(
  file = system.file("extdata", "ex_breakingbad.srt", package = "subtools"),
  clean.tags = FALSE
)
bb$Text_content
#> [1] "Oh, my God. Christ!"                         
#> [2] "Shit."                                       
#> [3] "[SIRENS WAILING IN DISTANCE]"                
#> [4] "Oh, God. Oh, my God."                        
#> [5] "Oh, my God. Oh, my God. Think, think, think."

clean_captions(x = bb)$Text_content
#> [1] "Oh, my God. Christ!"                         
#> [2] "Shit."                                       
#> [3] "Oh, God. Oh, my God."                        
#> [4] "Oh, my God. Oh, my God. Think, think, think."

Remove arbitrary patterns

clean_patterns() accepts any regular expression, giving full flexibility for project-specific cleaning.

# Remove speaker labels such as "WALTER:" or "JESSE:"
s_labeled <- as_subtitle(
  x = c(
    "1", "00:00:01,000 --> 00:00:03,000", "WALTER: We need to cook.",
    "",
    "2", "00:00:04,000 --> 00:00:06,000", "JESSE: Yeah, Mr. White!"
  ),
  format = "srt", clean.tags = FALSE
)

clean_patterns(x = s_labeled, pattern = "^[A-Z]+: ")$Text_content
#> [1] "We need to cook." "Yeah, Mr. White!"

Chaining cleaning steps

Because each cleaning function returns a subtitles object, steps can be piped:

s_clean <- read_subtitles(file = f_srt, clean.tags = FALSE) |>
  clean_tags() |>
  clean_captions() |>
  clean_patterns(pattern = "^-\\s*")   # remove leading dialogue dashes

s_clean$Text_content
#> [1] "Lorem ipsum dolor sit amet, consectetur adipiscing elit."         
#> [2] "Donec eu nisl commodo, elementum dui ut, gravida orci."           
#> [3] "Nulla aliquam,"                                                   
#> [4] "nibh cursus interdum volutpat,"                                   
#> [5] "dolor lacus hendrerit tellus, vel faucibus justo nisi quis felis."
#> [6] "Suspendisse potenti."

4. Combining subtitles

Collapsing multiple objects into one

bind_subtitles() merges any number of subtitles (or multisubtitles) objects. With collapse = TRUE (default), timecodes are shifted so that each file follows the previous one sequentially.

s1 <- read_subtitles(
  file = system.file("extdata", "ex_subrip.srt", package = "subtools"),
  metadata = tibble::tibble(Episode = 1L)
)
s2 <- read_subtitles(
  file = system.file("extdata", "ex_rushmore.srt", package = "subtools"),
  metadata = tibble::tibble(Episode = 2L)
)

combined <- bind_subtitles(s1, s2)
nrow(combined)
#> [1] 10
range(combined$Timecode_in)
#> Time differences in secs
#> [1]   22.500 1292.851

Keeping a list structure

Set collapse = FALSE to get a multisubtitles object — a named list of subtitles — when you want to process episodes independently before merging.

multi <- bind_subtitles(s1, s2, collapse = FALSE)
class(multi)
#> [1] "multisubtitles"
print(multi)
#> A multisubtitles object with 2 elements
#> subtitles object [[1]]
#> # A tibble: 6 × 5
#>   ID    Timecode_in Timecode_out Text_content                            Episode
#>   <chr> <time>      <time>       <chr>                                     <int>
#> 1 1     00'22.5"    00'24.1"     Lorem ipsum dolor sit amet, consectetu…       1
#> 2 2     00'25.5"    00'27.1"     Donec eu nisl commodo, elementum dui u…       1
#> 3 3     00'28.7"    00'29.1"     Nulla aliquam,                                1
#> 4 4     00'29.9"    00'31.1"     nibh cursus interdum volutpat,                1
#> 5 5     00'31.9"    00'33.1"     dolor lacus hendrerit tellus, vel fauc…       1
#> 6 6     00'33.9"    00'34.8"     Suspendisse potenti.                          1
#> 
#> 
#> subtitles object [[2]]
#> # A tibble: 4 × 5
#>   ID    Timecode_in Timecode_out Text_content                            Episode
#>   <chr> <time>      <time>       <chr>                                     <int>
#> 1 180   21'15.769"  21'23.069"   Rushmore deserves an aquarium. A first…       2
#> 2 181   21'23.069"  21'25.670"   - I don't know. What do you think, Ern…       2
#> 3 182   21'25.746"  21'32.170"   - What kind of fish? - Barracudas. Sti…       2
#> 4 183   21'32.851"  21'36.570"   - Piranhas? Really? - Yes, I'm talking…       2

get_subtitles_info() also works on multisubtitles:

get_subtitles_info(x = multi)
#> multisubtitles object with 2 subtitles.

5. Reading an entire series

For TV series organised in a standard directory tree, subtools provides convenience readers that handle the hierarchy automatically and extract Season/Episode metadata from folder and file names.

Series_Collection/
|-- BreakingBad/
|   |-- Season_01/
|   |   |-- S01E01.srt
|   |   |-- S01E02.srt
|   |-- Season_02/
|       |-- S02E01.srt
# Read a single season
season1 <- read_subtitles_season(dir = "BreakingBad/Season_01/")

# Read an entire series (all seasons)
bb_all <- read_subtitles_serie(dir = "BreakingBad/")

# Read multiple series at once
collection <- read_subtitles_multiseries(dir = "Series_Collection/")

Each function returns a single collapsed subtitles object by default (bind = TRUE), with Serie, Season, and Episode columns populated from the directory structure. Pass bind = FALSE to get a multisubtitles list instead.


6. Adjusting timecodes

move_subtitles() shifts all timecodes by a fixed number of seconds. Positive values shift forward; negative values shift backward. This is useful when the subtitle file is out of sync with the video.

subs_shifted <- move_subtitles(x = subs, lag = 2.5)

# Compare first cue before and after
subs$Timecode_in[1]
#> 00:00:22.5
subs_shifted$Timecode_in[1]
#> 00:00:25

move_subtitles() also works on multisubtitles:

multi_shifted <- move_subtitles(x = multi, lag = -1.0)
multi_shifted[[1]]$Timecode_in[1]
#> 00:00:21.5

7. Writing subtitles back to disk

write_subtitles() serialises a subtitles object to a SubRip .srt file.

write_subtitles(x = subs_shifted, file = "synced_episode.srt")

8. Text analysis with tidytext

Tokenising into words

unnest_tokens() extends tidytext::unnest_tokens() with subtitle-aware timecode remapping: each token inherits a proportional slice of the original cue’s time window, enabling timeline-based analyses.

words <- unnest_tokens(tbl = subs)
words
#> # A tibble: 35 × 4
#>    ID    Timecode_in Timecode_out Text_content
#>    <chr> <time>      <time>       <chr>       
#>  1 1     00'22.5010" 00'22.6702"  lorem       
#>  2 1     00'22.6712" 00'22.8404"  ipsum       
#>  3 1     00'22.8414" 00'23.0106"  dolor       
#>  4 1     00'23.0116" 00'23.1128"  sit         
#>  5 1     00'23.1138" 00'23.2489"  amet        
#>  6 1     00'23.2499" 00'23.6234"  consectetur 
#>  7 1     00'23.6244" 00'23.9638"  adipiscing  
#>  8 1     00'23.9648" 00'24.1000"  elit        
#>  9 2     00'25.5010" 00'25.6860"  donec       
#> 10 2     00'25.6870" 00'25.7605"  eu          
#> # ℹ 25 more rows

The Timecode_in / Timecode_out columns now reflect the estimated position of each word within its cue.

Tokenising into sentences or n-grams

# Bigrams
bigrams <- unnest_tokens(tbl = subs, output = Word, input = Text_content,
                         token = "ngrams", n = 2)
bigrams$Word
#>  [1] "lorem ipsum"            "ipsum dolor"            "dolor sit"             
#>  [4] "sit amet"               "amet consectetur"       "consectetur adipiscing"
#>  [7] "adipiscing elit"        "donec eu"               "eu nisl"               
#> [10] "nisl commodo"           "commodo elementum"      "elementum dui"         
#> [13] "dui ut"                 "ut gravida"             "gravida orci"          
#> [16] "nulla aliquam"          "nibh cursus"            "cursus interdum"       
#> [19] "interdum volutpat"      "dolor lacus"            "lacus hendrerit"       
#> [22] "hendrerit tellus"       "tellus vel"             "vel faucibus"          
#> [25] "faucibus justo"         "justo nisi"             "nisi quis"             
#> [28] "quis felis"             "suspendisse potenti"

Word frequency

library(dplyr)

words |>
  count(Text_content, sort = TRUE) |>
  head(10)
#> # A tibble: 10 × 2
#>    Text_content     n
#>    <chr>        <int>
#>  1 dolor            2
#>  2 adipiscing       1
#>  3 aliquam          1
#>  4 amet             1
#>  5 commodo          1
#>  6 consectetur      1
#>  7 cursus           1
#>  8 donec            1
#>  9 dui              1
#> 10 elementum        1

9. Advanced: cross-episode analysis

The metadata columns added at read time make it straightforward to compare episodes or seasons. The example below simulates a two-episode corpus and computes per-episode word counts — a pattern that scales directly to a full series loaded with read_subtitles_serie().

ep1 <- read_subtitles(
  file = system.file("extdata", "ex_breakingbad.srt", package = "subtools"),
  metadata = tibble::tibble(Episode = 1L)
)
ep2 <- read_subtitles(
  file = system.file("extdata", "ex_rushmore.srt", package = "subtools"),
  metadata = tibble::tibble(Episode = 2L)
)
ep3 <- read_subtitles(
  file = system.file("extdata", "ex_webvtt.vtt", package = "subtools"),
  metadata = tibble::tibble(Episode = 3L)
)

corpus <- bind_subtitles(ep1, ep2, ep3)

token_counts <- unnest_tokens(corpus) |>
  count(Episode, Text_content, sort = TRUE)

token_counts |>
  slice_max(n, n = 5, by = Episode)
#> # A tibble: 51 × 3
#>    Episode Text_content     n
#>      <int> <chr>        <int>
#>  1       1 god              5
#>  2       1 oh               5
#>  3       1 my               4
#>  4       1 think            3
#>  5       1 christ           1
#>  6       1 distance         1
#>  7       1 in               1
#>  8       1 shit             1
#>  9       1 sirens           1
#> 10       1 wailing          1
#> # ℹ 41 more rows

TF-IDF across episodes

TF-IDF highlights words that are distinctive to each episode compared with the rest of the corpus.

token_counts |>
  tidytext::bind_tf_idf(Text_content, Episode, n) |>
  arrange(Episode, desc(tf_idf)) |>
  slice_max(tf_idf, n = 5, by = Episode)
#> # A tibble: 48 × 6
#>    Episode Text_content     n     tf   idf tf_idf
#>      <int> <chr>        <int>  <dbl> <dbl>  <dbl>
#>  1       1 god              5 0.217  1.10  0.239 
#>  2       1 oh               5 0.217  1.10  0.239 
#>  3       1 my               4 0.174  1.10  0.191 
#>  4       1 think            3 0.130  0.405 0.0529
#>  5       1 christ           1 0.0435 1.10  0.0478
#>  6       1 distance         1 0.0435 1.10  0.0478
#>  7       1 shit             1 0.0435 1.10  0.0478
#>  8       1 sirens           1 0.0435 1.10  0.0478
#>  9       1 wailing          1 0.0435 1.10  0.0478
#> 10       2 aquarium         3 0.0612 1.10  0.0673
#> # ℹ 38 more rows

Dialogue timeline

Because timecodes are preserved through unnest_tokens(), words can be plotted along a timeline, e.g. to visualise how vocabulary density evolves across a film.

words_ep1 <- unnest_tokens(tbl = ep1) |>
  mutate(minute = as.numeric(Timecode_in) / 60)

if (requireNamespace("ggplot2", quietly = TRUE)) {
  library(ggplot2)
  ggplot(words_ep1, aes(x = minute)) +
    geom_histogram(binwidth = 0.5, fill = "steelblue", colour = "white") +
    labs(
      title = "Word density over time",
      x     = "Time (minutes)",
      y     = "Word count"
    ) +
    theme_minimal()
}


Summary

Task Function
Read a subtitle file read_subtitles()
Parse in-memory text as_subtitle()
Read a full season/series read_subtitles_season() / read_subtitles_serie() / read_subtitles_multiseries()
Print a summary get_subtitles_info()
Extract plain text get_raw_text()
Remove HTML/ASS tags clean_tags()
Remove closed captions clean_captions()
Remove custom patterns clean_patterns()
Merge subtitle objects bind_subtitles()
Shift timecodes move_subtitles()
Write to .srt write_subtitles()
Tokenise (words, n-grams, …) unnest_tokens()