The Ramblings of Ken
Ken Andersen's blog about technology related subjects.
Saturday, October 24, 2026
Weather Forecasting on Low Power FM Radio
Every radio station needs news and weather. A couple of years ago, KVWJ had a volunteer person to prepare and record forecasts. These would be played on the air twice an hour. A move across the country took our volunteer away.
I got to wondering if it would be possible to pull weather data via API, format it in a way that could be reported, and send it off to an AI service to get a human voice in mp3 format. It turns out the mp3 part was easy. It was the formatting of the weather data into a text report that was the difficult part.
The station pulled from openweathermap.org for a little while, but the forecasts seemed to be more for the Salt Lake area. We then used the tomorrow.io API for over a year. The data from tomorrow.io seemed more accurate, but the data was returned in hourly increments. We had to make some guesses based on these hourly reports when creating morning and evening forecasts.
Recently a listener contacted the station and said that the programming would be perfect if there were more accurate weather forecasts. I got to exploring our weather data again. I found consistent reports saying that Microsoft's Azure Maps Weather API was the most accurate. The problem was that there was a cost associated with this service that our station could not afford.
After more research I found that official non-profit companies can receive grants to use as credits with Microsoft Azure. KVWJ is owned and operated by an officially recognized non-profit, Alumni Records, Inc. It was actually a pretty smooth process to apply for and receive the grant. With that obstacle out of the way, it was time to get to writing code!
The Azure Maps Weather API simply requires an API key and a latitude and longitude. You then make a GET request to atlas.microsoft.com with parameters for how many days of data you want and if you want imperial or metric units. This is the request I settled on using PowerShell:
$RequestHeaders = @{
"subscription-key" = $MicrosoftAPIKey
"Accept" = "application/json"
}
$weather_forecast = Invoke-RestMethod -Uri https://atlas.microsoft.com/weather/forecast/daily/json?api-version=1.1&query=$($Latitude),$($Longitude)&unit=imperial&duration=5" -Method GET -Headers $RequestHeaders
The data that comes back includes a "longPhrase" field that is formatted in a way that describes the conditions like you'd hear on a weather forecast on TV. I no longer had to parse data and construct my own phrasing! The phrasing could simply be combined with something like "KVWJ Weather" or "Hyrum Weather," plus the longPhrase field, and finally adding on the low and/or high temperature that comes back in the data. This results in phrasing similar to:
South valley weather: Partly cloudy overnight with low temps near 36. Mostly sunny and up to 58 on Sunday.
Not bad for an automated forecast! It's been shockingly accurate so far. A couple of weeks ago it even included phrasing that described that the showers we were getting were the remnants of tropical storm Rachel.
The station has been using OpenAI's API for speech. We are exploring the possiblity of switching over to Microsoft's API for this as well.
Thursday, October 22, 2026
PostgreSQL for Song Announcements in Low Power FM Radio
Continuing the PostgreSQL in LPFM series, the subject today is song announcements.
When you listen to terrestrial radio, it can be frustrating when you hear a new song that you really like and you want to know who the artist is. Occassionally, a live DJ will announce what the song was. I wondered if we could have AI announce songs for us.
My first idea was to build a Python flask web service that would then send requests off to OpenAI to get back mp3 files that could be played on the air. The automation software had an event set to query an http url for audio playback. When that URL was hit, it would run this code:
def generate():
# Streaming request to OpenAI text-to-speech
# NOTE: adjust model/voice/format to what you want
with client.audio.speech.with_streaming_response.create(
model="gpt-4o-mini-tts", # example model name
voice="nova",
input=text,
instructions="Speak with excitement like a radio DJ!",
response_format="mp3", # or "wav", "mp3", etc.
stream_format="audio", # important for audio streaming
) as resp:
for chunk in resp.iter_bytes(chunk_size=4096):
if chunk:
yield chunk
# Use chunked transfer encoding
return Response(
generate(),
mimetype="audio/mpeg",
direct_passthrough=True,
)
This worked pretty well, but sometimes the response coming back from OpenAI was slower than expected. Sometimes it was more likely hiccups in my Python code. This would result in a couple of seconds of silence on the air. It was not ideal at all.
I wrote a Python script that would go through categories in the database and generate announcement files. Those files are then stored on the playback machine where the automation software can access them.
As I covered in the previous post, I had already setup a trigger system in PostgreSQL that would notify when a new playback is logged. If there were pregenerated announcement files ready to playback then this notification system could be utilized to point the radio automation software at the proper announcement file. I wrote another Python script that listens for the notifications and creates a symlink to the proper announcement file. That way the automation system can have one event that fires off that points to a symlink and the proper announcement is always played on the air. Here's a glimpse of what is going on through the day:
81024 Oct 1 15:47 998-1~0.mp3
72960 Oct 1 15:47 998-2~0.mp3
71424 Oct 1 15:56 999-1~0.mp3
86400 Oct 1 15:56 999-2~0.mp3
12 Oct 22 17:15 ANNOUNCEMENT~0.mp3 -> 1296-2~0.mp3
2026-10-22 17:13:07 2026-10-22 17:13:07,630 INFO Received notification: channel=rds_playback pid=40 playback_id='4245429'
2026-10-22 17:13:07 2026-10-22 17:13:07,631 INFO lookup_category playback_id=4245429
2026-10-22 17:13:07 2026-10-22 17:13:07,631 INFO lookup_category row=(1518,)
2026-10-22 17:13:07 2026-10-22 17:13:07,631 INFO lookup_category playback_id=4245429
2026-10-22 17:13:07 2026-10-22 17:13:07,632 INFO lookup_category row=('B medium oldie',)
2026-10-22 17:13:07 2026-10-22 17:13:07,709 INFO Target announcement found: 1518-1~0.mp3
2026-10-22 17:13:07 2026-10-22 17:13:07,711 INFO Removed old symlink at: /mnt/zara/Song Announcements/ANNOUNCEMENT~0.mp3
2026-10-22 17:13:07 2026-10-22 17:13:07,717 INFO Successfully updated relative symlink: ANNOUNCEMENT~0.mp3 -> 1518-1~0.mp3
2026-10-22 17:15:47 2026-10-22 17:15:47,924 INFO Received notification: channel=rds_playback pid=40 playback_id='4245430'
2026-10-22 17:15:47 2026-10-22 17:15:47,924 INFO lookup_category playback_id=4245430
2026-10-22 17:15:47 2026-10-22 17:15:47,925 INFO lookup_category row=(1296,)
2026-10-22 17:15:47 2026-10-22 17:15:47,925 INFO lookup_category playback_id=4245430
2026-10-22 17:15:47 2026-10-22 17:15:47,927 INFO lookup_category row=('C slow oldie',)
2026-10-22 17:15:48 2026-10-22 17:15:48,013 INFO Target announcement found: 1296-2~0.mp3
2026-10-22 17:15:48 2026-10-22 17:15:48,015 INFO Removed old symlink at: /mnt/zara/Song Announcements/ANNOUNCEMENT~0.mp3
2026-10-22 17:15:48 2026-10-22 17:15:48,021 INFO Successfully updated relative symlink: ANNOUNCEMENT~0.mp3 -> 1296-2~0.mp3
We've heard really good feedback on this new feature on the air and have been super happy with the results.
Wednesday, October 21, 2026
PostgreSQL for Radio Data System in Low Power FM Radio
This is a continuation from my previous post about PostgreSQL in LPFM.
In December 2023, KVWJ installed a new Nautel VX300 transmitter. You can read more about that at these places if you want:
This new transmitter enabled the station to display the song title and artist information on RDS equipped radios. We were excited to start doing this. I was afraid that this was a very complicated process, but it is quite simple. You send a netcat command to the transmitter with the text information you want. No problem. I originally accomplished this with a simple bash script. Here's a snippet from that:
SONGNAME=$(</mnt/zara/ZaraLogs/CurrentSong.txt)
NAMELENGTH=${#SONGNAME}
if [ "$NAMELENGTH" -gt 64 ]; then
SONGNAMECUT=$(echo $SONGNAME | cut -c 1-64)
echo "TEXT=$(echo $SONGNAMECUT )"|nc -i 2 -w 2 192.168.0.108 7005
echo "TEXT?"|nc -i 2 -w 2 192.168.0.108 7005 &
fi
if [ "$NAMELENGTH" -lt 65 ]; then
echo "TEXT=$(echo $SONGNAME )"|nc -i 2 -w 2 192.168.0.108 7005
echo "TEXT?"|nc -i 2 -w 2 192.168.0.108 7005 &
fi
I was surprised how easy it was to send RDS commands.
In 2026, however, we decided it was time to take RDS to the next level. One minor problem we had was that the CurrentSong.txt from ZaraRadio is reading the filename. If we had something wrong with the filename then the information would be displayed incorrectly on RDS. One common thing we run into is that filenames do not include "The" in them. So a song by "The Turtles" is simply labeled "Turtles" in the filename. This means that you see on RDS "So Happy Together - Turtles." This is not ideal.
I realized we could use the PostgreSQL database for this. I extended the database to include artist_display_name and track_display_name fields. I then added a template view with a form so you could edit the artist_display_name and track_display_name.
With the new artist_display_name and track_display_name fields and the template view in place, we were ready to move on from the bash script to something more sophisticated. My original implementation was a Python script watching for the Zara daily log to change and then looking up the song in the database to see if it had a display_name field populated. This was problematic because the file would change and sometimes the song in the log wouldn't be in the database yet. That's when I realized I could use a PostgreSQL database trigger and a Python listener for this. Here's a snippet from the Python:
# Wait for a Postgres notification, up to one second.
ready, _, _ = select.select([listener_conn], [], [], 1)
if ready:
listener_conn.poll()
while listener_conn.notifies:
current_song_category = None
notification = listener_conn.notifies.pop(0)
database_song_text, current_song_category = process_notification(
listener_conn,
notification,
)
So the Python code is waiting for a trigger to fire which then send a notification with a new playback ID when a new record is inserted into the playback table. More database queries are executed to then get the proper artist and track information:
SELECT CONCAT_WS(
' - ',
COALESCE(NULLIF(t.track_display_name, ''), NULLIF(t.track_name, '')),
COALESCE(NULLIF(a.artist_display_name, ''), NULLIF(a.artist_name, '')),
NULLIF(y.year::text, '')
)
FROM playback p
JOIN tracks t
ON t.id = p.trackid
LEFT JOIN artists a
ON a.id = t.artistid
LEFT JOIN years y
ON y.id = t.yearid
WHERE p.id = %s
LIMIT 1
The COALESCE database command allows the display_name field to be selected if it exists, otherwise it falls back to the regular name field.
With the right information we can build a string that can be sent to the transmitter. Instead of using netcat from the shell, we use a Python socket:
def send_rds_command(command: str) -> None:
with socket.create_connection((RDS_HOST, RDS_PORT), timeout=2) as sock:
sock.sendall((command + "\n").encode("utf-8"))
This architecture blew away my expectations. It's a foolproof way to control what information actually gets sent to the transmitter.
More is yet to come from my PostgreSQL in LPFM series...
Tuesday, October 20, 2026
PostgreSQL for Low Power FM Radio
Confession: I'm into terrestrial radio.
I volunteer my time doing IT projects for the local LPFM. If you want to read all about this station head on over to kvwj.org. It's pretty cool what has been done with next to zero budget.
Back in 2024 the director and chief engineer was wondering how he could get a list of the songs that have played on the air over the last four weeks. He was wanting to analyze just how often songs are playing. This station uses the free version of ZaraRadio. It gives you playback logs for every day. Going through the logs and figuring out what played is a big hassle though. Here's a small sample of what a daily log looks like:
06:32:39 start C:\Users\KVWJ\Desktop\ye olde new gold\playback\clockwheel rotation\B medium oldie\A Well Respected Man - Kinks - 1965.mp3
06:35:16 start C:\Users\KVWJ\Desktop\ye olde new gold\playback\clockwheel rotation\C slow oldie\It's Just A Matter Of Time - Brook Benton - 1959.mp3
06:37:41 start C:\Users\KVWJ\Desktop\ye olde new gold\playback\clockwheel rotation\D extra slow oldie\Your Mama Said You Cried - Kenny Dino - 1961.mp3
I thought it shouldn't be too bad to write a Python script that can scrape these logs and throw them into a SQL database. I took a few hours over a week or so and got a proof of concept working. I had a script running in a loop and grabbing lines from the log that matched the pattern above. I started with a SQLite database with a flat one table structure.
The next thing I worked on was a web interface to interact with the SQLite database. I have some experience in Django, so I cobbled together a project based on django-tables2 and got the data displaying on a web page. I then threw a download button on it so that our director could have a nice csv of the data to take with him.
I eventually realized that this flat SQLite database would not be sustainable.
I then got to work restructuring the database so it wasn't basically a large Excel spreadsheet. I broke it off into artist, track, category, year, and playback tables. The rows in the playback table corresponding to the log lines above now looks like this:
id,track,date,timestamp
4244293,2511,2026-10-20,06:37:41
4244292,1659,2026-10-20,06:35:16
4244291,1207,2026-10-20,06:32:39
This makes the database much more efficient.
I was at this point fighting with Django. Randomly it would get really slow. I decided it was time to move from the SQLite database to a more powerful PostgreSQL database. I created tables with the same name in the new database and then dumped the SQLite tables and imported them into the new PostregSQL tables. Much better.
And this is just part 1 is a series of posts I will be making about this.
Thursday, April 14, 2022
Is Ben Cool?
The 2021-2022 Utah Jazz
© The Ramblings of Ken Powered by Bootstrap , Blogger templates and RWD Testing Tool



