Relay True Bypass Switching Part 4: Smart Switching

This article builds on part 1, part 2, and part 3 of the Relay True Bypass Switching series. If you have not read parts 1, 2, and 3, please do so first.

In this part of our Relay True Bypass Switching article, we are going to be implementing smart switching functionality. In Part 3, we created a software-based solution for relay switching. We will expand on our code in Part 3 to create a more advanced true-bypass switch. The circuit and the programming method we will use are identical to those used in Part 3; we are simply adding additional functionality to the switching program we are using on our ATtiny45 (P-QATTINY45). All code used in this article is licensed under the MIT license. You are free to use it in your pedal designs, commercial or otherwise.

This article requires writing and compiling C code. Click here to download the sample smart switching relay.c file we will be discussing so that you can follow along.

The feature we are adding allows a user to momentarily toggle the effect by holding down the footswitch. If a user holds the footswitch down for a certain amount of time, the effect will remain toggled only for as long as the footswitch is held down. The effect will return to its previous state as soon as the footswitch is released. On the other hand, if the footswitch is briefly pressed (not held down), the effect will toggle as usual.

This is one of the more common “smart” switching features. It is available in well-known pedals and pre-programmed microcontrollers from vendors who sell relay bypass PCBs/modules.

In this part, we will only be updating the code for the ATtiny45 (P-QATTINY45). Our circuit will not change.

Figure 1: ATtiny45-controlled relay circuit

Figure 1: ATtiny45-controlled relay circuit

Because we are using the same circuit as part 3, we will dive right into the new code we have added to our relay.c file.

#include, #define, and Global Variables

#include <avr/io.h>
#include <avr/interrupt.h>
#include <util/delay.h>

#define SETTLE_MS 5
#define DEBOUNCE_MS 75
#define ON 1
#define OFF 0
#define HOLD_THRESHOLD_MS 300

The top of our file is very similar to the way it was in part 3. We have #included one additional file, avr/interrupt.h. This file will allow us to use “interrupts”, which we will use for some timing control. Previously, the only way we could wait for something to happen was by using the _delay_ms function. The _delay_ms function is useful, but it is limited to simply doing nothing for the specified amount of time. For the more complex timing applications we are using in this part, we will use the ATtiny45’s built-in timers.

We have also added one #define term here, HOLD_THRESHOLD_MS. HOLD_THRESHOLD_MS is how many milliseconds the switch must be held for it to be considered a "hold" (the effect is momentarily toggled) rather than a "press" (the effect is toggled normally). You can play around with this number if you want, but 300ms is a good value for presses to not register as holds while also allowing for reasonably short holds. The ideal number may depend on your stomping technique and how short you would need the momentary toggles to be.

uint8_t switch_state = OFF;
uint8_t relay_state = OFF;

volatile uint32_t elapsed_ms = 0;
uint32_t switch_time = 0;

Next we have our global variables. We have added 2 global variables here, elapsed_ms and switch_time. We will cover these in more detail later, but the brief summary is that elapsed_ms counts the number of milliseconds that have elapsed since the ATtiny45 was powered on, and switch_time will store the time when the switch is pressed or released. We’ll use that time to debounce the switch and determine if a switch was quickly pressed or if it was held down.

Both new variables are uint32_t (32-bit unsigned integer) variables. While uint8_t variables can store values from 0 to 255 (28 possible values), uint32_t variables can store values from 0 to 4,294,967,295 (232 possible values). uint32_t variables take up 4 bytes in memory and operations using them require more clock cycles, so they should be used sparingly - particularly when memory or speed is a concern.

Notice that elapsed_ms is also declared as volatile. We will discuss this in more detail later, but a variable must be declared as volatile when the variable is accessed inside normal code and it is also accessed inside an interrupt routine, which is the case for elapsed_ms.

I/O Initialization and On, Off, and Toggle Functions

void led_on(){
    PORTB |= (1 << PB1);
}
void led_off(){
    PORTB &= ~(1 << PB1);
}
void led_toggle(){
    PORTB ^= (1 << PB1);
}

void relay_on(){
    // Set PB3 high and PB2 low
    PORTB |= (1 << PB3);
    PORTB &= ~(1 << PB2);
    // Relay needs time to set
    _delay_ms(SETTLE_MS);
    // Set PB3 low again after the relay is
    // set for current savings
    PORTB &= ~(1 << PB3);
    relay_state = ON;
}
void relay_off(){
    // Set PB2 high and PB3 low
    PORTB |= (1 << PB2);
    PORTB &= ~(1 << PB3);
    // Relay needs time to reset
    _delay_ms(SETTLE_MS);
    // Set PB2 low again after the relay is
    // reset for current savings
    PORTB &= ~(1 << PB2);
    relay_state = OFF;
}
void relay_toggle(){
    if(relay_state == OFF){
        relay_on();
    }
    else{
        relay_off();
    }
}

void io_init(){
    // Set data direction register so that PB0 is an input,
    // PB1-3 are outputs
    DDRB = 0b00001110;

    // Enable the input pullup for PB0
    PORTB = 0b00000001;

    // Set the relay to off state initially
    relay_off();
}

Our io_init function and our relay and LED on, off, and toggle functions remain unchanged from our part 3 code.

Timer Initialization and Interrupts

Now let’s look at our new main function:

int main(void){
    // Initialize inputs and outputs
    io_init();
    // Initialize Timer0 and timer interrupt
    timer_init();
    while(1){
        if(poll_switch()){
            // If state should be toggled:
            relay_toggle();
            led_toggle();
        }
    }
    return 1;
}

We begin again by initializing our inputs and outputs with io_init, just as we did in Part 3. Afterward, we are initializing our timer with a new function named timer_init. The purpose of our timer is to track the amount of milliseconds that have elapsed. To do so, we want our code to be interrupted every 1ms so that we can increment elapsed_ms by 1.

The ATtiny45 has two timers, ~Timer0~ and ~Timer1~, which can be used for different tasks. We only need one timer for our purposes, and we will be using ~Timer0~.

void timer_init(){
    // Enable global interrupts
    sei();
    //set clock prescaler to CK/8, 8us per tick
    TCCR0B |= (1 << CS01);
    // Interrupt every 125 ticks which is every 1ms (125*8us = 1ms)
    OCR0A = 125;
    // Set the starting tick count
    TCNT0 = 0;
    // Enable timer interrupt
    TIMSK |= (1 << OCIE0A);
}

In the first non-comment line we are calling the function sei()which is defined in the avr/interrupt.h file we included. One of the ATtiny45’s registers, the AVR Status Register (~SREG~), has a Global Interrupt Enable bit. When this bit is high (1), interrupts can occur on the ATtiny45. When this bit is low, no interrupts will occur. The function sei() simply sets this bit high for us so that interrupts are enabled. Similarly, the function cli() which we are not using in timer_init, will set the Global Interrupt Enable bit low to disable interrupts.

Timer/Counter Control

On the next line, we are modifying the Timer/Counter Control Register B for ~Timer0~ (TCCR0B).

Table 1: ATtiny45 Timer/Counter Control Register B for ~Timer0~ (TCCR0B)
Bit/Pin #76543210
Bit NameFOC0AFOC0B--WGM02CS02CS01CS00
Read/WriteWWRRR/WR/WR/WR/W
Initial State00000000

We will only be looking at the ~Timer0~ bits and registers which are relevant to our code, but the full rundown on the ~Timer0~ registers can be found in section 11.9 of the ATtiny45 datasheet if you would like to learn more.

In ~TCCR0B~, the bits we are interested in are bits ~CS02~, ~CS01~, and ~CS00~. These are the Clock Select bits. The clock source for ~Timer0~ will be determined by what is written into these 3 bits.

Table 2: Clock source determined by Clock Select bits
CS02CS01CS00Description
000No clock source (Timer/Counter stopped)
001System clock
010System clock / 8
011System clock / 64
100System clock / 256
101System clock / 1024
110External clock on pin PB2, falling edge
111External clock on pin PB2, rising edge

We are not using an external clock, so we’ll ignore the bottom two options. We need a clock source, so we will ignore the first option. The other options use the system clock optionally divided by a value. There are multiple options that we could make work here. We are using ~010~, which is “System Clock / 8”. The ATtiny45’s clock speed, by default, is running at 1MHz, which makes

$$\text{System Clock} / 8 = 125kHz$$
void timer_init(){
    // Enable global interrupts
    sei();
    //set clock prescaler to CK/8, 8us per tick
    TCCR0B |= (1 << CS01);
    // Interrupt every 125 ticks which is every 1ms (125*8us = 1ms)
    OCR0A = 125;
    // Set the starting tick count
    TCNT0 = 0;
    // Enable timer interrupt
    TIMSK |= (1 << OCIE0A);
}

All of the bits in TCCR0B are 0 in their initial state, and we are not modifying any bits besides the ~CS~ bits, so we set the ~CS~ values to ~010~ by writing a 1 to ~CS01~ with TCCR0B |= (1 << CS01);.

Output Compare Match Interrupt

Note that a clock speed of 125kHz has a period of 8 microseconds (~1/\text{125,000}~). That means that there is one clock “tick” every 8 microseconds. Remember that we want our code to be interrupted every 1 ms. We can set up the ATtiny so that it is interrupted after a certain number of clock ticks. To interrupt every 1ms, we need to interrupt our code after 125 ticks from the 125kHz timer clock have passed, since ~8μs*125 = 1ms~.

In order to accomplish this, we will use a particular type of interrupt called an Output Compare Match Interrupt. There is a Timer/Counter Register for ~Timer0~ (TCNT0) which is incremented on every cycle of the ~Timer0~ clock. Every 8μs, the value in TCNT0 will increase by 1. There are also two Output Compare Registers which we can write to, OCR0A and OCR0B. The Output Compare Match Interrupt causes an interrupt to occur whenever the value in TCNT0 matches the value of OCR0A and/or OCR0B, depending on which Output Compare Match Interrupt is enabled (A, B, or both).

We will be using Output Compare Match A. By writing a value of 125 into Output Compare Register A for ~Timer0~ (OCR0A), we will force the ATtiny45 to interrupt when TCNT0 reaches 125 if the Output Compare Match A Interrupt is enabled properly. If TCNT0 starts from 0, it will reach a value of 125 after 125 8μs clock ticks, or every 1ms.

void timer_init(){
    // Enable global interrupts
    sei();
    //set clock prescaler to CK/8, 8us per tick
    TCCR0B |= (1 << CS01);
    // Interrupt every 125 ticks which is every 1ms (125*8us = 1ms)
    OCR0A = 125;
    // Set the starting tick count
    TCNT0 = 0;
    // Enable timer interrupt
    TIMSK |= (1 << OCIE0A);
}

To set our Output Compare Match A value to 125 and cause an interrupt to occur when TCNT0 reaches 125, we simply write 125 into the OCR0A register with the line OCR0A = 125;.

On the next non-comment line, we set the Timer/Counter Register for ~Timer0~ (TCNT0) to a value of 0 so that it has to increment 125 times (1ms) to reach a value of 125 with TCNT0 = 0;.

Table 3: ATtiny45 Timer/Counter Interrupt Mask Register (~TIMSK~)
Bit/Pin #76543210
Bit Name-OCIE1AOCIE1BOCIE0AOCIE0BTOIE1TOIE0-
Read/WriteRR/WR/WR/WR/WR/WR/WR
Initial State00000000

The ~TIMSK~ register has bits to enable Output Compare Match A and B for ~Timer0~, Output Compare Match A and B for ~Timer1~, and an additional interrupt method which we will not be using called Timer Overflow Interrupt. We are only using Output Compare Match A for ~Timer0~ in our program, which is bit ~OCIE0A~ in ~TIMSK~. The interrupts in ~TIMSK~ are enabled by writing a 1 to the bit, so we set the ~OCIE0A~ bit high with TIMSK |= (1 << OCIE0A);.

After this line of code, the timer interrupt is set up properly and our code will be interrupted every 1ms.

Interrupt Service Routine

So what happens when our code is interrupted? Regardless of what the ATtiny45 is currently doing - whether it’s polling the switch state, toggling the relay, or doing anything else - it will pause its current execution and run what’s called an Interrupt Service Routine (ISR) before returning to what it was doing before the ISR was executed.

To define an ISR for any interrupt on the ATtiny we use the function ISR() with no return type, with the interrupt vector name as the parameter. A list of valid vector names can be found in the large table on the Interrupts page of the AVR-GCC C Library manual.

We are only using the Timer/Counter0 Compare Match A interrupt. According to the manual, the vector name for this interrupt is TIM0_COMPA_vect. The ISR we define should take the form:

ISR(TIM0_COMPA_vect){
    // Interrupt routine here
}

Any interrupt routine can be defined in the above format with TIM0_COMPA_vect replaced with the vector name of the ATtiny45’s interrupt. However, the ISR will not run unless the interrupt has been enabled like we did with Output Compare Match A for Timer0.

Note that ISR() is technically a macro. Macros are similar to functions but not quite the same. The difference is not important to our code as long as you know how to implement the ISR.

The Interrupt Service Routine we are implementing for the Timer/Counter0 Compare Match A interrupt is very simple. It is generally recommended that ISRs be kept as short as possible:

ISR(TIM0_COMPA_vect){
    // Increment elapsed_ms by 1
    elapsed_ms += 1;
    // Reset the Timer0 count
    TCNT0 = 0;
}

All we are doing is incrementing elapsed_ms by 1 to track how many milliseconds have passed and setting TCNT0 to 0. By setting TCNT0 back to 0, it will need to be incremented 125 times again before the next interrupt.

Why is elapsed_ms volatile?

The reason that we declared elapsed_ms as a volatile variable is that we are accessing it both here in the ISR and in the poll_switch function. We can predict when this interrupt will happen since it is on a set timer, but that is often not the case with ISRs. Sometimes they are triggered by an event which can happen at any time, like a pin’s input changing. When we access elapsed_ms inside poll_switch, our compiler might optimize that code and not read elapsed_ms from memory. It does this because it usually knows if elapsed_ms has not changed in memory since the last read and does not need to be read again. The problem is that interrupts can happen at any time, so the compiler cannot predict when a variable like elapsed_ms will be changed by the ISR. The volatile keyword tells the compiler that the elapsed_ms variable may change at any time, so when the variable is accessed inside our poll_switch function it will be read from memory even if the compiler does not expect it to have changed. Had elapsed_ms not been declared volatile, it’s possible that it would contain the incorrect value when we try to access it inside of our poll_switch function.

New Switch Polling Function

Now that our timer is set up and we are properly counting the elapsed milliseconds, let’s look at the rest of our main function.

int main(void){
    // Initialize inputs and outputs
    io_init();
    // Initialize Timer0 and timer interrupt
    timer_init();
    while(1){
        if(poll_switch()){
            // If state should be toggled:
            relay_toggle();
            led_toggle();
        }
    }
    return 1;
}

After the initialization of our I/O and our timer, we enter an infinite while loop. Inside of this while loop, we have an if statement with a function as its condition. Every time our code reaches this if statement, the condition function poll_switch is executed. If poll_switch returns a non-zero value, the code inside the if statement is executed, toggling the relay and LED. If poll_switch returns 0, the if statement contents are skipped.

We want poll_switch to return a non-zero value whenever our effect should be toggled. Let’s look at the new poll_switch code:

uint8_t poll_switch(){
    cli();   // Disable interrupts
    // While interrupts are disabled, store the
    // current elapsed_ms value in a new variable:
    uint32_t now_time = elapsed_ms;
    sei();   // Re-enable interrupts
    if(!(PINB & 0b1)){
        // If switch is pressed
        if((switch_state == OFF) && (now_time - switch_time) > DEBOUNCE_MS){
            // If the switch was previously not pressed and DEBOUNCE_MS
            // have passed since last release
            switch_time = now_time;
            switch_state = ON;
            return 1;
        }
        else{
            // If the switch was already pressed (not a rising edge)
            // or DEBOUNCE_MS have not passed
            return 0;
        }
    }
    else{
        // If switch is not pressed
        if((switch_state == ON) && (now_time - switch_time) > DEBOUNCE_MS){
            // If the switch was previously pressed and DEBOUNCE_MS
            // have passed since last press
            switch_state = OFF;
            if(now_time - switch_time > HOLD_THRESHOLD_MS){
                // If the switch was held down for HOLD_THRESHOLD_MS
                // rather than just quickly pressed, we return 1
                // to toggle back to original state
                switch_time = now_time;
                return 1;
            }
            switch_time = now_time;
        }
        return 0;
    }
}

Accessing volatile Variable elapsed_ms

At the beginning of our poll_switch function we have the following commands:

    cli();   // Disable interrupts
    // While interrupts are disabled, store the
    // current elapsed_ms value in a new variable:
    uint32_t now_time = elapsed_ms;
    sei();   // Re-enable interrupts

We mentioned previously that cli() disables interrupts globally. After cli() is called, no interrupts will occur until sei() is called. All we are doing before re-enabling interrupts is creating a variable now_time and assigning the current elapsed_ms value to it. Accessing the elapsed_ms variable in any way will take more than one clock cycle. Because elapsed_ms is modified inside the ISR, we must disable interrupts when accessing it outside the ISR. If we do not disable interrupts while accessing elapsed_ms, it is possible for the ATtiny to partially read elapsed_ms, jump to our ISR and modify elapsed_ms in that routine, return from the ISR and continue reading the rest of elapsed_ms. The result of this could be that the value stored in now_time is neither the value of elapsed_ms before the ISR nor the value of elapsed_ms after the ISR, but something entirely different.

The result would be that sometimes our switch is not debounced properly or a switch “press” incorrectly appears to be a “hold” or vice-versa, since our elapsed_ms value is not reliable if interrupts are not disabled. The downside of disabling interrupts while accessing elapsed_ms is that our interrupt may sometimes get delayed a few clock cycles if TCNT0 reaches 125 while interrupts are disabled. Note that we would never miss an interrupt entirely if TCNT0 reaches 125 while interrupts are disabled. The interrupt is simply delayed until we enable the interrupts again with sei(). There are ways we could attempt to further mitigate the impact of these small delays if the timing in our application was extremely critical, but these small timing discrepancies are negligible in our application, so we will avoid the added complexity.

Now that we have stored elapsed_ms in the now_time variable, we will be using now_time for the rest of the poll_switch function. Since now_time is not modified inside the ISR, we no longer have to worry about disabling interrupts to look at the current time.

Handling a Switch Press

In Part 3, we read the state of the switch into a variable and then checked whether or not the switch was pressed with an if/else statement. Below is part of the code from Part 3:

uint8_t current_switch_state = !(PINB & 0b1);
if(current_switch_state){

Here we will skip the step where we store the switch’s state in a variable and simply use the result of !(PINB & 0b1); as the condition of our if/else statement, which works the same:

if(!(PINB & 0b1)){
    // If switch is pressed
    if((switch_state == OFF) && (now_time - switch_time) > DEBOUNCE_MS){
        // If the switch was previously not pressed and DEBOUNCE_MS
        // have passed since last release
        switch_time = now_time;
        switch_state = ON;
        return 1;
    }
    else{
        // If the switch was already pressed (not a rising edge)
        // or DEBOUNCE_MS have not passed
        return 0;
    }
}

If the switch is pressed, we then check for two more conditions in another if/else statement.

if((switch_state == OFF) && (now_time - switch_time) > DEBOUNCE_MS){

We are using the && (logical AND) operator to do so. A && operator is used on two operands. If both operands are true (non-zero), the result is true (non-zero). Otherwise the result is false (zero). In order for the contents of this if statement to run, the switch_state variable must have been OFF, which indicates that this is a rising edge (since we just read that the switch is pressed), and (now_time - switch_time) must be greater than DEBOUNCE_MS. We are storing the last time the switch was pressed or released in the switch_time variable. (now_time - switch_time) > DEBOUNCE_MS will evaluate to true if DEBOUNCE_MS (75ms) have passed since the last time the switch was pressed or released. This is how we are debouncing the switch in this program, rather than simply using _delay_ms() and waiting 75ms, we are using the timer to determine if that amount of time has passed.

If this switch press is a rising edge and we are past the 75ms bounce window, we consider this a valid switch press and the code inside the if statement is executed.

        switch_time = now_time;
        switch_state = ON;
        return 1;

We store the current time in the switch_time variable, set the switch_state variable to ON, and return 1. Remember that when poll_switch returns a non-zero value, we are toggling the LED and relay inside of our main function. Here is the relevant code from main again:

        if(poll_switch()){
            relay_toggle();
            led_toggle();
        }

If the switch press is not a rising edge or if less than 75ms have passed since the last time it was released, all we are doing is returning 0, which indicates that the switch should not be toggled.

        else{
            // If the switch was already pressed (not a rising edge)
            // or DEBOUNCE_MS have not passed
            return 0;
        }

Handling a Switch Release

Now let’s look at the else section that will run if !(PINB & 0b1) evaluates to 0 (the switch is not pressed).

else{
    // If switch is not pressed
    if((switch_state == ON) && (now_time - switch_time) > DEBOUNCE_MS){
        // If the switch was previously pressed and DEBOUNCE_MS
        // have passed since last press
        switch_state = OFF;
        if((now_time - switch_time) > HOLD_THRESHOLD_MS){
            // If the switch was held down for HOLD_THRESHOLD_MS
            // rather than just quickly pressed, we return 1
            // to toggle back to original state
            switch_time = now_time;
            return 1;
        }
        switch_time = now_time;
    }
    return 0;
}

Inside this else statement we have a similar nested if statement. In this case, we are checking for a falling edge rather than a rising edge, and checking to see if 75ms have passed since the previous press so we know the switch is not bouncing.

If those conditions are true, this is a valid switch release. We set the switch_state to OFF. At this point, we check to see if the release is 300ms (HOLD_THESHOLD_MS) or more after our switch press:

        if((now_time - switch_time) > HOLD_THRESHOLD_MS){
            // If the switch was held down for HOLD_THRESHOLD_MS
            // rather than just quickly pressed, we return 1
            // to toggle back to original state
            switch_time = now_time;
            return 1;
        }

If 300ms have passed, we consider the switch to have been held rather than pressed, so instead of doing nothing on the release we return 1, which will result in the switch toggling back to the state it was in before it was originally pressed. Before returning, we need to set the switch_time to now_time to keep track of when this release happened.

If the switch was not held, we still set switch_time to now_time, exit the if statement, and return 0 so that the effect does not toggle back on release.

        switch_time = now_time;
    }
    return 0;
}

The Full Smart Relay Switching Code

Now that everything has been covered, here is the code in its entirety.

You can also download the sample smart switching relay.c file directly.

#include <avr/io.h>
#include <avr/interrupt.h>
#include <util/delay.h>

#define SETTLE_MS 5
#define DEBOUNCE_MS 75
#define ON 1
#define OFF 0
#define HOLD_THRESHOLD_MS 300

uint8_t switch_state = OFF;
uint8_t relay_state = OFF;

volatile uint32_t elapsed_ms = 0;
uint32_t switch_time = 0;

ISR(TIM0_COMPA_vect){
    // Increment elapsed_ms by 1
    elapsed_ms += 1;
    // Reset the Timer0 count
    TCNT0 = 0;
}

void led_on(){
    PORTB |= (1 << PB1);
}
void led_off(){
    PORTB &= ~(1 << PB1);
}
void led_toggle(){
    PORTB ^= (1 << PB1);
}

void relay_on(){
    // Set PB3 high and PB2 low
    PORTB |= (1 << PB3);
    PORTB &= ~(1 << PB2);
    // Relay needs time to set
    _delay_ms(SETTLE_MS);
    // Set PB3 low again after the relay is
    // set for current savings
    PORTB &= ~(1 << PB3);
    relay_state = ON;
}

void relay_off(){
    // Set PB2 high and PB3 low
    PORTB |= (1 << PB2);
    PORTB &= ~(1 << PB3);
    // Relay needs time to reset
    _delay_ms(SETTLE_MS);
    // Set PB2 low again after the relay is
    // reset for current savings
    PORTB &= ~(1 << PB2);
    relay_state = OFF;
}

void relay_toggle(){
    if(relay_state == OFF){
        relay_on();
    }
    else{
        relay_off();
    }
}

void io_init(){
    // Set data direction register so that PB0 is an input,
    // PB1-3 are outputs
    DDRB = 0b00001110;

    // Enable the input pullup for PB0
    PORTB = 0b00000001;

    // Set the relay to off state initially
    relay_off();
}

void timer_init(){
    // Enable global interrupts
    sei();
    //set clock prescaler to CK/8, 8us per tick
    TCCR0B |= (1 << CS01);
    // Interrupt every 125 ticks which is every 1ms (125*8us = 1ms)
    OCR0A = 125;
    // Set the starting tick count
    TCNT0 = 0;
    // Enable timer interrupt
    TIMSK |= (1 << OCIE0A);
}

uint8_t poll_switch(){
    cli();   // Disable interrupts
    // While interrupts are disabled, store the
    // current elapsed_ms value in a new variable:
    uint32_t now_time = elapsed_ms;
    sei();   // Re-enable interrupts
    if(!(PINB & 0b1)){
        // If switch is pressed
        if((switch_state == OFF) && (now_time - switch_time) > DEBOUNCE_MS){
            // If the switch was previously not pressed and DEBOUNCE_MS
            // have passed since last release
            switch_time = now_time;
            switch_state = ON;
            return 1;
        }
        else{
            // If the switch was already pressed (not a rising edge)
            // or DEBOUNCE_MS have not passed
            return 0;
        }
    }
    else{
        // If switch is not pressed
        if((switch_state == ON) && (now_time - switch_time) > DEBOUNCE_MS){
            // If the switch was previously pressed and DEBOUNCE_MS
            // have passed since last press
            switch_state = OFF;
            if(now_time - switch_time > HOLD_THRESHOLD_MS){
                // If the switch was held down for HOLD_THRESHOLD_MS
                // rather than just quickly pressed, we return 1
                // to toggle back to original state
                switch_time = now_time;
                return 1;
            }
            switch_time = now_time;
        }
        return 0;
    }
}

int main(void){
    // Initialize inputs and outputs
    io_init();
    // Initialize Timer0 and timer interrupt
    timer_init();
    while(1){
        if(poll_switch()){
            // If state should be toggled:
            relay_toggle();
            led_toggle();
        }
    }
    return 1;
}

To see the smart relay switching in action, you can program the ATtiny45 using the same method as described in the “Compiling and Programming the ATtiny45” section of Part 3.

Microcontrollers have many applications in audio contexts, and it is a very deep topic. The code here is relatively simple compared to what you would find in most audio applications, but it is still a lot to decipher if you are new to microcontrollers and especially if you are a beginner at C programming. If you had trouble following along, you may want to try building a few basic microcontroller circuits first to get the core concepts down before moving on to subjects like interrupts and timers. There are many great AVR tutorials out there which will help get you started. If you are looking for something simpler, Arduino would be a gentler introduction than programming AVR chips directly. The Arduino libraries remove most of the need to write bits into registers directly and replace that requirement with more immediately intuitive code. Arduino-based projects are very popular among hobbyists, but there are advantages to using plain AVR chips without the extra overhead, and they are more fitting for a professional product.

Like Part 3, you don’t need to understand how all the code works to use it, but we hope that this article gives you an idea of what you can do with microcontrollers in an audio context and inspires you to modify or create your own design, whether it’s a more advanced smart switching circuit or a complex audio effect.

Note that the information presented in this article is for reference purposes only. Amplified Parts makes no claims, promises, or guarantees about the accuracy, completeness, or adequacy of the contents of this article, and expressly disclaims liability for errors or omissions on the part of the author. No warranty of any kind, implied, expressed, or statutory, including but not limited to the warranties of non-infringement of third party rights, title, merchantability, or fitness for a particular purpose, is given with respect to the contents of this article or its links to other resources.