Synthesize speech in documents using Ruby
For a short document, you can extract its text in Ruby and send it to Google Cloud Text-to-Speech in one request. The example below previews the selected text and saves MP3 narration to a new file. It accepts UTF-8 plain text and HTML, with a 1 MiB source limit and a 5,000-byte extracted-text limit.
Setting up Google Cloud Text-to-Speech
Use GNU/Linux with Ruby and Bundler installed. This walkthrough is tested with Ruby 4.0.7,
Bundler 4.0.23, and GNU coreutils’ timeout; a local MP3 player is needed to check the result.
Use a maintained Ruby release from the Ruby downloads page.
The timeout command below depends on GNU coreutils, so it is not directly portable to macOS or Windows.
You also need a Google Cloud project with billing and the Text-to-Speech API enabled, plus credentials allowed to use it. Follow Google’s project setup and Application Default Credentials guide before running synthesis. The code uses ADC; it does not log you in or change your cloud configuration. Document text is sent to Google, and requests can incur charges. Start with non-sensitive sample text.
Create a new, empty working directory, open a Bash terminal there, and save this as Gemfile.
The explicit gem source lets Bundler download the dependencies:
source 'https://rubygems.org'
gem 'google-cloud-text_to_speech', '2.2.1'
gem 'nokogiri', '1.19.4'
Install into this project and keep the resulting Gemfile.lock with your code:
bundle config set --local path vendor/bundle &&
bundle install --gemfile ./Gemfile
Basic document narration script
Save this class as document_narrator.rb. Validation happens before client creation, so previewing
text or refusing an existing destination does not need cloud credentials. The extracted text must
contain something besides whitespace and fit Google’s
5,000-byte content limit.
For example, 2,501 copies of é exceed that limit even though there are fewer than 5,000 characters.
require 'google/cloud/text_to_speech'
require 'nokogiri'
require 'tempfile'
class DocumentNarrator
class InputError < StandardError; end
def initialize(client: nil)
@client = client
end
def extract_text(file_path)
raise DocumentNarrator::InputError, 'Input must be a regular file' unless File.file?(file_path)
source = File.binread(file_path, 1024 * 1024 + 1)
raise DocumentNarrator::InputError, 'Source exceeds 1 MiB' if source.bytesize > 1024 * 1024
source.force_encoding(Encoding::UTF_8)
raise DocumentNarrator::InputError, 'Source must be valid UTF-8' unless source.valid_encoding?
text = case File.extname(file_path).downcase
when '.txt'
source
when '.html', '.htm'
begin
doc = Nokogiri::HTML5(source, nil, 'UTF-8', max_tree_depth: 400, max_attributes: 400)
rescue ArgumentError
raise InputError, 'HTML nesting or attribute limit exceeded; prepare a .txt file'
end
doc.css('script, style, template, noscript').remove
doc.at_css('body').xpath('.//text()').map(&:text).join(' ').strip
else
raise DocumentNarrator::InputError, 'Use a .txt, .html, or .htm input'
end
raise DocumentNarrator::InputError, 'Extracted text is empty' if text.strip.empty?
raise DocumentNarrator::InputError, 'Extracted text exceeds 5,000 bytes' if text.bytesize > 5000
text
end
def narrate_file(input_path, output_path, language_code: 'en-US')
text = extract_text(input_path)
if File.exist?(output_path) || File.symlink?(output_path)
raise DocumentNarrator::InputError, 'Output must not exist; choose a new filename'
end
Tempfile.create(['.narration-', '.mp3'], File.dirname(output_path)) do |file|
file.binmode
client = @client || Google::Cloud::TextToSpeech.text_to_speech
response = client.synthesize_speech(
input: { text: text },
voice: { language_code: language_code, ssml_gender: :FEMALE },
audio_config: { audio_encoding: :MP3 }
)
raise IOError, 'Synthesis returned empty audio' if response.audio_content.empty?
file.write(response.audio_content)
file.flush
# A hard link publishes the complete candidate without replacing another writer's file.
File.link(file.path, output_path)
end
end
end
HTML extraction reads body text in document order, inserts a space between text nodes, and removes
script, style, template, and noscript elements. It does not render CSS, honor every hidden
attribute, or reproduce a browser’s accessible reading order. It can also split a word that spans
inline elements. Preview the result and prepare a plain-text version when this ordering is unsuitable.
The HTML parser also caps DOM tree depth at 400
and attributes at 400 per element. PDFs and word-processing files need a separate extraction step
and are outside this script’s scope.
Advanced features and error handling
Save this entry point as narrate.rb beside the class. Filenames must be valid UTF-8, including when
the shell uses the POSIX locale. The -- delimiter distinguishes synthesis arguments from preview
mode and allows filenames beginning with a hyphen.
require_relative 'document_narrator'
begin
args = ARGV.map { |arg| arg.dup.force_encoding(Encoding::UTF_8) }
raise DocumentNarrator::InputError, 'Arguments must be valid UTF-8' unless args.all?(&:valid_encoding?)
mode = args.shift
narrator = DocumentNarrator.new
if mode == '--preview' && args.length == 1
puts narrator.extract_text(args[0])
elsif mode == '--' && (2..3).cover?(args.length)
narrator.narrate_file(args[0], args[1], language_code: args[2] || 'en-US')
puts 'Narration saved'
else
raise DocumentNarrator::InputError, 'Usage: narrate.rb --preview INPUT | -- INPUT NEW_OUTPUT [LANGUAGE]'
end
rescue DocumentNarrator::InputError => error
warn error.message
exit 1
rescue StandardError
warn 'Narration failed. Check file permissions, credentials, API access, and your connection.'
exit 1
end
Create document.html containing this small example:
<!doctype html>
<html lang="en">
<head><title>Not spoken</title></head>
<body><p>The copper robot carries a yellow umbrella.</p><script>Not spoken either.</script></body>
</html>
Preview it before making a paid request:
bundle exec ruby narrate.rb --preview document.html
The preview should contain only this sentence, apart from surrounding whitespace:
The copper robot carries a yellow umbrella.
Then synthesize it:
timeout --signal=TERM --kill-after=2s 60s \
bundle exec ruby narrate.rb -- document.html narration.mp3 en-US
This gives the entire command 60 seconds, including Ruby startup, credential loading or OAuth
refresh, and synthesis. GNU timeout allows another two seconds before forcibly killing an
unresponsive process. A timeout is a failure, even if the provider received the request; inspect the
destination before trying again. Forced termination can leave a .narration- temporary file.
Check the narration and handle failures
On success, the command prints Narration saved and creates
narration.mp3. Open that exact file in your MP3 player and listen through the end, comparing it
with the preview. A nonempty or playable MP3 alone does not establish that the right text was spoken.
The voice’s language setting does not translate the document; use a language code matching the text.
The destination’s parent directory must already exist and support hard links. An existing file or symlink is refused, and publication also fails if another process creates that path during synthesis. A provider failure leaves an existing destination untouched. Choose another destination to make a second recording; the script has no overwrite option.
Input errors report the failed check. For a source above 1 MiB or extracted text above 5,000 bytes, prepare a smaller document rather than truncating it silently. For an HTML nesting or attribute refusal, prepare a plain-text version. For a generic failure, try the preview first, then check filesystem permissions and the selected project’s ADC, billing, API access, and network connection. Provider error details and document text are kept out of synthesis diagnostics. Longer narration needs a separate chunking and assembly workflow; this example deliberately makes one synchronous request.
