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

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

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

-Arttu