picoamp


Screenshot of Picoamp interface in Picotron

Building games and apps in Pico-8 and Picotron has put me back into the fun zone of programming… back when I was learning in classic ASP, Java J2EE, and ColdFusion. In those days, I’d drive my 14 year old self to the radio station after school, put on some headphones and play with code for hours, always listening to 90s Shoutcast streams with Winamp. Picoamp has made me feel like that again, so I threw together this Winamp sort of clone that runs in Picotron to really complete my coding time machine bubble.

Before diving into Picoamp, a quick note on what Picotron is.

What is Picotron

Picotron is this incredible fantasy workstation from Lexaloffle, built around imaginary hardware where you can create and share Lua apps and games that you load and run on the desktop. Its limitations are where the retro computing piece really gets fun. When you build a Picotron app, it gets saved to a virtual cart as a graphic. You can share that graphic to share the app. Being a “virtual cart” based storage mechanism, you have a limited amount of size, limited display and color palette, and limited sound options.

The self-imposed constraints make it fun and interesting - you can’t just install libraries to do stuff, you have to figure it all out at a base level and get creative with it.

How it works

Picotron doesn’t have any of what it needs to play music, but my host OS sure does. So, if we can bridge across the Picotron gap and interact with the OS in some way, we can both drive the (obviously lame in comparison to Winamp) Apple Music app and get data needed for our Picoamp visualization. The quick and dirtiest way I found to cross this bridge was to basically write data files from Picotron that a Ruby service could read and pick up. Similarly, the Ruby service uses the Mac’s osascript interface to get information from Apple Music and write to files that Picotron can pick up to update the interface. If I were doing it again from scratch, I’d probably open a socket from the Ruby service and use direct socket comms from Picotron instead and treat it a bit more like a pub/sub sort of interface.

Ruby service

On the host machine, a small Ruby service runs and talks to Apple Music using osascript commands.

def write_current_track
    track_data = {
        name: `osascript -e 'tell application "Music" to get name of current track'`.strip.downcase,
        artist: `osascript -e 'tell application "Music" to get artist of current track'`.strip.downcase,
        album: `osascript -e 'tell application "Music" to get album of current track'`.strip.downcase,
        duration: `osascript -e 'tell application "Music" to get duration of current track'`.strip.downcase
    }
    File.write('../../appdata/current_track.pod',
         "--[[pod,created=\"#{Time.now}\",modified=\"#{Time.now}\",revision=0]]\n#{ruby_hash_to_lua_table(track_data)}")
end

def write_current_position
    position_data = {
        position: `osascript -e 'tell application "Music" to get player position'`.strip,
        volume: `osascript -e 'tell application "Music" to get sound volume'`.strip
    }
        File.write('../../appdata/playhead.pod',
          "--[[pod,created=\"#{Time.now}\",modified=\"#{Time.now}\",revision=0]]\n#{ruby_hash_to_lua_table(position_data)}")
end

def listen_for_commands
    current_track_timer = 0

    Kernel.loop do
        if current_track_timer == 0
            current_track_timer = 10
            write_current_track
    end

    current_track_timer -= 1

    command_content = File.read('../../appdata/command.txt').split("\n")
    # Get the second line of the file (after the newline)

    command = command_content[1]

    File.write('../../appdata/command.txt', command_content[0])

    puts "Received command: #{command}" if command && !command.empty?

    # Process commands like play, pause, etc.

end

In these examples, we’re writing current track and position information to .pod files and listening for commands to be written from Picotron. Pretty simple, not exactly the most responsive, but it works.

Picoamp UI Updates

With playhead information written from the service, Picoamp can pick it up and update the UI:

function player_status.update_playhead_status()
    local playhead_data = fetch("/appdata/playhead.pod")
    player_status.current_position = playhead_data.position
    player_status.current_volume = playhead_data.volume

    local play_pct = player_status.current_position / player_status.track_data.duration
    playhead_min_x = 17
    playhead_max_x = 240

    playhead_x = playhead_min_x + (playhead_max_x - playhead_min_x) * play_pct

    if playhead_x < controls.playhead.x then
      player_status.update_track_data()
    end

    controls.playhead.x = playhead_x
end

function player_status.update_volume_position()
    local volume_pct = player_status.current_volume / 100
    volume_x = controls.volume.min_x + (controls.volume.max_x - controls.volume.min_x) * volume_pct
    controls.volume.x = volume_x
end

Wrapping Up

Like many of my quick silly little builds, this was a nostalgic experiment to recapture the joy of coding from my teenage years, and it’s evolved into a quirky tool I still use daily for music listening. The file-based bridge to Apple Music isn’t perfect—it’s a bit slow and definitely not production-ready—but it proves that with a little creativity, you can bend platforms like Picotron to do unexpected things.

That said, this is very much a personal hack: the Ruby service ties it to macOS and Apple Music, making it hard to package for others. If you’re into retro programming or Lua, give Picotron a try—it’s a playground for ideas like this.