Back To Basics: The Ring Buffer

If you’ve been writing software for very long, you’ve likely encountered the humble ring buffer. Ring buffers are everywhere . They appear in code for audio engines, network stacks, kernel drivers, graphics engines, etc. They’re one of the most important data structures for getting data from point A to point B in a timely and thread-safe way. Today, we’re going to show the ring buffer some love. We’ll talk about how the ring buffer works, why you might use it, and we’ll even implement one in C++.

What Is a Ring Buffer?

You may have heard of something called a circular buffer, or maybe even a cyclic queue. Both of those are just other names for the ring buffer. You can think of a ring buffer as a specialized type of queue. Just as with any old, vanilla queue, a producer produces some data, shoves it into the data structure, and a consumer comes along and consumes it. This all happens in first-in-first-out (FIFO) order. But what sets the ring buffer apart is the way it manages its data and the limitations it imposes.

A ring buffer has a fixed capacity. It can’t grow and it can’t shrink. If you create a ring buffer that can store 10 items, 10 items will forever be its max capacity. Because the capacity is fixed, a producer has two options once it fills the ring buffer – the choice of which is largely driven by how the ring buffer is designed and the application’s needs. The producer can either wait until a slot is free in the ring buffer so that it can add more data. Or it can just stomp over data that hasn’t been consumed yet. Both approaches are valid in certain contexts.

The consumer’s role is to consume data.  If there’s no data available in the ring buffer, the consumer has to wait or go do something else. The side-effect of a consumer reading data is that it frees up slots within the ring buffer for the producer to use.

Ideally, the producer’s producing is always just slightly ahead of the consumer’s consuming, resulting in a nice game of “catch me if you can” with virtually no waiting by either party.

The canonical diagram of a ring buffer looks like so.

In this diagram, we have a buffer with 32 slots. The producer has filled 15 of them, indicated by blue. The consumer is behind the producer, reading data from the slots, freeing them as it does so. A free slot is indicated by orange.

Keep in mind that these sorts of diagrams are meant to demonstrate a concept and not so much depict an implementation. It’s certainly possible to implement a ring buffer that’s physically circular, as in the image above, using something like a linked list of separate smaller buffers. Quite often, however, you’ll see a ring buffer implemented using one big memory allocation. In this case, separate state is used to track used capacity as well as read and write indices.

Why Use a Ring Buffer?

You’ll most commonly see ring buffers used in circumstances that involve high volumes of data that need to be produced and consumed at high speeds. Real-time audio/video, network drivers, etc. are all contexts where you’ll find ring buffers being used. In these situations, performance of the data structure is key.

Performance takes a beating if you’re having to allocate and deallocate memory all the time. Memory allocation is slow. So ring buffers avoid it by allocating a chunk of memory one time and reusing it over and over. Memory fragmentation is also avoided.

Anything that involves locking (e.g.,  mutexes, and even memory allocation) is problematic when it comes to performance.  You simply can’t predict how long a lock will be held. As we’ll see in our example implementation, ring buffers can be both thread-safe and lock-free.

Speed Is My Jam. When Would I Not Want to Use a Ring Buffer?

Ring buffers are ideally suited for the single producer/single consumer scenario. If there are more than one of either, things get complicated and locking will certainly need to be introduced either directly within the ring buffer or as a wrapper.

If you need your data structure to grow to an arbitrary size, then ring buffers are off the table.

If you need to explicitly control the lifetime of data within the ring buffer (e.g., if you need to hold pointers/references to contained data), then ring buffers are obviously the wrong choice.

If your data needs to be sorted or have a priority associated with it, ring buffers are the wrong choice here as well. Ring buffers are ALWAYS first-in, first-out.

The Gotcha

Because the ring buffer size is fixed, you sometimes have to experiment to find the optimum size to keep the producer ahead of the consumer at a rate that neither ever has to wait to do their job. Take the world of professional audio software as an example. Here, you’ll sometimes find the ring buffer capacity explicitly configurable within the UI as a mechanism for the user to tradeoff between audio latency and glitchy audio for a given hardware configuration.

Screenshot taken from Cakewalk BandLab

When is it ok for a producer to stomp over unconsumed data?

I mentioned earlier that in some scenarios it’s ok for a producer to stomp over unread data. When would this ever be ok? Consider a real-time audio or video streaming application such as a radio streamer or video conferencing app, or perhaps the broadcasting of player states in an online game. For sundry reasons, network hiccups occur. We’ve all experienced them. But regardless of the why, it’s always important to charge forward and have our applications processing the latest and greatest data.

Implementing an Audio Ring Buffer

The time has come. We’re going to implement a simple ring buffer in C++. We’ll make it data type agnostic and lock-free. Let’s start things off with a class skeleton, a constructor that allocates the buffer, and some state for tracking how much of the buffer is free.

template<typename DataType>
class RingBuffer
{
public:
    RingBuffer(int capacity) :
        m_capacity(capacity),
        m_freeItems(capacity),
    {
        static_assert(ATOMIC_INT_LOCK_FREE == 2);
        m_spBuffer = std::make_unique<DataType[]>(m_capacity);
        memset(m_spBuffer.get(), 0, sizeof(DataType) * m_capacity);
    }
    ...
private:
    ...
    std::unique_ptr<DataType[]> m_spBuffer;
    const int m_capacity;
    std::atomic<int> m_freeItems;
};

This is a data type agnostic implementation. RingBuffer is a class template that allows clients to parameterize it with whatever data type they wish (so long as it’s memcpy and memcmp compatible).

The constructor allows the client to specify the size of the ring buffer. This is stored in m_capacity.

We also have a std::atomic<int> that’s used to track how many free slots are available for writing. Having this atomic is key to making this class thread-safe, as both producers and consumers will be indirectly checking this value during the reading and writing of ring buffer data.

You’ll also note that the constructor has a static_assert for ensuring that std::atomic<int> is lock free. This is to avoid any locking that could impact performance.

And just for fun, we initialize the contents of m_spBuffer to zero to put us in a known initial state. This isn’t really all that important. But it might benefit unit tests (which I did write for this, incidentally).

Next up, let’s add the state and functions needed for writing data into the buffer.

    int m_writeIndex; // Initialized to zero in constructor. Not shown above.
 
    int getWriteableItemCount() const
    {
        return m_freeItems.load();
    }
 
    int write(DataType * pData, int numItems)
    {
        int writeableItemCount = getWriteableItemCount();
        if (writeableItemCount == 0)
            return 0;
 
        int totalItemsToWrite = std::min(writeableItemCount, numItems);
 
        // Note that writeableItemCount will do nothing to help us 
        // determine if we're on the edge of the buffer and need to wrap around.
        // That's up to us to determine here.
        int itemsLeftToWrite = totalItemsToWrite;
 
        // Note that we're treating m_capacity like an index here for 
        // one-past-the-end.
        if ((m_writeIndex + itemsLeftToWrite) &gt;= m_capacity)
        {
            // We'd exceed the extent of the buffer here if wrote totalItemsToWrite samples, 
            // so let's do a partial write.
            int itemsAvailableUntilEnd = m_capacity - m_writeIndex;
            memcpy(m_spBuffer.get() + m_writeIndex, pData, itemsAvailableUntilEnd);
 
            // Bookkeeping
            m_writeIndex = 0;
            itemsLeftToWrite -= itemsAvailableUntilEnd;
            pData += itemsAvailableUntilEnd;
        }
 
        if (itemsLeftToWrite &gt; 0)
        {
            memcpy(m_spBuffer.get() + m_writeIndex, pData, itemsLeftToWrite * sizeof(DataType));
 
            // Bookkeeping
            m_writeIndex += itemsLeftToWrite;
            itemsLeftToWrite = 0;
        }
 
        m_freeItems -= totalItemsToWrite;
        return totalItemsToWrite;
    }

There’s one new piece of state here and two new functions.

The new state, m_writeIndex, is used to track where the producer is writing next. This value is only ever used by the producer, so we don’t need to make it atomic.

The function getWriteableCount() merely returns the number of free slots available for writing in the ring buffer. It’s for the benefit of both the producer and the write() function itself.

The write() function attempts to write the specified number of items into the ring buffer and returns the actual number of items written. There are some things worth noting here.

We first check to see if we CAN actually write anything. If we can’t, we return immediately.

Next, we decide how MUCH we can actually write and store it in a local variable called totalItemsToWrite. This value may be less than what the producer requested if they requested more than we have space for. After that, we check to see if we might be trying to write more towards the end of the buffer than we have space for. If so, we write what we can and then loop back around to the beginning to write what’s left. Anything that’s left goes wherever the current write position is located.

Before leaving the function, we update the relevant member variables.

Now let’s look at the state and functions for reading data from the buffer.

    int m_readIndex; // Initialized to zero in constructor. Not shown above.
 
    int getReadableItemCount() const
    {
        return m_capacity - m_freeItems.load();
    }
 
    int read(DataType * pData, int numItems)
    {
        int readableItemCount = getReadableItemCount();
        if (readableItemCount == 0)
            return 0;
 
        int totalItemsToRead = std::min(readableItemCount, numItems);
 
        // Note that readableItemCount will do nothing to help us 
        // determine if we're on the edge of the buffer and need to wrap around.
        // That's up to us to determine here.
        int itemsLeftToRead = totalItemsToRead;
 
        if ((m_readIndex + itemsLeftToRead) &gt;= m_capacity)
        {
            // We'd exceed the extent of the buffer here if read totalItemsToRead items, 
            // so let's do a partial read.
            int itemsAvailableUntilEnd = m_capacity - m_readIndex;
            memcpy(pData, m_spBuffer.get() + m_readIndex, itemsAvailableUntilEnd);
 
            // Bookkeeping
            m_readIndex = 0;
            itemsLeftToRead -= itemsAvailableUntilEnd;
            pData += itemsAvailableUntilEnd;
        }
 
        if (itemsLeftToRead &gt; 0)
        {
            memcpy(pData, m_spBuffer.get() + m_readIndex, itemsLeftToRead * sizeof(DataType));
 
            // Bookkeeping
            m_readIndex += itemsLeftToRead;
            itemsLeftToRead = 0;
        }
 
        m_freeItems += totalItemsToRead;
        return totalItemsToRead;
    }

Much like the writing side of things, we have a new piece of state for tracking the consumer’s read position. We also have a complementary getReadItemCount() function for returning the number of items available for reading. And then there’s the read() function.

If you compare the write() and the read() function implementations here, you’ll see they’re almost exactly the same. There are only two big differences – the direction the data goes in and out of m_spBuffer and the way the member variables are updated (m_freeItems being incremented vs. decremented, and m_readIndex being used vs. m_writeIndex). Apart from that, they’re pretty much the same.

The checks against m_freeItems ensure that the consumer will never overtake the producer. And, in this implementation, the producer can never overwrite data. So accessing m_spBuffer from two different threads is safe because the producer and consumer are never accessing the same slots at the same time.

The only other shared state between the producer and the consumer is m_freeItems and that’s atomic.

The complete RingBuffer implementation is as follows.

/**
 * A simple ring buffer class. This is thread-safe so long as only a
 * single producer and a single consumer are clients.
 */
template<typename DataType>
class RingBuffer
{
public:
    /**
     * Constructor.
     *
     * @param capacity The total number of items to accommodate in the RingBuffer.
     */
    RingBuffer(int capacity) :
        m_capacity(capacity),
        m_freeItems(capacity),
        m_readIndex(0),
        m_writeIndex(0)
    {
        // Lock-free would be important for scenarios that can't use locking, such
        // as real-time audio. If you don't have real-time concerns, then this can 
        // possibly be removed.
        static_assert(ATOMIC_INT_LOCK_FREE == 2);
        m_spBuffer = std::make_unique<DataType[]>(m_capacity);
        memset(m_spBuffer.get(), 0, sizeof(DataType) * m_capacity);
    }
    /**
     * @return The number of items that can be read by the consumer.
     */
    int getReadableItemCount() const
    {
        return m_capacity - m_freeItems.load();
    }
    /**
     * @return The number of items that can be written by the producer.
     */
    int getWriteableItemCount() const
    {
        return m_freeItems.load();
    }
    /**
     * Attempts to read the specified number of items.
     *
     * @return The number of items read.
     */
    int read(DataType * pData, int numItems)
    {
        int readableItemCount = getReadableItemCount();
        if (readableItemCount == 0)
            return 0;
 
        int totalItemsToRead = std::min(readableItemCount, numItems);
        // Note that readableItemCount will do nothing to help us 
        // determine if we're on the edge of the buffer and need to wrap around.
        // That's up to us to determine here.
        int itemsLeftToRead = totalItemsToRead;
        // Note that we're treating m_capacity like an index here for 
        // one-past-the-end.
        if ((m_readIndex + itemsLeftToRead) >= m_capacity)
        {
            // We'd exceed the extent of the buffer here if we read totalItemsToRead 
            // items, so let's do a partial read instead.
            int itemsAvailableUntilEnd = m_capacity - m_readIndex;
            memcpy(pData, m_spBuffer.get() + m_readIndex, itemsAvailableUntilEnd);
            // Bookkeeping
            m_readIndex = 0;
            itemsLeftToRead -= itemsAvailableUntilEnd;
            pData += itemsAvailableUntilEnd;
        }
        if (itemsLeftToRead > 0)
        {
            memcpy(pData, m_spBuffer.get() + m_readIndex, itemsLeftToRead * sizeof(DataType));
            // Bookkeeping
            m_readIndex += itemsLeftToRead;
            itemsLeftToRead = 0;
        }
        m_freeItems += totalItemsToRead;
        return totalItemsToRead;
    }
    /**
     * Attempts to write the specified number of items. This is only
     * guaranteed to write what we have space for. The amount of available
     * space can be determined by invoking getWriteableItemCount(). 
     * 
     * @return The number of items actually written.
     */
    int write(DataType * pData, int numItems)
    {
        int writeableItemCount = getWriteableItemCount();
        if (writeableItemCount == 0)
            return 0;
        int totalItemsToWrite = std::min(writeableItemCount, numItems);
        // Note that writeableItemCount will do nothing to help us 
        // determine if we're on the edge of the buffer and need to wrap around.
        // That's up to us to determine here.
        int itemsLeftToWrite = totalItemsToWrite;
        // Note that we're treating m_capacity like an index here for 
        // one-past-the-end.
        if ((m_writeIndex + itemsLeftToWrite) >= m_capacity)
        {
            // We'd exceed the extent of the buffer here if we wrote totalItemsToWrite
            // samples, so let's do a partial write instead.
            int itemsAvailableUntilEnd = m_capacity - m_writeIndex;
            memcpy(m_spBuffer.get() + m_writeIndex, pData, itemsAvailableUntilEnd);
            // Bookkeeping
            m_writeIndex = 0;
            itemsLeftToWrite -= itemsAvailableUntilEnd;
            pData += itemsAvailableUntilEnd;
        }
        if (itemsLeftToWrite > 0)
        {
            memcpy(m_spBuffer.get() + m_writeIndex, pData, itemsLeftToWrite * sizeof(DataType));
            // Bookkeeping
            m_writeIndex += itemsLeftToWrite;
            itemsLeftToWrite = 0;
        }
        m_freeItems -= totalItemsToWrite;
        return totalItemsToWrite;
    }
private:
    std::unique_ptr<DataType[]> m_spBuffer; //! The data.
    int m_writeIndex; //!< Where the producer is writing to next.
    int m_readIndex; //!< Where the consumer is reading from next.
 
    const int m_capacity; //!< Total number of frames managed by the ring buffer.
    std::atomic<int> m_freeItems; //!< Number of frames that are available to be written into.
};

Hopefully this article has helped you to appreciate the humble ring buffer a little bit more. If ring buffers were new to you before this article, I hope this was a helpful introduction and that you’ll be able to recognize them in the wild when you see them (they’re not always given helpful names like RingBuffer. 🙂 ). The implementation shown here is one of the simplest ones, and just one of many possible ways to do it.

If you find a bug in my code or have any questions or comments, please let me know. If you’ve seen any bizarre ring buffer implementations, tell me about that too. Weird code is always fun to see.

Until next time.

Radio Controlled Audio with Arduino

In the first article of this series, I spent some time looking at the DFPlayer – a small, affordable MP3 playback device. I demonstrated how it can be connected to an Arduino and driven over a serial connection. In the second article of the series, I looked at RC radio controllers and how they can also be used in an Arduino project. In this final article, I’m going to be combining the concepts from the previous articles to construct a sound board that allows me to trigger audio playback through a radio controller. This will provide an audio solution for a robotics project that my daughter and I worked on together a few months ago.

There’s not a whole lot of theory here. I’m going to assume that you’ve read the previous two articles. If something is confusing, or if it seems like I skipped over an explanation, please go check out Digital Audio With the DFPlayer and Radio Controllers With Arduino. If something still seems missing, please reach out to me and I’ll fix it.

Physical Construction

Let’s start by seeing how I wired everything together. Below is a quick layout I did with Fritzing.

And here is what the actual board looked like when I was finished.



The biggest component on the board is an Arduino Nano.
Next to the Nano is the DFPlayer. Soldered to the top of the board is a connector for power. And beneath the DFPlayer are two 2-pin JST connectors. One of the JST connectors is for the speaker and the other is for the PWM data from the RC radio receiver.

I used a solder bridge to connect the components where I could. And where I couldn’t, I added some bridge wire.

If you read my DFPlayer article, you might note a difference in serial connections here. Originally, I had opted for a software-based serial connection from the Nano to DFPlayer using pins 10 and 11. I tried that arrangement here, but then the DFPlayer started locking up frequently when triggered from the radio. I thought there might be some timing/buffering issues at play, so I switched to the hardware UART, which requires use of the RX and TX pins on the Nano. And the problem went away. I’m still not convinced I understand why. I’ll save this investigation for another day. Using the RX and TX pins, however, means I can’t write new code to the Nano without disconnecting the DFPlayer first (e.g., desoldering). I shouldn’t need to do that unless I want to add new functionality down the road.

The Code

What follows is the code I wrote that ties everything together. Because we already saw how much of this works in the first two articles, I’m not going to explain the code line-by-line. I’ll instead give you a quick tour of the functions of interest, as well as a high-level overview of what they’re trying to accomplish.

#include "Arduino.h"
#include "DFRobotDFPlayerMini.h"
 
// These are connected to RC channels 5 and 6, respectively.
static const int AUDIO_TRIGGER_PIN = 2;
static const int VOLUME_KNOB_PIN = 3;
 
// For serial communication to the DFPlayer.
static DFRobotDFPlayerMini g_dfPlayer;
 
// The total number of files on the DFPlayer's SD card.
static int g_totalFileCount = 0;
 
/**
 * Checks to see if anything has changed on the audio trigger
 * channel. And if so, plays the next sound.
 */
void handleFilePlayback()
{
    static int lastTriggerValue = 0;
    static int lastFilePlayed = 1;
 
    int pulseValue = pulseIn(AUDIO_TRIGGER_PIN, HIGH);
    if (pulseValue == 0)
        return;
 
    int audioTrigger = (pulseValue < 1500) ? 0 : 1;
    if (audioTrigger != lastTriggerValue)
    {
        g_dfPlayer.stop();
        delay(200);
        if (lastFilePlayed >= g_totalFileCount)
        {
            // We've already played the last file. Time to loop back around.
            g_dfPlayer.play(1);
            lastFilePlayed = 1;
        }
        else
        {
            g_dfPlayer.next();
            ++lastFilePlayed;
        }
        delay(200);
        lastTriggerValue = audioTrigger;
    }
}
 
/**
 * Checks to see if anything has changed on the volume adjustment
 * channel. If so, adjusts the volume on the DFPlayer accordingly.
 */
void handleVolumeAdjustment()
{
    static int lastVolumeValue = 0;
 
    int pulseValue = pulseIn(VOLUME_KNOB_PIN, HIGH);
    if (pulseValue == 0)
        return;
 
    int volumeKnob = map(pulseValue, 990, 2000, 0, 30);
    if (volumeKnob != lastVolumeValue)
    {
        g_dfPlayer.volume(volumeKnob);
        lastVolumeValue = volumeKnob;
    }    
}
 
/**
 * Enters into an endless loop, flashed the specified
 * LED at the specified rate.
 */
void haltAndFlashLED(int ledId, int flashRateInMillis)
{
    bool ledOn = false;
 
    unsigned long timeLastToggled = millis();
    do
    {    
        unsigned long currTime = millis();    
        if ((currTime - timeLastToggled) > flashRateInMillis)
        {
            digitalWrite(ledId, (ledOn ? HIGH : LOW));
            ledOn = !ledOn;        
            timeLastToggled = currTime;
        }        
        delay(0);
    } while(true);
}
 
void setup()
{
    // We'll use the built-in LED to indicate a communications
    // problem with the DFPlayer.
    pinMode(LED_BUILTIN, OUTPUT);
    digitalWrite(LED_BUILTIN, LOW);
 
    pinMode(AUDIO_TRIGGER_PIN, INPUT);
    pinMode(VOLUME_KNOB_PIN, INPUT);
 
    // Let's give the DFPlayer some time to startup.
    delay(2000);
 
    Serial.begin(9600);
 
    if (!g_dfPlayer.begin(Serial))
        haltAndFlashLED(LED_BUILTIN, 1000);
 
    g_totalFileCount = g_dfPlayer.readFileCounts(DFPLAYER_DEVICE_SD);
 
    if (g_totalFileCount <= 0)
        haltAndFlashLED(LED_BUILTIN, 500);
 
    // Valid values for volume go from 0-30.
    g_dfPlayer.volume(20);
    // Plays the first file found on the filesystem.
    g_dfPlayer.play(1);
}
 
void loop()
{
    handleFilePlayback();
 
    // Consumes any data that might be waiting for us from the DFPlayer.
    // We don't do anything with it. We could check it and report an error via the
    // LED. But we can't really dig ourselves out of a bad spot, so I opted to
    // just ignore it.
    if (g_dfPlayer.available())
        g_dfPlayer.read();
 
    handleVolumeAdjustment();
 
    // Consumes any data that might be waiting for us from the DFPlayer.
    // We don't do anything with it. We could check it and report an error via the
    // LED. But we can't really dig ourselves out of a bad spot, so I opted to
    // just ignore it.
    if (g_dfPlayer.available())
        g_dfPlayer.read();
}

Let’s start by looking at the setup() function. If you’ve read the previous two articles, this should look somewhat familiar to you. We start with initializing pins, the serial port, and the DFPlayer. Something new here is that we query the number of files on the SD card by calling the g_dfPlayer’s readFileCounts() member function. We then cache this value so we later know when to loop back to the first file. If we get a 0 for this value, something is wrong. In that case, we light up the LED and halt. Otherwise, we set the volume to a reasonable value and play the first file.

Next up, let’s look at the loop() function. It’s pretty simple. It just repeatedly calls handleFilePlayback() and handleVolumeAdjustment() over and over.

The handleFilePlayback() function is dedicated to checking whether or not the toggle channel value on the RC receiver has changed and reacting appropriately. If the toggle switch hasn’t moved, we do nothing. If it has moved, we then check to see if we’ve exceeded the number of files on the SD card. If we’ve already played all the files, we loop back around to file 1. If we haven’t, then we play the next file.

The handleVolumeAdjustment() function checks whether or not the volume knob on the radio has changed value and then reacts appropriately. If the knob value hasn’t changed, we do nothing. Otherwise, we map the PWM value range of 990 to 2000 to the DFPlayer’s volume range, which goes from 0 to 30, and then update the DFPlayer.

Conclusion

And that’s it! All I needed to do after writing the code to the Arduino was connect the speaker, RC receiver, and battery and give it a whirl.

Everything worked great, with one exception. I noticed if my sound board was too near the RC receiver, I started hearing noise in the speaker. For me, it was an easy fix. I just moved the RC receiver further away from the sound board. But if you have a tighter fit, you might need to experiment with shielding.

Radio Controllers with Arduinos

In the previous article, I introduced my need to add sound capabilities to a radio-controlled robot project that I had been working on with my daughter. I focused on the DFPlayer as a potential solution and discussed how to control it using only an Arduino. However, I didn’t discuss radio control at all. Because the DFPlayer discussion was already pretty lengthy, I wanted to defer that discussion and write a separate article for that.

And here we are.

This time around, we’re going focus entirely on radio control. I’ll give a quick primer on how radio control and PWM works. We’ll also learn how to integrate radio control into an Arduino project. By the end, we’ll be ready to combine the concepts introduced in this article and the previous one to produce a radio-controlled audio player. We’ll save the final implementation for a third, and final, article.

Radio Controllers

A radio controller is typically a pair of devices – a radio transmitter and radio receiver. They cover the gamut in terms of cost and functionality. Whether you’re building a drone, an RC car, a robot, or a fun Halloween prop, there are a lot of radio solutions to choose from. For my purposes, I was just adding functionality to a pre-existing project using a radio that was already in use – the Flysky FS-i6x. That’s the radio controller I’ll be discussing here. But the concepts should carry over to pretty much any consumer-grade radio controller you buy off the shelf. I picked up the FS-i6x from Amazon for $59. (Although, at the time of this writing it appears the price has gone up slightly.)

The Flysky FS-i6x is a 10-channel 2.4 GHz radio transmitter and comes with a 6-channel receiver that’s pre-bound to the radio. It supports tank-style channel mixing (after some configuration), a few common stick configurations, and a few extra switches and knobs that can be used for whatever you need.

Channels and Gigahertz, Oh My!

If you’re new to the radio controller world, you may be unfamiliar with the channels and frequency specifications that are used to characterize radio controllers.

The frequency number refers to the frequency band the radio transmitter uses to exchange information with the receiver. For newer RC systems, 2.4 GHz is commonplace. As far as the FCC is concerned, 2.4 GHz is part of the industrial, scientific, and medical unlicensed band. It’s a crowded band that’s shared by cordless phones, Bluetooth, WiFi, baby monitors, etc.

The number of channels a radio controller has refers to the number of separate “features” it supports. Each channel is tied to a separate control on the transmitter and has its own independent data associated with it. If you look at the picture of the transmitter above, you’ll see it has 2 sticks. Each of these sticks is associated with two channels – one for up and down and one for left and right. Each toggle switch is also a separate channel. And each knob is a separate channel too. (2 sticks x 2 channels per stick) + 4 toggle switches + 2 knobs = 10 channels. Other radio transmitters can have support for more channels and others can have less.

How Radio Controllers Work

Off the shelf, consumer-grade radio controllers are typically unidirectional as far as communication is concerned. Data flows from the radio transmitter to the radio receiver. There are some common exceptions to this rule. For instance, receiver-side battery strength and radio signal strength are often transmitted back to the transmitter for the user to monitor. But, in general, data is one way.

The way the data looks as it flows from transmitter to receiver will vary by manufacturer. There are no standards for consumer grade radio controllers that I’m aware of. If your receiver dies and needs replacing, or you simply want to buy another one so that you can use your transmitter in another project, you’ll need to find a receiver that’s compatible with your transmitter (usually from the same manufacturer).

On the receiver side, each channel will have a separate physical connector that can be used to connect to a servo, flight controller, general purpose microcontroller, Raspberry Pi, etc. The data that leaves these connectors is typically delivered as PWM signals.

PWM

PWM stands for Pulse Width Modulation. The idea behind PWM is that we start with a low voltage and periodically pulse a higher voltage for a specific length of time. The length of this pulse is interpreted to mean something. What that something is depends entirely on your application and what you want it to mean.

I’ll show some code in a bit that allows us to see the range of PWM values emitted from the FS-i6x receiver.

Assigning Channels for the Sound Control

Channels 1-4 on my transmitter are reserved for the sticks, but channels 5 and 6 can be reassigned. I assigned channel 5 to the first toggle switch, which I’ll be using this to trigger audio playback. And I assigned the first knob to channel 6, which I’ll use to control volume.


On the receiver side, I just had to connect the signal pins for channels 5 and 6 to something that can read PWM. I connected the signal pins for these channels to the Arduino Nano’s pins 2 and 3, respectively. Pins 2 and 3 on the Nano can be especially useful here because they’re the only two pins on the Nano that support interrupts. At this point, I don’t actually need to use interrupts. I’m undecided if I’ll use them in the final project.

My connections looks like so.


In addition to the channel connections, both the Arduino and the RC receiver are connected to a 5v power source. That’s what the red and black wires coming off both devices are for.

Now it’s time to see some code. For this test, I wanted to make sure that the Arduino is able to read data from the RC receiver, as well as find out the range of values delivered via PWM. The min and max range will be important to know in the final Arduino sketch for my project. My RC test sketch looks as follows.

void setup() 
{
    pinMode(2, INPUT);
    pinMode(3, INPUT);
 
    // For writing to the Serial Monitor on the PC.
    Serial.begin(9600);   
}
 
void loop() 
{
    int audioTrigger = pulseIn(2, HIGH);
    int volumeKnob = pulseIn(3, HIGH);
 
    Serial.print("Trigger value: ");
    Serial.println(audioTrigger);
    Serial.print("Volume value: ");
    Serial.println(volumeKnob);
    delay(1000);
}

Short and sweet.

In the setup() function, I set the pin mode for pins 2 and 3 to INPUT and then set the baud rate for the serial port to 9600. The serial port is only important here for communication with the Arduino IDE’s Serial Monitor. It’s not needed for anything PWM related.

In the loop() function, I call pulseIn() for each pin. I pass HIGH so that pulseIn() measures the time the signal is high. I could have passed LOW in to measure the opposite, if it made sense to do so. This function returns the number of microseconds (not milliseconds) the pulse is in the specified state. After that, I simply send the values over the serial connection for display.

After uploading the sketch and powering everything up, both channels were initially reporting values in the vicinity of 1 ms (1000 microseconds).

Trigger value: 1002
Volume value: 994

When I toggled switch A on my radio, the trigger value jumped within the vicinity of 2 ms (2000 microseconds).

Trigger value: 1994

Since the trigger switch is either in position A or B, I can probably safely assume that any value less than 1.5ms is one value and any value equal to or higher than 1.5ms is the other.

When I turned the VRA knob on my radio, the volume values also began approaching a maximum value of 2 ms (2000 microseconds).

Volume value: 994
Volume value: 1326
Volume value: 1786
Volume value: 1998

Assuming the range is between 990 and 2000, I can map that to the range of values used to adjust the DFPlayer’s volume.

So now I have all the information I need to put together the final sketch, which we’ll do in the next article.