A simple, headless Raspberry Pi internet radio using Ruby, Dirble and Espeak (9.6.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 GitHub

Let's get started!

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..

Ruby and gems

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.

Main file

The program fits nicely in one file; let's construct it piece by piece

Initialization and a monkey-patch

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

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

Auxillary files

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.

And done!

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 GitHub!

Hopefully you found this post useful :) Be sure to comment - and thanks for your attention!

-Arttu