Originally published in 2017
I recently acquired a Raspberry Pi 3; and as one of my first Pi projects, I wanted to implement a simple Internet Radio application, as I’ve seen many, many of those in the past :D
My design is a bit unusual in a sense that it doesn’t entail a lot of crafting; instead, it is designed that it is as simple as possible to set up. It is entirely headless; one can operate it solely by a numeric keyboard and a pair of headphones to hear the voice-synthesized instructions. A cursory Google search doesn’t reveal anything quite similar.
In this post, I hope to show you how to replicate the same setup I have. The full code and associated files are available on GitLab
First of all, you will most likely need a functional Linux installation for this to work (which your RasPi, barring some unusual exception, most likely is). While it is possible that this program would be adaptable for Windows or OS X, I have not gone through this effort. That will be left as an exercise to the reader, if so desired.
You need to have a relatively modern version of Ruby installed (at least 2.x.x). You may be interested in trying RVM for this purpose. You will also need MPlayer and Espeak (voice synthesizer) installed somewhere in your path, as stream playing and voice control functionality depend on it.
This radio program uses Dirble as its radio directory, and therefore you will also need an API key. This can be found from the developer portal. Their database has also proven to be pretty comprehensive and snappy, making for a pleasant listening experience :)
Be forewarned: installing the dependencies on Pi may take a while, so one would be best served to start installing them right away
First, set up an empty working directory where you will place the files. After that is done, the first step is..
In addition to dependencies stated above, this program also has other dependencies, which we will describe in form of gems. For those unfamiliar, a gem in Ruby is something quite equivalent to a package in a repository.
Let’s first implement a Gemfile; a Gemfile is basically a list of dependencies. We use an additional tool called Bundler which ensures that whatever dependencies I have do not conflict with other programs
source 'https://rubygems.org'
gem 'espeak-ruby'
gem 'dirble', :git => "https://github.com/dirble/dirble-api-ruby"
gem 'ruby-continent'
Most gems we can get from the standard channels - but for the Dirble, the most up-to-date library seems to be available only from the Dirble’s GitHub repository. Even that does need some special operations however, which will be detailed a bit further down the page.
The program fits nicely in one file; let’s construct it piece by piece
To the beginning of the main file:
require 'rubygems'
require 'bundler/setup' # Set up Bundler dependencies
require 'espeak'
require 'dirble'
# Unfortunately, at the time of writing for this program, there seems to be a major hiccup in terms of how the Dirble API works, and how the Ruby library thinks it should work
# As making a proper patch would take too long for this purpose, we need to implement something commonly called a 'monkey patch'.
# Thanks to Ruby's powerful metaprogramming ability, we can add an additional initialization layer, which then ensures we can access the data we need (but which is not exposed by the default API)
module Dirble
class Category
alias old_initialize initialize
(attr_reader :title) unless method_defined?(:title)
def initialize(options)
@title = options[:title]
old_initialize(options)
end
end
class Station
alias old_initialize initialize
(attr_reader :streams) unless method_defined?(:streams)
def initialize(options)
@streams = options[:streams]
old_initialize(options)
end
end
end
require 'io/console' # Raw input
require 'continent' # Country data
require 'open3' # Interactions with MPlayer
As demonstrated there, the initialization part actually tampers with the Dirble library. It seems that the API has changed significantly since 2015 (the last update to the repository), and therefore no longer can extract all information there would be.
While it would be perfectly possible to make a full patch for the modern API, it would be a significant effort of its own and well out of scope for this project. So, for now, I settled for a simple live patch that opens up a few core classes of the library, and exposes certain information reachable to them upon initialization. This is possible, because Ruby allows opening of other classes at any time to change their functionality.
This patch should not stomp over pre-defined methods, in the event that the repository is updated to work properly out of the box. It remains to be seen what will happen later on.
Rest of the program is one simple module. Most of the code is implemented in this module, except the last row which actually calls inside this module to start the program (as Ruby scripts have no “special” main function to start with)
The code is assembled a set of methods, passing control to each other, and eventually returning up the stack. One starts from the main method; after deciding the type of the search and determining the set of stations, passes through to the station listing method, and eventually from there gets to the actual playing method, which sets up the stream in MPlayer.
There’s also a couple of helper functions, like the list chooser to simplify the code elsewhere for repeated tasks.
module VocalPiRadio
DISPLAY_AVAILABLE = false # This is very useful for debug purposes, as this avoids wasting time for speech
USE_BELL = true # Set this to false, if you do not desire to use the bell sound
# Rings a bell sound to indicate that something unusual has occurred
def self.bell()
return if (DISPLAY_AVAILABLE || !USE_BELL)
`mplayer -quiet "#{File.join(File.dirname(__FILE__), "bell.wav")}"` # Trigger mplayer to play the sound; assumed to be in the same folder as the script
end
# Speaks out a snippet of text
#
# @param [String] text The text to speak out
# @param [String] lang Language of the text snippet to be spoken; if this voice is not found, English is spoken
def self.speak(text, lang="en")
puts "[Speaking '#{text}' in '#{lang}']"
(ESpeak::Speech.new(text, voice: ESpeak::Voice.all.any? {|v| v.language == lang} ? lang : "en", speed: 165).speak) unless DISPLAY_AVAILABLE
end
# A slightly specialized readline for handling numeric/special input. This function returns ONE of follows:
#
# 1) an Integer, which describes the selected choice
# 2) a special character as a single-character string: this may be one of following: + - \e
# 3) empty string if Enter is simply pressed
# 3) nil if conflicting input was provided
def self.number_selective_readline
num = nil
loop do
case STDIN.getch
when /(\d)/
num ||= 0
num = (num*10) + ($1.to_i)
speak(num.to_s)
when /\//
speak("Reset to zero")
num = 0
when /[\r\n]/
return (num != nil ? num : "")
when /([+\-\e])/
return (num == nil ? $1 : nil) # Return the special character UNLESS there have been preceding numbers
when "*"
return "\e" # For numeric keyboards, use this symbol as the escape character
else
return nil
end
end
end
# A simple selection function; each choice is spoken when selected. One can also press + to hear further information, if available
# @param [String] intro The question to be asked
# @param [Array<X>] list The list of items
# @param [Proc] name_speak_function A function that when presented with an item, should speak its name
# @param [Boolean] interruptible If TRUE, allow backing out by pressing ESC or *; in this case, NIL is returned
# @param [Proc] description_speak_function If defined, user can press plus on any choice to speak out a further description
# @return [X] The chosen item, or NIL if the list is empty, or user interrupted
def self.select_by_choice(intro, list, name_speak_function, interruptible, description_speak_function=nil)
return list[0] if ((list.length == 1) && !interruptible) # No choices needed, only one option
return nil if (list.empty?) # Empty list - return nil
instruction_text = "#{intro}. Select a number from 1 to #{list.length}, and then press ENTER.#{interruptible ? " Press Escape or * to cancel." : ""}"
VocalPiRadio.speak(instruction_text) # Speak the instructions
valid_selection = nil
loop do
res = number_selective_readline
case res
when "-"
puts "Received '-' (instructions)"
VocalPiRadio.speak(instruction_text)
when Integer
puts "Received a number"
if (res > list.length || res < 1) then
bell
VocalPiRadio.speak("There are only #{list.length} choices starting from 1, but you chose #{res}. Please try again.")
else
# We have a valid selection!
valid_selection = res
name_speak_function.call(list[valid_selection-1])
VocalPiRadio::speak("Press Enter to confirm")
end
when "\e"
puts "Received escape.."
next unless interruptible
puts ".. and accepted it"
return nil
when "+"
puts "Received +"
next if (valid_selection == nil || description_speak_function == nil)
description_speak_function.call(list[valid_selection-1])
when ""
if (valid_selection == nil)
bell
VocalPiRadio::speak("You have not made a selection yet. Press MINUS for instructions")
else
return list[valid_selection-1]
end
when nil
bell
VocalPiRadio::speak("That selection was not valid. Try again or press MINUS for instructions")
end
end
end
# Attempt to actually play the provided station
# @param [Dirble::Station] station Station to be played
def self.play_station(station)
speak("Activating stream.")
actual_station = Dirble::Station.find(station.id)
stream_url = actual_station.streams.sample[:stream]
# Open the stream application.
Open3.popen2("mplayer -slave -quiet \"#{stream_url}\"") do |stdin, stdout, thread|
loop do
begin
break unless thread.alive?
char = STDIN.getch
case char
when /[\e\r\n]/
stdin.puts "quit"
stdin.flush
break
end
rescue Exception => e
bell
speak("Error was encountered: #{e.to_s}")
thread.kill
end
end
end
speak("That was station ID code #{station.id}")
end
# The list of stations
# @param [Array<Dirble::Station>] list List of stations
# @param [String] country_hint If these stations are from a certain country, this parameter hints at the pronunciation rules that may apply
def self.station_list(list, country_hint="en")
loop do
station = select_by_choice("Which station would you like to play?", list.select {|station| station.streams != nil && station.streams.length > 0}.sort_by {|station| station.name || "z"}, lambda {|station| VocalPiRadio::speak(station.name, country_hint)}, true, nil)
return if station == nil # Exit if escaped
play_station(station) # Play the station
end
end
# The main loop of the radio program. Ask for the type of query, and then pass on the list of stations found
def self.radio_main
Dirble.configure do |config|
config.api_key = File.read(File.join(File.dirname(__FILE__), "API_key.txt")) # Read the API key from the file
end
loop do
choices = {:by_category => "Search by category", :by_country => "Search by country", :by_id => "Select by identifier"}
choice = select_by_choice("Select the mode.", choices.keys, lambda {|x| VocalPiRadio::speak(choices[x])}, true)
puts "Selected #{choice}"
case choice
when :by_category
category_list = Dirble::Category.all.select {|x| (x.title != nil && x.title.length > 0) || (x.description != nil && x.description.length > 0)} # Load category data
####
loop do
# In a loop, select a category and invoke the station selector
category = select_by_choice("Select the category you would like.",
category_list.sort_by {|catg| catg.name || "z"},
lambda do |catg|
speak(catg.title || catg.description || nil)
end,
true,
lambda {|catg| speak(catg.description) if (catg.description != nil && catg.description.length > 0)})
break if category == nil # Break out if desired
stations = category.stations
if (stations.empty?) then
bell
speak("Unfortunately, there are no stations available for this category. Please select another.")
next
else
station_list(stations)
end
end
when :by_country
speak("Please enter the numeric code of the country, and press ENTER. Press Escape or * to cancel")
found_choice = nil
loop do
# In a loop, figure out the country code
res = number_selective_readline
case res
when Integer
country = Continent.by_numeric_code(res)
if (country == nil)
bell
speak("Invalid country - please try again.")
else
found_choice = country
speak("#{country[:name]}. If correct, press Enter, otherwise try again")
end
when ""
if found_choice == nil then
bell
speak("You have not selected a country yet. Please try again or press MINUS for instructions.")
next
end
country_code = found_choice[:alpha_2_code].downcase
stations = Dirble::Station.by_country(country_code)
if (stations.empty?)
bell
speak("Unfortunately, there are no stations available for #{found_choice[:name]}. Please select another country.")
next
else
station_list(stations, country_code)
speak("Please enter another country, or press Escape or * to exit")
end
when "-"
speak("Please enter the numeric code of the country, and press ENTER. Press Escape or * to cancel")
when "\e"
break
else
bell
speak("Invalid country code. Please try again, press MINUS for instructions, or press Escape or * to exit")
end
end
when :by_id
speak("Please enter the identifier of the station to play and then press ENTER, or press Escape or * to cancel")
found_station = nil
loop do
res = number_selective_readline
case res
when Integer
station = Dirble::Station.find(res)
if (station == nil || station.streams == nil || station.streams.length < 1) then
speak("Sorry, #{res} did not match any station")
else
speak("Found station #{station.name}. Press Enter to confirm.")
found_station = station
end
when "-"
speak("Please enter the identifier of the station to play, or press Escape or * to cancel")
when ""
if (found_station == nil) then
speak("You have not selected a station yet.")
else
play_station(found_station)
speak("Please select another station, or press Escape or * to cancel")
end
when "\e"
break
else
bell
speak("That was not a valid identifier. Please try again.")
end
end
when nil
puts "Exiting.."
return # Exit
end
end
end
end
VocalPiRadio.speak("VocalPiRadio v0.1")
VocalPiRadio.radio_main # Trigger main program
You may also want to copy some short audio clip as bell.wav; this sound is played each time something unexpected or unusual occurs. Sort of a “pay attention” sound, if you will. An example clip is included: a slightly shortened version of Computer Magic from Microsift has been included. As like the original, this is also in public domain.
Naturally, you must also have an API key written in the API_key.txt
file in the same directory as the script itself. This API key is required to access Dirble directory services - and can be had for free from the Dirble developer portal.
After you have done all that, simply navigate to the folder of the script; then type bundle install and bundle exec ruby main.rb. The first command sets ups the prerequisite gems, and the second one actually runs it including the gems.
If everything works properly, you should hear the program prompting you to make a choice. Look around, and see what you find.
Now that you have a functioning script, you can start improving it to your taste - or maybe just listen to random radio channels. The choice is yours; this script is still very primitive, and there’s certainly lot of things one can do to improve their own experience.
As of the country codes, they are discoverable from here. I have not verified how up-to-date the data is, but it seems by a quick glance have the majority of countries and territories existing today. A little exercise for any reader: update the script to use a more recent library called Countries, assigning some meaningful numerical codes to them.
Thanks for reading! And again, the full source is in GitLab!
Hopefully you found this post useful :) Be sure to comment - and thanks for your attention!
-Arttu