Add Dynamic Subtitles and Closed Captions to Your Roblox Game

Hello all,

I wanted to share a recent tutorial I created, outlining the process of developing a custom closed captioning or subtitle system for Roblox. To make the process easier, the provided model includes all necessary components and examples.

Overview

Roblox lacks a native closed captioning or subtitle system, so we need to create a custom solution. This guide will demonstrate how to implement a custom subtitle system using a LocalScript and a settings ModuleScript that automatically adapts to any playing audio within Roblox. The system uses the SubRip (SRT) format for subtitles.

Required model
Get the “DynamicSRT” model from the Roblox developer catalogue. This model includes all necessary components like ScreenGui, LocalScript, ModuleScript, and TextBox.

Instructions

  1. Insert the model

    • Insert the “DynamicSRT” model from the Roblox developer catalogue into your game.
  2. Setup the ScreenGui

    • Move the ScreenGui from the model into StarterGui.
    • Ensure the ScreenGui contains a TextBox which will display the subtitles.
  3. Move the LocalScript

    • Inside the ScreenGui, there should be a LocalScript named DynamicSRT.
    • Make sure this LocalScript is a child of the ScreenGui.
  4. Verify the Settings ModuleScript

    • Inside the DynamicSRT script, ensure there is a ModuleScript named Settings.
    • The Settings ModuleScript contains default settings for the subtitles, such as the TextBox reference and clear text settings.
  5. Add subtitles to audio

    • For each Sound instance in your game that you want to add subtitles to, create a ModuleScript as a child of the Sound.
    • Name the ModuleScript with “SRT” in its name (e.g., “ExampleSRT”).
    • The ModuleScript should follow the template provided below, containing the SRT subtitles.

Example “ExampleSRT” ModuleScript

local Settings = {
    -- Optional setting overrides for subtitles
    TextLabel = nil, -- Text object to display captions
    ClearText = "", -- Text to set the TextBox when cleared
    ClearOnSubtitleEnd = true, -- Clear the TextBox when a subtitle ends
    ClearOnAudioEnd = true, -- Clear the TextBox when audio ends
    UpdateInterval = 0, -- Wait interval (in seconds) to update the text with new captions
    
    -- SRT file for parent Sound
    SRT = [[
1
00:00:00,000 --> 00:00:01,000
<b>Warning.</b>

2
00:00:01,000 --> 00:00:03,740
Medical facility quarantine breach detected.
    ]],
}

return Settings

The SRT format

  • Each subtitle entry starts with a unique ID.
  • The ID is followed by a timestamp indicating the start and end times for the subtitle, separated by an arrow (-->).
  • Below the timestamp, the subtitle text is written.
  • Example:
    1
    00:00:00,000 --> 00:00:01,000
    Warning.
    
    2
    00:00:01,000 --> 00:00:03,740
    Medical facility quarantine breach detected.
    
  • If rich text is enabled, you can use HTML-like tags to format the subtitles.
  • Example:
    1
    00:00:00,000 --> 00:00:01,000
    <b>Warning.<b/> (Bold text.)
    
    2
    00:00:01,000 --> 00:00:03,740
    <i>Medical facility quarantine breach detected.</i> (Italic text.)
    

Join the game to see the subtitles in action. The LocalScript will automatically adapt to any Sound instance and display the corresponding subtitles in the TextBox. This will only affect the client side, so you must join the game rather than simply running it.

You can customize the appearance of the TextBox in ScreenGui to match your game’s design.

SRT files can be created manually or with automatic transcription services like the one I have linked at the bottom of the post. You’ll usually want to modify automatic transcriptions so they align properly.

Feel free to provide any feedback or questions you may have. Your input is greatly appreciated.

Thank you!

15 Likes

Can we see some videos of it in action?

1 Like

It is 100% dependent on your design choices. However, I’ve recorded a demo of what the tutorial would produce based on the default elements provided in the model on the Roblox Creator Store.

Here you go!

1 Like

It was working perfectly before, but now theres an issue with the subtitle GUI…

I’m getting this error message:

The current thread cannot access 'StreamingService' (lacking capability Assistant)  -  Client - DynamicSRT:15
Stack Begin  -  Studio
Script 'Players.MRX_perte.PlayerGui.ScreenGui.DynamicSRT', Line 15 - function listenForAudioPlayback  -  Studio - DynamicSRT:15
Script 'Players.MRX_perte.PlayerGui.ScreenGui.DynamicSRT', Line 96  -  Studio - DynamicSRT:96
Stack End

As a result, the subtitle GUI is no longer functioning. Any help in resolving this issue would be greatly appreciated!

Found a solution!!!

-- Load the settings module
local Settings = require(script:WaitForChild("Settings"))

-- Retrieve settings from the module
local TextBox = Settings.TextLabel
local ClearOnSubtitleEnd = Settings.ClearOnSubtitleEnd
local ClearOnAudioEnd = Settings.ClearOnAudioEnd
local ClearText = Settings.ClearText

-- Function to listen for audio playback events
local function listenForAudioPlayback(sound)
	if not sound:IsA("Sound") then
		print(string.format("The child %s is not a Sound instance", sound.Name))
		return
	end

	-- Connect to the "Playing" property change signal of the sound
	sound:GetPropertyChangedSignal("Playing"):Connect(function()
		-- Check if the sound is playing
		if sound.Playing then
			print(string.format("Sound %s is now playing", sound.Name))
			-- Find the SRT ModuleScript attached to the sound
			local moduleScript = sound:FindFirstChildWhichIsA("ModuleScript")
			if moduleScript and string.match(moduleScript.Name, "SRT") then
				local subtitlesModule = require(moduleScript)
				local subtitles = subtitlesModule.SRT
				if subtitles then
					-- Initialize an empty table to store parsed subtitles
					local parsedSubtitles = {}
					-- Parse the subtitles string into a table of subtitle objects
					for number, start, finish, text in subtitles:gmatch("(%d+)\n(%d%d:%d%d:%d%d,%d%d%d) %-%-> (%d%d:%d%d:%d%d,%d%d%d)\n(.-)\n?\n") do
						local startSec = tonumber(start:sub(1, 2)) * 3600 + tonumber(start:sub(4, 5)) * 60 + tonumber(start:sub(7, 8)) + tonumber(start:sub(10)) / 1000
						local finishSec = tonumber(finish:sub(1, 2)) * 3600 + tonumber(finish:sub(4, 5)) * 60 + tonumber(finish:sub(7, 8)) + tonumber(finish:sub(10)) / 1000
						local subtitle_text = text:gsub("\n", " ") -- Replace newline characters in the subtitle text with spaces
						table.insert(parsedSubtitles, {number = tonumber(number), start = startSec, finish = finishSec, text = subtitle_text})
					end

					-- Debug output for parsed subtitles
					print("Parsed Subtitles:")
					for _, subtitle in ipairs(parsedSubtitles) do
						print(subtitle.number, subtitle.start, subtitle.finish, subtitle.text)
					end

					-- Initialize loop variable
					local lastSubtitle = nil
					local loopRunning = true

					while loopRunning do
						local currentTime = sound.TimePosition
						local audioPlaying = sound.Playing

						if not audioPlaying then
							if ClearOnAudioEnd then
								TextBox.Text = ClearText
							end
							loopRunning = false
						else
							for _, subtitle in ipairs(parsedSubtitles) do
								if currentTime >= subtitle.start and currentTime <= subtitle.finish and subtitle.text ~= lastSubtitle then
									TextBox.Text = subtitle.text
									lastSubtitle = subtitle.text
								elseif currentTime > subtitle.finish and subtitle.text == lastSubtitle then
									if ClearOnSubtitleEnd then
										TextBox.Text = ClearText
									end
									lastSubtitle = nil
								end
							end
						end
						wait(0.5)
					end
				else
					print("No subtitles found in module.")
				end
			else
				print(string.format("No suitable module script found for sound %s", sound.Name))
			end
		end
	end)
end

-- Start listening for audio playback events in the game
for _, sound in ipairs(workspace:GetDescendants()) do
	listenForAudioPlayback(sound)
end

-- Optionally, listen for new Sounds that are added at runtime
workspace.DescendantAdded:Connect(function(newChild)
	listenForAudioPlayback(newChild)
end)

But the sounds must be in the Workspace for the subtitles to work!

…for a test i just put the Sound in StarterCharacterScripts
Example.rbxl (59.7 KB)

annoyingly it’s a engine bug in studio currently, you could replace whatever that loop was with:

if game:GetService("RunService"):IsStudio() then 
if v == game:GetService("StreamingService") then continue end
end

- Source

1 Like

so i dident need to make change code…


Thanks lol… so i just could have done this:

...
local function listenForAudioPlayback(parent)
	-- Iterate through all children of the parent object
	for _, child in ipairs(parent:GetChildren()) do
		if game:GetService("RunService"):IsStudio() then 
			if parent == game:GetService("StreamingService") then end		
		
		if child:IsA("Sound") then
			-- Connect to the "Playing" property change signal of the sound
			child:GetPropertyChangedSignal("Playing"):Connect(function()
				-- Check if the sound is playing
				if child.Playing then
					-- Find the SRT ModuleScript attached to the sound
					local moduleScript = child:FindFirstChildWhichIsA("ModuleScript")
					-- If the SRT ModuleScript is found and matches the naming convention
					if moduleScript and string.match(moduleScript.Name, "SRT") then
						-- Load the subtitles module from the SRT ModuleScript
						local subtitlesModule = require(moduleScript)
						-- Retrieve the subtitles from the module
						local subtitles = subtitlesModule.SRT
						-- If subtitles are found
						if subtitles then
							-- Initialize an empty table to store parsed subtitles
							local parsedSubtitles = {}
							-- Parse the subtitles string into a table of subtitle objects
							for number, start, finish, text in subtitles:gmatch("(%d+)\n(%d%d:%d%d:%d%d,%d%d%d) %-%-> (%d%d:%d%d:%d%d,%d%d%d)\n(.-)\n?\n") do
								-- Convert start and finish timestamps to seconds
								local startSec = tonumber(start:sub(1, 2)) * 3600 + tonumber(start:sub(4, 5)) * 60 + tonumber(start:sub(7, 8)) + tonumber(start:sub(10)) / 1000
								local finishSec = tonumber(finish:sub(1, 2)) * 3600 + tonumber(finish:sub(4, 5)) * 60 + tonumber(finish:sub(7, 8)) + tonumber(finish:sub(10)) / 1000
								-- Replace newline characters in the subtitle text with spaces
								local subtitle_text = text:gsub("\n", " ")
								-- Insert the parsed subtitle into the table
								table.insert(parsedSubtitles, {number = tonumber(number), start = startSec, finish = finishSec, text = subtitle_text})
							end
							-- Initialize a variable to store the last displayed subtitle
							local lastSubtitle = nil
							-- Initialize a flag to control the loop
							local loopRunning = true
							-- Loop until the audio playback is stopped
							while loopRunning do
								-- Get the current playback time of the sound
								local currentTime = child.TimePosition
								-- Check if the sound is still playing
								local audioPlaying = child.Playing
								-- If the sound playback has stopped
								if not audioPlaying then
									-- Check if the setting is enabled to clear the TextBox on audio end
									if ClearOnAudioEnd then
										-- Clear the TextBox
										TextBox.Text = ClearText
									end
									-- Exit the loop
									loopRunning = false
								else
									-- Iterate through parsed subtitles
									for _, subtitle in ipairs(parsedSubtitles) do
										-- Check if the current time falls within the time range of the subtitle and it's a new subtitle
										if currentTime >= subtitle.start and currentTime <= subtitle.finish and subtitle.text ~= lastSubtitle then
											-- Update the TextBox with the subtitle text
											TextBox.Text = subtitle.text
											-- Update the last displayed subtitle
											lastSubtitle = subtitle.text
											-- Check if the current time is after the end of the subtitle and it's the same as the last displayed subtitle
										elseif currentTime > subtitle.finish and subtitle.text == lastSubtitle then
											-- Check if the setting is enabled to clear the TextBox on subtitle end
											if ClearOnSubtitleEnd then
												-- Clear the TextBox
												TextBox.Text = ClearText
											end
											-- Reset the last displayed subtitle
											lastSubtitle = nil
										end
									end
								end
								-- Wait for the specified interval before checking again
								wait(0.5)
							end
						end
					end
				end
				end)
			end
		end
		-- Recursively call the function to listen for audio playback events in children
		listenForAudioPlayback(child)
	end
end
...

Wow…

1 Like

no problem, pretty goofy bug lol