Christmas music box with the Thingy:53

Christmas music box with the Thingy:53

Earlier this year, I came across a fun project made by one of my colleagues, Torbjørn Øvrebekk, at Tech Support’s annual Christmas workshop last year. It’s a simple application that plays pre-programmed Christmas songs out of a small buzzer using the nRF52 or nRF53’s PWM capability.

With Christmas and vacation coming up, I thought it would be fun to add a control feature to the project over Bluetooth LE to have a musical ornament to hang on the Christmas tree, as well as add a couple more songs to the playlist. If that sounds fun, how about following along and adding your own upgrades as well?


The Thingy:53 is the perfect hardware for this project, with our two critical components, an nRF5340 and a buzzer, already integrated.

The project’s PWM implementation is based entirely on the nrfx driver. The most recent release of nRF Connect SDK, v2.5.0, sees some major changes to the nrfx driver APIs. To avoid the migration effort, I opted for v2.4.2 instead, where the nrfx APIs are still compatible with the project’s current implementation.

I will also build with the thingy53_nrf5340_cpuapp target board instead of thing53_nrf5340_cpuapp_ns. The added security of TF-M is not critical for our project here, and the simpler target should help us get a better compiling time.

Getting started

Let's take a look at the project modules.


Consists of music.c and music.h, this module defines a structure to hard-code some songs into our application and the functions to decode the relatively human-readable music syntax for further processing.


Consists of sound_gen.c and sound_gen.h, this module processes the notes from the Music module into a buffer of PWM duty cycle value that will be double buffered to the PWM peripheral.

Instruments and Songs

Consists of instruments.h, instruments.c, songs.h, and song_*.c. These modules define the songs and the instruments that each track of a song will be played with.

Testing the project

Now that we have a general idea of how it works let’s see it in action on the Thingy:53.

First, we need to change the PWM output to the actual pins connected to the buzzer on the Thingy:53. Referring to either the Thingy:53's schematic or its DTS files in nRF Connect SDK, we can find that pin P1.15 controls the buzzer.

Update main.c as below to reconfigure the PWM output to that pin:

#include <hal/nrf_gpiote.h>
#include <hal/nrf_gpio.h>
#define PWM_PIN NRF_GPIO_PIN_MAP(1, 15)
        .output_pins =
            0,						// channel 0
            PWM_PIN,				// channel 0
            NRFX_PWM_PIN_NOT_USED,	// channel 1
            NRFX_PWM_PIN_NOT_USED,	// channel 2
            NRFX_PWM_PIN_NOT_USED	// channel 3
        .irq_priority = 5,
        .base_clock   = NRF_PWM_CLK_16MHz,
        .count_mode   = NRF_PWM_MODE_UP,
        .top_value    = PWM_COUNTERTOP,
        .load_mode    = NRF_PWM_LOAD_COMMON,
        .step_mode    = NRF_PWM_STEP_AUTO

If you compile and flash the Thingy:53 with the application now, you should be able to hear the tune of Silent Night and God Rest Ye played on repeat!

Adding a remote-control interface

Coming from a largely nRF5 SDK background, my first idea for setting up remote control over Bluetooth LE was to use the Nordic UART Service.

However, I quickly realized there is a very powerful tool in the nRF Connect SDK (specifically from the Zephyr RTOS) that would be perfect for the job: The Shell service. It provides a few features that are perfect for this job:

  1. Human-readable custom command definitions and processing with just a few lines of codes

  2. Transport over UART, RTT, or BLE (via MCUmgr)

    1. This means that we would also get wired control of the device with minimal code changes. This comes in handy for debugging.

So first, let’s enable Shell over UART and Bluetooth LE.

# Enable Shell with UART and Dummy backend. 
# The dummy backend is necessary for enabling Shell over MCUmgr 

# Enable MCUmgr Shell feature
CONFIG_NET_BUF=y    # MCUmgr dependency
CONFIG_ZCBOR=y      # MCUmgr dependency

# Enable MCUmgr over Bluetooth LE

# Enable automatic connection parameter negotiation suitable for MCUmgr

# Enable MCUmgr message reassembly

# Enable Bluetooth LE Peripheral

# Enable higher BLE GATT MTU and GAP Data Length
# Shell commands would not work with small MTU

# Restrict number of connections supported to lower RAM consumption

If you prefer to work with RTT instead, you can also enable Shell over RTT with CONFIG_SHELL_BACKEND_RTT.

We also need to reconfigure the controller running on the Network core to allow for the large data length that was just set on the Host side on the Application core.
Create child_image/hci_rpmsg.conf, and add these overlay configurations:

# Enable higher BLE GATT MTU and GAP Data Length

# Restrict number of connections supported to lower RAM consumption

You can now test whether the Shell works using a serial terminal on your PC and/or the nRF Device Manager app.

Figure 1: Serial (CDC ACM) Shell Interface

Figure 2: nRF Device Manager Shell Interface

With the Shell running, it's time to set up some custom commands. Update lines these into main.c:

#include <zephyr/shell/shell.h>
static int cmd_stop(
	const struct shell *shell, size_t argc, char *argv[])
    shell_fprintf(shell, SHELL_NORMAL, "TODO: Stop command\n");
    return 0;

static int cmd_play(
	const struct shell *shell, size_t argc, char *argv[])
    shell_fprintf(shell, SHELL_NORMAL, "TODO: Play command\n");
    return 0;

		"usage example:\n$ music_box play\n",
		"usage example:\n$ music_box stop\n",

SHELL_CMD_REGISTER(music_box, &music_box_cmds, "Control Music Box", NULL);

We can run a quick test to confirm that our custom commands are functioning as intended.

Figure 3: Custom Shell command music_box running

Now, let’s add the functionality.

Adding a Stop Playing functionality

The Stop Playing behavior can be done by interrupting the processing in the Music module. We can quickly implement it with a boolean flag.


volatile bool stop_playing_flag = false;
int music_play_song(music_songdef_t *song)
	do {
		active_lists = 0;
		for(int nl = 0; nl < song->num_note_lists; nl++) {
			if (stop_playing_flag) break;
		if (stop_playing_flag) break;
	} while(active_lists > 0);
	stop_playing_flag = false;
	printk("Song completed\n");
	return 0;


extern volatile bool stop_playing_flag;
static int cmd_stop(
	const struct shell *shell, size_t argc, char *argv[])
    shell_fprintf(shell, SHELL_NORMAL, "ACK music_box stop\n");
	stop_playing_flag = true;
    return 0;

Adding a Start Playing functionality

Our base project has the music started and looped in a dedicated thread. We can add a play control functionality to the thread using the Zephyr RTOS Kernel Events API.

Since this changes the way music is played, we also need to update the cmd_stop() function to also post a kernel event.

First, enable the Kernel Events API in prj.conf:

# Enable Kernel Events API

Next, set the event definition and handling in main.c:

#define MUSIC_CTRL_EVTS_START_BIT	((uint32_t) 0x01U << 0)
#define MUSIC_CTRL_EVTS_STOP_BIT	((uint32_t) 0x01U << 1)

static int cmd_stop(
	const struct shell *shell, size_t argc, char *argv[])
    shell_fprintf(shell, SHELL_NORMAL, "ACK music_box stop\n");
	k_event_post(&music_ctrl_evts, MUSIC_CTRL_EVTS_STOP_BIT);
	stop_playing_flag = true;
	return 0;
static int cmd_play(
	const struct shell *shell, size_t argc, char *argv[])
	shell_fprintf(shell, SHELL_NORMAL, "ACK music_box play\n");
	k_event_post(&music_ctrl_evts, MUSIC_CTRL_EVTS_START_BIT);
    return 0;
void thread_play_notes_func(void)
	while (1) {
		volatile uint32_t evts= 0;
		static volatile bool is_playing = false;
		evts = k_event_wait(&music_ctrl_evts, MUSIC_CTRL_EVTS_ALL_BITS, false, K_MSEC(10));
		if ((evts & MUSIC_CTRL_EVTS_START_BIT) != 0) {
			is_playing = true;
		if ((evts & MUSIC_CTRL_EVTS_STOP_BIT) != 0) {
			is_playing = false;

		k_event_clear(&music_ctrl_evts, MUSIC_CTRL_EVTS_ALL_BITS);

		if (is_playing) {

Adding more songs

With our control interfaces all set up, it’s time to add some more songs!

First, create a new song source file and register it with the project CMakeLists.txt:

    app PRIVATE 

Then, we can define a couple more songs in the new song_xmas.c. Here is the code, with 99% credit to my wife, who transcribed the songs from music sheets to the project's syntax:

#include <songs.h>

const char boot_tune[] = "G3-3-100,A-4,B-4,C-4,B-4,A-4,end";

music_songdef_t song_short_dbg = {
	.max_amp = 120,
	.note_lists[0].note_string = boot_tune,
	.note_lists[0].instrument = &instr_lead1,
	.note_lists[0].note_offset = 0,
	.num_note_lists = 1,
	.speed = 300,

const char santa_claus_is_coming_to_town_track_1[] = 
const char santa_claus_is_coming_to_town_track_2[] = 

music_songdef_t song_santa_claus_is_coming_to_town = {
	.max_amp = 120,
	.note_lists[0].note_string = santa_claus_is_coming_to_town_track_1,
	.note_lists[0].instrument = &instr_lead1,
	.note_lists[0].note_offset = 0,
	.note_lists[1].note_string = santa_claus_is_coming_to_town_track_2,
	.note_lists[1].instrument = &instr_lead1,
	.note_lists[1].note_offset = 0,
	.num_note_lists = 2,
	.speed = 200,

const char paa_laaven_sitter_nissen_track_1[] = 
	"D2-1-100,G-1,Gb-1,G-1,A-1,B-1,A-1,B-1,C3-1,E-2,D-2,D-3,B2-1,D3-2,C-2,C-3,A2-1,C3-2,B2-2,B-3,D-1," \
    "G-1,Gb-1,G-1,A-1,B-1,A-1,B-1,C3-1,E-2,D-2,D-3,B2-1,D3-2,C-2,C-1,Gb2-1,Gb-1,Gb-1,G-2,B-2,G-2,G-2," \
    "E3-1,D-1,C-1,B2-1,D3-1,C-1,B2-1,A-1,G-4,E-2,G-1,G-1,F-4,D-2,A-1,A-1,G-4,E-2,G-2," \
const char paa_laaven_sitter_nissen_track_2[] = 
	"P-1-100,G1-4,D-4,G-4,D-4,D-4,A-2,D-2,G-4,D-4,G-4,D-4,G-4,E-4,A-4,D-2,A-2,G-2,D-2,G-4," \

music_songdef_t song_paa_laaven_sitter_nissen = {
	.max_amp = 120,
	.note_lists[0].note_string = paa_laaven_sitter_nissen_track_1,
	.note_lists[0].instrument = &instr_lead1,
	.note_lists[0].note_offset = 0,
	.note_lists[1].note_string = paa_laaven_sitter_nissen_track_2,
	.note_lists[1].instrument = &instr_lead1,
	.note_lists[1].note_offset = 0,
	.num_note_lists = 2,
	.speed = 200,

Also, remember to declare the new songs in songs.h:

extern music_songdef_t song_god_rest_ye_gentlemen;
extern music_songdef_t song_holy_night;
extern music_songdef_t song_short_dbg;
extern music_songdef_t song_santa_claus_is_coming_to_town;
extern music_songdef_t song_paa_laaven_sitter_nissen;

Finally, update main.c to play through a playlist:

		if (is_playing) {
			static music_songdef_t* playlist[] = {
			static size_t playlist_play_idx = 0;
			if (playlist_play_idx >= (sizeof(playlist) / sizeof(music_songdef_t*))) {
				playlist_play_idx = 0;


Here is an audio clip of the Thingy:53 playing all four pre-programmed songs of the final project:

  1. Santa Claus is Coming to Town
  2. På Låven Sitter Nissen (a Norwegian Christmas song)
  3. Silent Night
  4. God Rest Ye Merry Gentlemen

Below, we see the Thingy:53 hanging on our Christmas tree blinking away, with a few more Thingy:52s for effect.


With the building blocks that we have covered today, there are a few features that you could consider adding:

  • Playlist shuffling
    • Currently, our implementation plays all songs in the same order on repeat.
      A little shuffle of the play order would be nice.
  • LED effect
    • The RGB LEDs on the Thingy:53 can be controlled with either basic GPIO toggling or PWM for some soft blinking effect.
      There are several samples from Zephyr that we can refer directly to for a speed boost.
  • Button control
    • The Thingy:53 also has a button that can work as a Play/Stop toggling button.
      We can add it as another convenient control interface. The functions used for the custom Shell commands could be reused as-is here.
  • Sleeping and Power Optimization
    • Right now, when our music box stops playing, all of its functionality is still running in the background.
      We can implement power-saving behavior, such as putting the device into a deep sleep after a period without interaction, or significantly increase the Bluetooth LE advertising period.

My final project is available on the xmas_2023 branch of my ncs-melody fork:
Please feel free to comment here if you have any questions Slight smile

Happy holidays!