With the release of QNX Everywhere's Raspberry Pi 4 Custom Target Image (CTI) last year, I saw the perfect opportunity to put my QNX knowledge to the test while picking up a few new skills along the way. After months of work and plenty of planning before my first commit, I'm ready to share my latest project: BamBox.

This also lines up with me starting a new role as a software developer on the QNX Everywhere developer relations team, so you'll probably be hearing from me again soon.

What is BamBox?

BamBox is an enhanced CD player powered by QNX 8.0, running on a Raspberry Pi 4, housed in a fully 3D-printed case. The controls are inspired by car audio: the knob on the right handles volume, the other navigates the interface, and three dedicated buttons handle media controls.

Features include:

  • CD playback

  • Built-in speakers and high-quality audio digital-to-analog converter (DAC)

  • Track controls: previous, play/pause, next

  • Audio controls (volume and output selection)

    • Hardware and software volume for speakers
    • Software-only volume for headphones
  • Track navigation and selection

  • Music metadata fetched from MusicBrainz

  • CD backup as Free Lossless Audio Codec (FLAC) files, uploaded to a personal server for remote playback

Source code and 3D models are open source under the MIT license. Parts list, build instructions, and everything else is on GitHub.

Background

This project came from a mix of personal interest and circumstance. Through my work at QNX, I've spent a lot of time with embedded systems and, more recently, the Raspberry Pi 4. I wanted something that would let me apply that experience in a more hands-on way.

At the same time, I've built up a decent CD collection but never had a reliable way to play them. Most CD players you can find nowadays are either surprisingly expensive or old enough that they're on their last legs. It felt like the perfect excuse to build something myself. Funnily enough, I've probably spent more on components than I would have on a ready-made player — but what's the fun in that?

Reading the CD Drive

The most fundamental function of a CD player is, obviously, reading a CD. In my code, this is split into two parts. The first is load(), which reads all the disc information — track positions and any CD-Text metadata — using devctl calls to query the drive. The second is read(), which retrieves the actual audio data.

At its core, reading a CD is surprisingly simple. Each call retrieves a single sector of audio data: two 16-bit channels in pulse-code modulation (PCM) format. Here's how I implemented it:

#define READ_SIZE CDROM_CDDA_FRAME_SIZE

typedef union {
    cdrom_raw_read_t read;
    uint8_t data[READ_SIZE];
} raw_read_request_t;

bambox::Error CdReader::read(CdReader::AudioData &audio) {
    if (handle_ == -1) {
        return {ECode::ERR_NOFILE, "Disc not loaded"};
    }

    // end of track return EOF
    if (track_lba_current_ == track_lba_end_) {
        audio.frames = EOF;
        return {};
    }

    raw_read_request_t req = {.read = {.lba = track_lba_current_, .nsectors = 1, .est = CDROM_EST_CDDA}};
    int ret = devctl(handle_, DCMD_CAM_CDROMREAD, &req, sizeof(req), NULL);
    if (ret != 0) {
        return {bambox::ECode::ERR_IO, "Failed to read CD", ret};
    }

    audio.ts = std::chrono::minutes(LBA2MIN(track_lba_current_ - track_lba_start_)) +
               std::chrono::seconds(LBA2SEC(track_lba_current_ - track_lba_start_));
    memcpy(audio.data.data(), req.data, sizeof(req.data));

    // Frames is read size / 2 channels / 2 bytes (16 bit audio)
    audio.frames = CDROM_CDDA_FRAME_SIZE / 4;
    track_lba_current_++;
    return {};
}

Audio

Once PCM data is coming off the disc, the next step is getting it out of the speakers.

Audio for BamBox goes through the io-snd SALSA API, which made development much smoother than expected. QNX already provides drivers for both the PCM headphone jack and the I2S DAC on the Raspberry Pi 4 as source samples on QNX Software Center (QSC), so most of the low-level work was already done. My job was mostly integration.

Because io-snd is based on ALSA (Advanced Linux Sound Architecture), documentation was easy to track down. To make adding or swapping audio devices painless in the future, I set it up with a JSON configuration file — adding a USB audio device, for example, should require only a config update, no code changes:

"audio_devs": {
    "Speakers": {
        "dev": "pcmC1D0p",    // Dev name in /dev/snd
        "mixer": "controlC1", // ctrl name in /dev/snd
        "volume": 50          // Starting volume 0-100
    },
    "Headphones": {
        "dev": "pcmC0D0p",
        "mixer": "controlC0",
        "volume": 80
    }
    // TODO USB
},
"audio_dev_default": "Speakers",

Since the CD driver already delivers audio in PCM format, all I had to do was pass data from the CD reader straight to the audio device. The core write function:

int AudioPlayer::write(void *data, int frames) {
    if (current_dev_ == nullptr) {
        return -1;
    }
    auto ret = snd_pcm_writei(current_dev_->handle, data, frames);

    // If the device was previously paused in correctly we need
    // to recover the state of the PCM device.
    if (ret < 0) {
        snd_pcm_recover(current_dev_->handle, -EPIPE, 1);
    }
    return 0;
}

On the hardware side, the PCM5102 DAC connects directly to the Pi, passes through a volume-stage potentiometer, into a HW-104 amplifier, and finally drives the speakers. I was honestly surprised by how good it sounds — as good as, if not better than, most off-the-shelf CD players. I used PWM pins directly on the Pi for a previous guitar pedal project, and the difference is night and day. That said, the Raspberry Pi 4's internal audio DAC is a different story — power isolation issues make it noticeably worse than a proper external DAC.

BamBox Hat wiring.
original

LCD Display

The display uses a virtual framebuffer to capture the rendered GTK4 image and write it to the Serial Peripheral Interface (SPI) LCD using the /dev/screen API. QNX and GTK handle most of the heavy lifting — all I needed was to set up the framebuffer and run a display loop at roughly 30fps:

void LcdDisplay::display_loop() {
    screen_buffer_t screen_pix_buf;
    uint16_t *screen_pix_ptr = NULL;
    uint16_t screen_buf[LCD_HEIGHT][LCD_WIDTH];

    screen_create_pixmap_buffer(screen_pix_);
    screen_get_pixmap_property_pv(screen_pix_, SCREEN_PROPERTY_RENDER_BUFFERS, (void **)&screen_pix_buf);
    screen_get_buffer_property_pv(screen_pix_buf, SCREEN_PROPERTY_POINTER, (void **)&screen_pix_ptr);

    // SET windows
    lcd_write_cmd(0x2a);
    lcd_write_data(0); lcd_write_data(0);
    lcd_write_data((240 - 1) >> 8); lcd_write_data((240 - 1) & 0xff);
    lcd_write_cmd(0x2b);
    lcd_write_data(0); lcd_write_data(0);
    lcd_write_data((320 - 1) >> 8); lcd_write_data((320 - 1) & 0xff);
    lcd_write_cmd(0x2C);

    while (1) {
        screen_read_display(screen_dsy_, screen_pix_buf, 0, NULL, 0);
        convert_img(screen_buf, screen_pix_ptr);

        gpio_->level_set(LCD_DC, true);
        for (int i = 0; i < LCD_HEIGHT; i++) {
            write(spi_dev_, screen_buf[i], LCD_WIDTH * 2);
        }
        gpio_->level_set(LCD_DC, false);
        usleep(33330); // roughly 30 fps
    }
}

The matching screen configuration:

begin virtual display 1
    defer-framebuffer-creation = false
    id_string = virt0
    video-mode = 320 x 240 @ 30
    format = rgba8888
    usage = gles2blt physical
end virtual display

begin class framebuffer-1
    display = 1
    pipeline = 1
    format = rgba8888
    usage = rpi4drm physical
end class
BamBox LCD screen.
original

User Interface and Input

I really wanted to get close to the hardware on this one. Rather than relying on the QNX General Purpose Input/Output (GPIO) resource manager, I wrote my own read/write handlers and interrupt service threads (IST) for GPIO interrupts. Probably not the smartest call in hindsight, but it was a satisfying challenge. The implementation lives in Gpio.hpp if you want to take a look.

Input comes from a rotary encoder, which requires monitoring both the DT and CLK pins to determine direction. For those unfamiliar: rotary encoders have three pins — power (or GND), DT, and CLK. You watch which pin transitions first to determine whether the knob is turning left or right. Here's the interrupt request (IRQ) handler:

gpio_->register_irq(cfg_.rotary_encoder.clk_gpio,
    {platform::Gpio::TriggerType::RISING_EDGE, platform::Gpio::TriggerType::FALLING_EDGE},
    [&](unsigned int gpio, bool high) {
        static bool old_state = false;
        if (high != old_state) {
            bool dt = gpio_->level_get(cfg_.rotary_encoder.data_gpio) != 0;
            if (dt) { // Only do one of the bumps to avoid incrementing twice per turn.
                GSourceOnceFunc cb;
                if (high == dt) {
                    cb = (GSourceOnceFunc) +[](BamBox* bambox) { bambox->ui_handle_input(InputType::RIGHT); };
                } else {
                    cb = (GSourceOnceFunc) +[](BamBox* bambox) { bambox->ui_handle_input(InputType::LEFT); };
                }
                g_idle_add_once(cb, this);
            }
        }
        old_state = high;
    });

Since it had been a while since I'd written rotary encoder code, I checked the QNX rotary-encoder sample first to make sure the wiring was right before diving in. The push buttons were much simpler — just a similar IRQ registration to fire on input.

original

Building the Hardware

I won't go deep into the hardware here, but one thing worth calling out is the use of JST (Japan Solderless Terminal) connectors throughout the build. Rather than soldering components directly to the board, JST connectors let me disconnect and replace parts with minimal soldering. The whole assembly ends up looking like a custom Raspberry Pi 4 hat.

The BamBox circuit design.
original

Features and Final UI

Here's a look at the finished interface in action:

  • Main screen — album art, song title, artist, and playback progress bar
  • Volume control — software volume for the active output device
  • Output select — switch between speakers and headphones during playback
  • Track select — jump to any track on the disc
  • Settings — default audio device, volume, and theme (changes take effect on reboot via the Restart button)
  • Backup CD to cloud — rip the disc as FLAC files with MusicBrainz metadata, then upload via curl + WebDAV (Web Distributed Authoring and Versioning) to a personal music server
  • Song info — full album and track metadata
  • Light mode — loosely based on QNX's colour scheme
original

Challenges and Lessons Learned

GTK4 is a framework, not a library

Since this was my first GTK app — and honestly my first real GUI app in C++ — I went in with the wrong mental model. I built the CD player, audio device, and LCD display as standalone C++ components, planning to bolt the UI on afterward. GTK doesn't work that way. It's a full application framework with its own structure, much like Qt. You're not writing a C++ app and then adding GTK on top; you're building a GTK application in C++, and the whole program needs to be shaped around that from the start.

I ended up rewriting the UI several times before everything fit together properly. Switching to GtkBuilder — rather than building the UI entirely in C++ — made a big difference for maintainability.

💡 If you're building a GTK4 app for the first time: design around it from day one. It's a framework, not a library. GtkBuilder makes UI iteration much more manageable than pure C++.

LCD endianness and rotation

The display needed pixel data in a specific byte order that didn't match what /dev/screen was providing. Until I sorted that out, colours were wrong, and the image was incorrectly rotated. The display ultimately needed a 270-degree rotation to render correctly.

original

Raspberry Pi 4 USB power limits

The Pi's USB ports couldn't supply enough power for the CD drive — it struggled to spin up and frequently failed to read discs. The fix was a powered USB hub, which came with a bonus: individual power switches per port, so I can turn the player on and off by toggling the Pi's port.

⚠️ The Raspberry Pi 4's USB ports can't reliably power a bus-powered CD drive. Use a powered USB hub.

Limited input options

With only a rotary encoder and four push buttons, UI design required some creativity. The final interface ended up feeling like an older car stereo — primarily list-based and fairly linear. GTK has no native support for these kinds of inputs, so I had to manually process GPIO events and translate them into GTK signals to trigger UI callbacks. Not especially difficult, but it required a fair amount of bridging code.

What's Next for BamBox?

There's still more I'd like to do with this:

  • Custom Raspberry Pi 4 printed circuit board (PCB) HAT — the hand-soldered through-hole breadboard works, but a proper PCB would be more reliable and easier to assemble, and would let me add a custom BamBox silkscreen.
  • Proper openWFD driver for the Waveshare SPI LCD — the virtual framebuffer approach is a bit of a hack. The right solution is a dedicated screen device driver so GTK can render directly to the SPI hardware.
  • Touchscreen controls — the rotary encoder is inherently linear, which limits the interface. A touchscreen integrated via QNX's mtouch would open up much richer interactions and a more user-friendly UI.
  • Custom Board Support Package (BSP) — the QNX CTI is great for getting started, but one of QNX's most powerful features is the ability to tailor a custom BSP to your exact needs. With the recent release of the Raspberry Pi 4 BSP, I plan to develop a custom BamBox BSP to further optimize startup time.

If you want to follow along or build something of your own, you can get free access to QNX at qnx.com/getqnx.

All the source code and 3D models are available on GitHub under the MIT license. Feel free to build your own BamBox — or pull out just the pieces you need. The GPIO handler and LCD library in particular could be handy starting points for your own QNX hardware projects.

I hope this inspires you to build something with QNX. I'd love to hear what you come up with!

Lastly, if you want some help with your QNX journey, you can find the QNX team and community:

Happy Hacking!