mac-say

Ruby wrapper around the modern version of the macOS say command. Inspired by the @bratta's mactts

Build Status Coverage Status Code Climate InchCI Status mac-say gem

Features

  • [x] Basic strings reading
  • [x] Basic files reading
  • [x] Multiline strings support
  • [x] Dynamic voices parsing (based on real say output)
  • [x] Voices list generation (including samples and ISO information)
  • [x] Voices search (by name / language / country / etc.)
  • [x] Simple (class-level) and customisable (instance-level) usage
  • [ ] Observe reading progress line by line❓
  • [ ] Audio output support❓

Install

$ gem install mac-say

Examples

require 'pp'
require 'mac/say'

# ===== Class level =====

# Get all the voices
pp Mac::Say.voices

# Collect the separate attributes lists
pp Mac::Say.voices.collect { |v| v[:name] }
pp Mac::Say.voices.collect { |v| v[:language] }
pp Mac::Say.voices.collect { |v| v[:sample] }

# Look for voices by an attribute
pp Mac::Say.voice(:singing, true)
pp Mac::Say.voice(:joke, false)
pp Mac::Say.voice(:gender, :female)

# Look for voices by multiple attributes
pp Mac::Say.voice { |v| v[:joke] == true && v[:gender] == :female }
pp Mac::Say.voice { |v| v[:language] == :en && v[:gender] == :male && v[:quality] == :high && v[:joke] == false }

# Find a voice (returns a Hash)
pp Mac::Say.voice(:name, :alex)
pp Mac::Say.voice(:country, :scotland)

# Find the voices by the feature (returns an Array)
pp Mac::Say.voice(:language, :en)

# Work with the voices collection
indian_english = Mac::Say.voice(:country, :in).select { |v| v[:language] == :en }.first[:name]

# Use multiline text
puts Mac::Say.say <<-DATA, indian_english
  Invokes the given block passing in successive elements from self, deleting elements for which the block returns a false value.
  The array may not be changed instantly every time the block is called.
  If changes were made, it will return self, otherwise it returns nil.
DATA

# ===== Instance level =====

# with constant name in the constructor and custom rate
talker = Mac::Say.new(voice: :alex, rate: 300)
talker.say string: 'Hello world'

# with the voice name from the class method + dynamic sample
talker = Mac::Say.new(voice: Mac::Say.voice(:country, :scotland)[:name])
talker.say string: talker.voice(:country, :scotland)[:sample]

# with the dynamic voice name selected from the multiple voices
talker = Mac::Say.new
voice = talker.voice(:language, :en)&.sample(1)&.first&.fetch :name
talker.say string: 'Hello world!', voice: voice

# changing voice in runtime for an instance of talker (while saying something)
voice = talker.voice(:country, :kr)
talker.say string: voice[:sample], voice: voice[:name]

# or change the voice without saying anything
talker.say voice: :"ting-ting"
talker.say string: '您好,我叫Ting-Ting。我讲中文普通话。'

# Listen to all the languages with the dynamic voices + dynamic samples
polyglot = Mac::Say.new
voices = polyglot.voices

voices.each_with_index do |v, i|
  puts "#{i + 1} :: #{v[:name]} :: '#{v[:sample]}'"
  polyglot.say string: v[:sample], voice: v[:name]
end

# Or perform a roll call
roll_call = Mac::Say.new
voices = roll_call.voices

voices.each_with_index do |v, i|
  puts "#{i + 1} :: #{v[:name]}"
  roll_call.say string: v[:name], voice: v[:name]
end

# ===== Reading files =====

# Read the file (prior to the string)
file_path = File.absolute_path '../test/fixtures/text/en_gb.txt', File.dirname(__FILE__)

# with a voice from the class
voice = Mac::Say.voice(:country, :gb)&.first&.fetch(:name)
reader = Mac::Say.new(file: file_path, voice: voice)
reader.read

# with a dynamic voice from the class
voice = Mac::Say.voice(:country, :scotland)[:name]
reader = Mac::Say.new(file: file_path)
reader.read voice: voice

# with a dynamic voice from the instance
reader = Mac::Say.new(file: file_path)
reader.read(voice: reader.voice(:country, :us)[2][:name])

# with a dynamic voice from the instance
new_file_path = File.absolute_path '../test/fixtures/text/en_us.txt', File.dirname(__FILE__)

# with a dynamic file change
reader = Mac::Say.new(file: file_path)
reader.read voice: :alex
reader.read file: new_file_path

# ===== Exceptions =====

# wrong file
begin
  reader = Mac::Say.new(file: 'wrong')
  reader.read
rescue Mac::Say::FileNotFound => e
  puts e.message
end

# wrong file
begin
  reader = Mac::Say.new
  reader.read file: 'too_wrong'
rescue Mac::Say::FileNotFound => e
  puts e.message
end

# wrong path
begin
  Mac::Say.new(say_path: '/usr/bin/wrong_say')
rescue Mac::Say::CommandNotFound => e
  puts e.message
end

# wrong voice
begin
  talker = Mac::Say.new(voice: :wrong)
  talker.say string: 'OMG! I lost my voice!'
rescue Mac::Say::VoiceNotFound => e
  puts e.message
end

# wrong voice
begin
  talker = Mac::Say.new
  talker.say string: 'OMG! I lost my voice!', voice: :too_wrong
rescue Mac::Say::VoiceNotFound => e
  puts e.message
end

# wrong voice
begin
  Mac::Say.say 'OMG! I lost my voice!', :still_wrong
rescue Mac::Say::VoiceNotFound => e
  puts e.message
end

# wrong feature
begin
  Mac::Say.voice(:tone, :enthusiastic)
rescue Mac::Say::UnknownVoiceAttribute => e
  puts e.message
end

# wrong feature
begin
  Mac::Say.new.voice(:articulation, :nostalgic)
rescue Mac::Say::UnknownVoiceAttribute => e
  puts e.message
end

Installing & Updating MacOS TTS Voices (MacOS 12.2+)

Open System Preferences using  Menu / Spotlight / Alfred / Dock and follow text or visual instructions:

System Preferences → Accessibility → Spoken Content → System Voice →
→ Customize… → (select voices) → OK → (Wait for download…)

Installing & Updating MacOS TTS Voices

Caveats

  • All the Siri... voices you might see in the system voices list are not available in say, that's why you can not user them

Dev Notes

# generated with Ore: https://github.com/ruby-ore/ore
$ mine mac-say --git --mit --rubygems-tasks --markdown --minitest --travis --yard

# generate docs (unless this resolved: https://github.com/rrrene/inch/issues/42)
$ yard --markup markdown --markup-provider=redcarpet --title "mac-say Documentation" --protected --asset img:img

# check the docs
$ inch --pedantic

# test with a fake `say`
$ USE_FAKE_SAY='./test/fake/say' bundle exec rake test

# test with rake
$ bundle exec rake test

# test with m
$ bundle exec m

# run one test by LN
$ bundle exec m ./test/test_mac-say.rb:34

Additionally to update dependencies I use my one-liner.

# A snippet to look for any new/unknown voices and test their attributes
pp Mac::Say.voices.select {|v| v[:gender].nil? }
talker = Mac::Say.new(voice: :rishi)
talker.say string: 'Hello, my name is Rishi. I am an Indian-English voice.'

Copyright (c) 2022 Serge Bedzhyk

See LICENSE.txt for details.