
A Thing That Broadcasts Your Life On An FM Radio Station
No doubt you've asked yourself countless times over the course of your life:
"Why can't an old-timey radio announcer broadcast my local weather, a summary of events from my smart camera and thermostat, and a summary of my amazon packages on an FM radio frequency?!"
We've all been there. I've been there, in fact and I actually did something about it. Today, you'll learn how to do something about it as well.
Why would anyone build this?
A fair question, I suppose. But why wouldn't we? To make this work we'll learn to:
- Interface with a weather API 🌤️
- Pull down emails via SMTP ✉️
- Interact with smart devices 📷
- Write an AI prompt 📝
- Record ourselves doing a funny voice 🎙️
- Interface with ElevenLabs ™️
- Work with a CLI audio editor 🔈
- Use a Raspberry Pi to pipe audio to an FM Transmitter 📻
All of these things are useful and cool, and when we put them together, we'll have magic.
What tech should we use?
Another great question but I want to tweak it a little and replace "should we" with "do we want to" because there's usually more than one correct answer and if you get the job done and learn something new I think you've done it the "right" way.
I like Ruby, personally so I'll be writing primarily in that. However, I'll provide a couple of options where possible:
puts "We're starting a radio station!"
Boilerplate
There are a lot of ways to organize something like this and most of them will be "correct". I'm not going to spend a lot of time on project organization, frameworks, file structure, etc. Instead I'm going to boil this down into minimal files and minimal structure so we get right to making things work. I'm going to do as much of this as I can in a single file for portability and ease of testing.
This implementation will simply write data to a JSON file and save files locally. This can serve as our MVP and we'll discuss different ways to make this a more robust application at the end.
Data Object
First let's create a little data model and a helper method to save stuff to our JSON file.
{
"created_at": "",
"events": ["bunch of strings"],
"script_prompt": "",
"script": "",
"audio_file": "",
"error": ""
}
For each "thing" we want included in our broadcast, we'll create a string representation for it and plop it in the "events" array. We'll send these along with our prompt when we want to generate the script. Not that our code will have any errors, but we'll include a place to put one just in case.
The "Events"
Our events
array might look something like:
The weather today is rainy with a high of 80 degrees, tomorrow is cloudy with a chance of happiness
The thermostat switched from idle to cool at 8:00pm with a detected temperature of 73 and a set temperature of 72
Motion was detected on the backyard camera
An email from Amazon indicates an order is arriving today
...
Writing to the JSON File
Now let's write a simple class to interact with this file so we can do the fun stuff.
require 'json'
class Broadcast < Struct.new(:created_at, :events, :script_prompt, :script, :audio_file, :error)
FILE_PATH = 'broadcast.json'
def initialize(created_at: '', events: [], script_prompt: '', script: '', audio_file: '', error: '')
super(created_at, events, script_prompt, script, audio_file, error)
end
def self.load
return new unless File.exist?(FILE_PATH)
data = JSON.parse(File.read(FILE_PATH))
new(**data.transform_keys(&:to_sym))
rescue JSON::ParserError
new
end
def save
File.write(FILE_PATH, JSON.pretty_generate(to_h))
self
end
def to_h
super.transform_keys(&:to_s)
end
end
Nice. Now we can save stuff like this:
record = Record.load
record.created_at = Time.now.to_s
record.events << 'new event'
record.script_prompt = 'Generate a story about my day'
record.script = 'Good evening folks, you are listening to the home-front download!'
record.save
Weather You Want to or Not
There are exactly 435,365 ways to get the weather[citation needed] in today's fast-paced world. I'm going to show you one of them.
OpenWeatherMap
OpenWeatherMap provides a really simple, really cool One Call API that, as of writing, gives you 1000 free calls per day. I can't imagine I'd need to get the weather for a radio broadcast more than 1000 times in a day so this will work nicely, you just need to sign up for an API key and pass it a latitude and longitude.
Getting Longitude & Latitude
I need to tell OpenWeatherMap where I want the weather from so I need to pass it coordinates. There are many ways to do this but my favorite thing to do is just open up Google Maps, click on my location, and copy/paste the coordinates out of the URL.
Be a Meteorologist
Alright now that we have an API key and some coordinates let's get our weather report.
require 'json'
require 'net/http'
require 'uri'
# ....
# So we don't have to install activesupport
def ordinalize(number)
abs_number = number.to_i.abs
if (11..13).include?(abs_number % 100)
"#{number}th"
else
case abs_number % 10
when 1 then "#{number}st"
when 2 then "#{number}nd"
when 3 then "#{number}rd"
else "#{number}th"
end
end
end
def get_weather_event
uri = URI('https://api.openweathermap.org/data/3.0/onecall')
uri.query = URI.encode_www_form({
lat: '63.0698904',
lon: '169.9694836',
appid: '<api_key>',
units: 'imperial',
exclude: 'minutely,hourly' # we only want daily forecasts
})
response = Net::HTTP.get_response(uri)
weather_data = response.code == '200' ? JSON.parse(response.body) : nil
current = weather_data["current"]
today = weather_data["daily"][0]
tomorrow = weather_data["daily"][1]
right_now = Time.at(current['dt'])
[
'Today,',
right_now.strftime("%A, %B the #{ordinalize(right_now.day)}"),
"#{today['summary']}.",
"Right now it's #{current['temp'].round} and feels like #{current['feels_like'].round}",
"with a low of #{today['temp']['min'].round}",
"with a high of #{today['temp']['max'].round}.",
"Tomorrow, #{tomorrow['summary']}",
"and a high of #{tomorrow['temp']['max'].round} and a heat index of #{tomorrow['feels_like']['day'].round}"
].join(' ')
end
That gives us our weather data! It's a hefty payload so we'll need to parse out what we want. In short it looks like:
{
"current" : {},
"daily": [{},{}] # where the first element is "today" and the second one is "tomorrow" and so on.
}
So we parse it in the above method and that'll produce a nice little forecast:
Today, Tuesday, September the 23rd There will be partly cloudy today.
Right now it's 44 and feels like 41 with a low of 37 with a high of 44.
Tomorrow, Expect a day of rain with snow and a high of 38 and a heat index of 23
Adding the Weather Event
Now we can add it to our events array:
record = Record.load
record.created_at = Time.now.to_s
record.events << get_weather_event
Isn't that neat?
Emails Aplenty
You can get as fancy with this as you want to and pull down emails that relate to products you've shipped, or meetings you've had, or even get summaries on your favorite spam emails! For the purposes of this tutorial I'm going to keep it simple and we're going to pull certain emails based on a regex of their subject line and not look into the contents. Specifically we're going to look for emails that relate to packages being shipped or delivered.
IMAP
I use Hey.com for my email service and they currently don't offer an IMAP interface. So I setup forwarding of all my email to an inexpensive Migadu.com email address so that I can pull, parse, and wipe the inbox regularly without fear of messing anything up and being able to easily interface with emails.
IMAP is weird. They way you query things and pull things is a bit odd and there are other options but I'll use the built-in IMAP implementation in Ruby and the imapclient package in Python
Let's pull some emails.
def get_email_events
imap = Net::IMAP.new(ENV['IMAP_HOST'], '993', true, nil, false)
imap.authenticate('LOGIN', ENV['IMAP_USERNAME'], ENV['IMAP_PASSWORD'])
imap.select('INBOX')
search_terms = ["Shipped", "Out for Delivery", "Delivered", "on its way", "shipped", "on the way"]
# IMAP querying is weird in ruby. You have to submit an array that
# first contains your verbs, then you put in a field and search criteria
# for each verb. So if I wanted to search a subject for "Amazon" OR "Walmart"
# The array would be formatted as ["OR", "SUBJECT", "Amazon", "SUBJECT", "Walmart"]
# weird, huh? The code below handles that.
search_array = []
# we need one OR for each pair of "FIELD" and "SEARCH TERMS", minus one because
# we don't want a trailing "or"
((search_terms.count * 2) - 1).times { search_array << 'OR' }
search_terms.each { |term| search_array += ['SUBJECT', term, 'SUBJECT', term.downcase] }
email_seq_nos = imap.search(search_array)
emails = if email_seq_nos == []
[]
else
imap.fetch(email_seq_nos, %w[ENVELOPE UID])
end
return [] if emails.count < 1
emails.map do |email|
[
"An email from #{email.attr['ENVELOPE'].from.first.name}",
"with a subject of '#{email.attr['ENVELOPE'].subject.gsub(/\p{Emoji_Presentation}/, '').strip}'",
"was received at #{DateTime.parse(email.attr['ENVELOPE'].date).to_time.strftime('%D %I:%M:%S %p')}"
].join(' ')
end
end
Adding Emails to Our Events List
And now we can add in the emails
record = Record.load
record.created_at = Time.now.to_s
record.events << get_weather_event
record.events += get_email_events
Smart Cameras
There are many smart cameras out there with differing levels of API access. I personally have Wyze cameras and I love them. However their API support is non-existent. However again, there's always an enterprising Python developer willing to reverse-engineer an API so we can take advantage of the wyze-sdk package to pull data from my cameras. If you're reading this and you don't have Wyze cameras, there's undoubtedly a way to make it work - just do some Googling.
This SDK requires you to get a Wyze API Key which is easy to do and then you can just add those as environment variables.
For my Ruby script, I'll need to call the python script from there. In our Python code, we can, of course, just use it directly. This will automatically grab tokens and save them to a reusable wyze_credential.json
file.
''' get_wyze_events.py '''
import os
import json
from datetime import datetime, timedelta
from wyze_sdk import Client
from wyze_sdk.errors import WyzeApiError
# Function to write data to JSON file
def write_tokens_to_file(access_token, refresh_token):
data = {
"access_token": access_token,
"refresh_token": refresh_token
}
with open("wyze_credentials.json", 'w+') as json_file:
json.dump(data, json_file, indent=4)
# Function to read data from JSON file
def read_tokens_from_file():
with open("wyze_credentials.json", 'r') as json_file:
data = json.load(json_file)
return data.get('access_token'), data.get('refresh_token')
def populate_tokens():
response = Client().login(
email=os.environ['WYZE_EMAIL'],
password=os.environ['WYZE_PASSWORD'],
key_id=os.environ['WYZE_KEY_ID'],
api_key=os.environ['WYZE_API_KEY']
)
write_tokens_to_file(response['access_token'], response['refresh_token'])
access_token, refresh_token = read_tokens_from_file()
if(access_token == ""):
populate_tokens()
access_token, refresh_token = read_tokens_from_file()
client = Client(token=access_token, refresh_token=refresh_token)
# Do a test of the current tokens
try:
client.cameras.list()
except WyzeApiError as e:
if(str(e).startswith("The access token has expired")):
resp = client.refresh_token()
write_tokens_to_file(resp.data['data']['access_token'], resp.data['data']['refresh_token'])
access_token, refresh_token = read_tokens_from_file()
client = Client(token=access_token, refresh_token=refresh_token)
# Pull all devices and build a map out of the mac address and the device name
devices = client.devices_list()
mac_map = {}
for device in devices:
mac_map[device.mac] = device.nickname
twelve_hours_ago = datetime.now() - timedelta(hours=12)
try:
output_format = []
for mac in mac_map.keys():
events = client.events.list(device_ids=[mac], begin=twelve_hours_ago)
for event in events:
output_format.append(
{
"camera_name": mac_map[event.mac],
"alarm_type": event.alarm_type.description,
"tags": [tag.description for tag in event.tags if tag is not None],
"time": event.time
}
)
except WyzeApiError as e:
# You will get a WyzeApiError if the request failed
print(f"Got an error: {e}")
# if we're calling this from Ruby, we can do this
print(json.dumps(output_format))
# If we're in Python land we'd wrap this up in a get_camera_events function and just return
# return output_format
This script returns a bunch of JSON objects like this:
[
{
"camera_name": "Garage Camera",
"alarm_type": "Motion",
"tags":
[
"Person",
"Pet"
],
"time": 1758758731665
},
// ...
]
Lovely. Wyze even includes the "AI" tags that indicate what it saw. Now we just gotta parse it!
def get_camera_events
events = JSON.parse(`python3 get_wyze_events.py`)
events.map do |event|
event_time = DateTime.strptime(event['time'].to_s, '%Q').to_time.localtime
event_text = "#{event['camera_name']} detected #{event['alarm_type']}"
if event['tags'].count.positive?
verb = event['alarm_type'].downcase == 'sound' ? 'heard' : 'saw'
event_text = "#{str} and #{verb} #{event['tags'].map { |t| "a #{t}" }.join(' and ')}"
end
event_text
end
end
Add Camera Events to Our List
record = Record.load
record.created_at = Time.now.to_s
record.events << get_weather_event
record.events += get_email_events
record.events += get_camera_events
Thermostat
There are many smart thermostats out there with differing levels of API access. I personally have a HoneyWell Total Comfort Control thermostat and I love it. However their API support is non-existent. However again, there's always an enterprising Python developer willing to reverse-engineer an API so we can take advantage of the pyhtcc package to pull data from my thermostat. If you're reading this and you don't have a HoneyWell Total Comfort Control thermostat, there's undoubtedly a way to make it work - just do some Googling.
Yes that paragraph is almost the exact same as the start of the previous section. The code for this is super straightforward, though
# get_honeywell_status.py
import os
import json
from pyhtcc import PyHTCC
p = PyHTCC(os.environ['HONEYWELL_USERNAME'], os.environ['HONEYWELL_PASSWORD'])
zone = p.get_zone_by_name('THERMOSTAT')
theromstat_data = {
"temperature": zone.get_current_temperature_raw(),
"mode": zone.get_system_mode().name
}
# This is useful if we're calling this from our ruby script
print(json.dumps(theromstat_data))
# If we're in Python land we'll wrap this in a method called get_thermostat_event()
# that just returns the thermostat_data object
And to format stuff:
def get_thermostat_status
status = JSON.parse(`python3 get_honeywell_status.py`)
"The thermostat is set to #{status['mode']} and the indoor temperature is #{status['temperature']}"
end
Add the Thermostat Status to Events
record = Record.load
record.created_at = Time.now.to_s
record.events << get_weather_event
record.events += get_email_events
record.events += get_camera_events
record.events << get_thermostat_status
Events -> Prompt -> Script
Okay now that we have all of our events in a nice array let's create a prompt that asks our AI model of choice to create a script from. I made my prompt configurable in case I wanted to change anything later. You could hook this up to a UI or a DB if you wanted. For now, though, we'll just create a static base prompt.
def base_script_prompt
<<-PROMPT.freeze
In the style of a 1930's radio broadcaster, give a news update summarizing the below events.
Do not include prompts, headers, or asterisks in the output.
Do not read them all individually but group common events and summarize them.
Do not include sound or music prompts. Mention that the broadcast is for the current time of #{Time.now.strftime('%I:00 %p')}
The news update should be verbose and loquacious but please do not refer to yourself as either.
The station name is 1.101 Cozy Castle Radio and your radio broadcaster name is Hotsy Totsy Harry Fitzgerald.
At some point in the broadcast advertise for a ridiculous fictional product from the 1930's or tell a joke, do not do both.
Give an introduction to the news report and a sign off.
Here are the events:
PROMPT
end
Nice!
Now to get the full prompt with out events we can simply do:
record.script_prompt = "#{base_script_prompt} #{record.events.join("\n")}"
And then to generate the script, fire up an AI Client of your choice and send the prompt up! I'll use OpenAI for this example:
def generate_script(script_prompt)
openai_client = OpenAI::Client.new(
access_token: ENV['OPENAI_ACCESS_TOKEN'],
organization_id: ENV['OPENAI_ORGANIZATION_ID']
)
messages = [{ role: 'user', content: script_prompt }]
response = openai_client.chat(parameters: { model: 'gpt-5', messages: })
response.dig('choices', 0, 'message', 'content')
end
That should return something like:
Greetings, fine folks of the airwaves! This is Hotsy Totsy Harry Fitzgerald bringing you the latest happenings on 1.101 Cozy Castle Radio. Let's dive right into our news roundup, shall we?
In today's weather report, expect partly cloudy skies with rain and a high of 82 degrees. Tomorrow, anticipate more partly cloudy skies with clear spells and a high of 79. Gentle reminder for all our listeners to carry an umbrella, just in case!
In other news, our ever-so-helpful Thermostat has been hard at work, reporting temperature and humidity. Humidity was reported as 61.0 percent and the temperature sitting at a comfortable 70.0 degrees.
Keeping an eye on your home security, Side/Kitchen/Entry and Backyard Cameras caught quite a bit of motion and people sightings in the past week, so do remember to be mindful of any unexpected visitors and keep those doors locked, dear listeners!
Now, a brief word from our sponsor: Introducing the all-new Miracle Hair Tonic! Have you been struggling with thinning hair or a lack of luster in those lovely locks? Fear not, for Miracle Hair Tonic is the solution you've been waiting for, guaranteed to bring back fullness and shine to your crowning glory. Get your very own bottle from the Gatsby General Store today!
In the world of incoming correspondence, several emails pertaining to shipped or delivered orders have been received, including early arrivals from Amazon and delightful packages from Crooked Roots and Fracture Support. Be sure to keep an eye on those mailboxes, friends!
And with that, we wrap up today's broadcast. Thank you for tuning in to 1.101 Cozy Castle Radio, and may the rest of your day be as splendid as the company you keep. Hotsy Totsy Harry Fitzgerald, signing off!
And with that, we've converted a bunch of real data from our lives into a script for a radio broadcast.
But we're not done yet...
Radio Voice Time
ElevenLabs has a bunch of fantastic text-to-speech voices that we can use to bring our radio broadcast to life. You could certainly play around and use one of those and you can skip ahead if you don't want to make your own. But I'll give a quick walk-through of creating your own.
Record Your Own
You can use this interface to record a view samples of your best radio voice and create your very own voice model:
Just record some samples and upload them and create your voice. You can paste in your script to their web interface to see if you like it:
Use the ElevenLabs API
The ElevenLaps API is pretty simple to use but you have to stream the generated audio back. Here's a simple method that will get the audio and stream it to a file.
def get_script_audio(text, filename)
uri = URI("https://api.elevenlabs.io/v1/text-to-speech/#{ENV['ELEVENLABS_VOICE_ID']}/stream")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
request = Net::HTTP::Post.new(uri)
headers = {
'Accept' => 'application/json',
'xi-api-key' => ENV['ELEVENLABS_API_KEY'],
'Content-Type' => 'application/json',
'Accept' => 'audio/mpeg'
}
headers.each { |key, value| request[key] = value }
request.body = { text: text, model_id: 'eleven_monolingual_v1' }.to_json
File.open(filename, 'wb') do |file|
http.request(request) do |response|
raise StandardError, "Non-success status code while streaming #{response.code}" unless response.code == '200'
response.read_body do |chunk|
file.write(chunk)
end
end
end
filename
end
You'll need to get the voice id from the API. You can do this from a bash console:
curl -X GET "https://api.elevenlabs.io/v1/voices" \
-H "Accept: application/json" \
-H "xi-api-key: ELEVENLABS_API_KEY" | jq | grep -C 3 "Old Timey"
Saving our Audio File
record = Record.load
record.created_at = Time.now.to_s
record.events << get_weather_event
record.events += get_email_events
record.events += get_camera_events
record.events << get_thermostat_status
record.script_prompt = "#{base_script_prompt} #{record.events.join("\n")}"
vocals_file = get_script_audio(record.script_prompt, "broadcast.mp3")
Editing Audio With Socks
I mean sox
- a really cool cli audio processor.
We can use this to dynamically add intro and outro music and then compress it down to sound like it's coming over the radio! Just install it on your system.
Intro/Outro Audio
I used Udio to create a little intro/outro music:
Editing Audio
We need to do a little setup for this and make our intro/outro file available and make directory where we can work. What we're going to do is:
- Get the current length in seconds of our vocals from elevenlabs
- Create a version of the intro and outro that fades in and out evenly (my sample is about 30 seconds long so I want it to fade in for 15 seconds, then out for 15 seconds.)
- Pad the vocals file with some silence at the start
- Pad a version of the outro file so that it contains nothing but silence until the end of our vocals
- Mix all the files together and compress them
Let's use our sox
tool to create a method to handle this:
# Uses the taglib tool to read length in seconds from mp3 files
# brew install taglib
# env TAGLIB_DIR=/opt/homebrew/Cellar/taglib/2.1.1 gem install taglib-ruby --version '>= 2'
require 'taglib'
def audio_length_in_seconds(file_path)
length = 0
return length unless File.exist?(file_path)
TagLib::MPEG::File.open(file_path) do |file|
length = file.audio_properties.length_in_seconds
end
length
end
def mix_broadcast_audio(vocals_file, output_file)
original_intro_outro_file = "radio_intro_outro.mp3"
intro_outro_file = "radio_intro_outro_resampled.mp3"
vocals_length = audio_length_in_seconds(vocals_file)
intro_outro_length = audio_length_in_seconds(intro_outro_file)
working_dir = 'work'
padded_vocals_file = "#{working_dir}/padded_vocals.mp3".freeze
faded_intro_outro_file = "#{working_dir}/faded_intro_outro.mp3"
padded_outro_file = "#{working_dir}/padded_outro.mp3".freeze
mixed_broadcast_file = "#{working_dir}/mixed_broadcast.wav".freeze
compressed_broadcast_file = "#{working_dir}/compressed_broadcast.wav".freeze
# create work directory and clear old file file
system("mkdir work")
system("rm #{output_file}")
# resample file from Udio
system("sox #{original_intro_outro_file} #{intro_outro_file} rate -h 44100")
# pad vocals
system("sox #{vocals_file} #{padded_vocals_file} pad 10@0")
# fade intro outro
system("sox #{intro_outro_file} #{faded_intro_outro_file} fade 5 25 20")
# pad outro
system("sox #{faded_intro_outro_file} #{padded_outro_file} pad #{vocals_length}@0")
# mix files
system("sox -M -v 0.3 #{faded_intro_outro_file} -v 1.2 #{padded_vocals_file} -v 0.4 #{padded_outro_file} #{mixed_broadcast_file}")
# compress and fade in
system("sox #{mixed_broadcast_file} #{compressed_broadcast_file} fade 5 compand 0.3,1 6:-70,-60,-20 -5 -90 0.2 norm -3")
# rubocop:enable Layout/LineLength
# wav to mono mp3
system("sox #{compressed_broadcast_file} #{output_file} remix -")
# cleanup everything
system("rm -rf work")
output_file
end
That's some messy code but the power that sox
gives you is incredible. For the most part the code is just lining up the intro, vocals, and outro files so that when combined the intro and outro sound logically placed. The last bit with the compand
command is a little crazy and you'll want to experiment with that and read the docs - but it "roughens" up the audio into this:
Saving The Final File
record = Record.load
record.created_at = Time.now.to_s
record.events << get_weather_event
record.events += get_email_events
record.events += get_camera_events
record.events << get_thermostat_status
record.script_prompt = "#{base_script_prompt} #{record.events.join("\n")}"
vocals_file = get_script_audio(record.script_prompt, "broadcast.mp3")
That's it!
Showtime!!!
Now all we have to do is broadcast this! If you're in an area where "pirate" radio stations are illegal you'll want to make sure you purchase an approved FM Transmitter. HobbyBroadcaster.net has a list of places to get one.
Once you have one of these you can just plug any device that can play your file into the Line-in and then fire up your radio!
But if you wanted to automate this a little you could run this whole thing on a Raspberry Pi and have it generate the file on a cron task, then play the audio out of the audio jack and right into your transmitter. Just don't forget to tune in!!
Automation Setup
You can simply install something like alsa-utils on your Raspberry Pi and use the aplay
command to play your file. If you have an audio cable connected from your Pi to your transmitter that's it!
If you put everything above into a single file, you could do something like this:
0 20 * * * cd ~/broadcast && source .env && ruby broadcast.rb && aplay broadcast.mp3
Wrap-Up
If you want to see the project in all of its glory, you can check out the repo for this post in the MakeThingsWorkDotDev
Github Organization. This includes the full versions of the ruby and python scripts and instructions on how to run it.
If it all works you'll see:
/> ruby broadcast.rb
Starting Broadcast Generation... 🎙️
Collecting Weather Data ☀️
Collecting Email Events 📧
Collecting Camera Events 📹
Collecting Thermostat Status 🌡️
Generating Script ✍️
Getting Vocals 🎤
Mixing Audio 🎵
You're done! 🎉 (Completed in 67.41 seconds)
Likely you'll have different cameras, thermostats, emails, etc. This is intended as a working example, but also some inspiration for you to build something that works for you!
Thanks so much and feel free to reach out at rick [at] makethingswork [dot] dev
with any questions.