TDA1514/TDA2040 Surround Sound Amplifier - Mark 3 - Surround Sound Amplifier 2020 Remake

Part 4 - Digital Control and Software

On this page...

Microprocessor

The biggest upgrade for the version 3 remake was to introduce some form of digital control. My goals were to control the power, input, and volume from an Infrared remote control so that I did not have to reach the unit to power it on, select Bluetooth, connect to Bluetooth, and adjust the volume from wherever I am.

This requires a microprocessor to wait for Infrared signals, and control digital outputs to either relays or a motor.

I kept the feedback very minimal in order to avoid a lot of remake of the front of the amplifier - keeping it close to how it was to avoid drilling yet more holes!

I used only two tri-colour LEDs to indicate the status of power (standby/on/muted) or the selected input (Bluetooth/Aux1/Aux2). This meant that I did not require any complex logic to drive an LCD/OLED or LED segment display.

The most complex piece of code is handling the inputs, mostly decoding the IR signals, but also handling button press and rotary encoder turns.

This results in a much smaller amount of compiled code and it easily fits into the program memory of a PIC16F627A (1024 words, 224 bytes SRAM).

Hardware Inputs

There would be three inputs:

  1. Infrared input (allowing power, input select and volume)
  2. Rotary Encoder - for input selection
  3. Power button - for powering on/off with a simple non-latching push-to-make switch

The absence of a volume control input is because the microprocessor doesn't care. The volume level is controlled and shown by the motorised potentiometer that I have and adjustment of the volume on the unit is done simply by turning the knob.

Infrared hardware is easy enough to build. All we need is an Infrared receiver such as a TSOP4838 receiver which contains most of the components needed within it. A 100 ohm resistor in series with the 5V supply, followed by a decoupling capacitor to ground is recommend, and a 10k pull up resistor to the 5V supply.

The digital output of the IR receiver can then be connected to an external interrupt pin on the PIC. For me I picked RB0 because the PIC16F827A only has this as an external interrupt.

The Infrared commands are sent by a universal remote control - the One For All Zapper URC6210. It's intended for TVs, but power, volume up/down, mute and channel up/down (which changes the audio inputs) is perfect for an amplifier!

One For All Zapper URC6210

The audio input selection can also be done at the unit by using a rotary encoder, where turning the encoder clockwise cycles forward through the three inputs and turning anti-clockwise cycles backwards. I thought this would be nicer for me than have three separate buttons for each input.

The schematic I'm showing is for a normal mechanical encoder, but since the breakdown of my original preamplifier project I had a couple of spare optical encoders which are a little larger and mount nicely into the existing holes in the case already made for potentiometers that used to be there. These optical encoders just needed some pull up resistors, and a bypass capacitor for the power supply. I used only one of my encoders in this rebuild.

The final on-unit control is the power button. Again, I had a hole already present from the old rotary mains switch that used to be there so I found a non-latching push-to-make switch that would fit in the same diameter hole as enlarging it would be difficult and making it smaller is impossible.

This switch simply has a connection to RB1 on the PIC microprocessor and a pull up resistor to 5V on one side. The other side connects to ground so that when the switch is pressed, the microprocessor can detect the input changing from 5V to 0V. In software, the microprocessor checks to ensure the switch has been held for at least 700ms before acting - avoiding both switch bouncing and accidental brief presses.

Hardware outputs

The outputs are pretty simple really as all are simple on or off digital outputs. There is no fancy I2C, SPI, RS232, PWM or any other fancy outputs in this build!

That means the outputs are really easy to control, but there are a number of them...

  1. Two outputs control the motor on the potentiometer, for the volume control. That's up or down. Both these outputs can be off and either can be on but never both are the same time!
  2. There are 5 relays controlled by the microprocessor - that's the power relay, the muting relay, and three relays to select the active input
  3. IR LED - this just provides some feedback that IR commands are being received. It also functioned as a debug indicator during my testing!

There are also LEDs on the front of the unit controlled by the microprocessor, but these just reflect the state of the relay output pins.

The volume control is an ALPS motorised potentiometer. A nice 6 channel one that I brought years ago and never used. The motor on it is simple enough to operate and is a simple DC motor that works on 5V where connecting the DC current in one direction makes the motor rotate the potentiometer clockwise, and reversing the current makes it turn anti-clockwise.

The PIC itself cannot provide enough current to drive a motor directly, and it also cannot reverse the current. This is commonly solved by building a H-Bridge. My simple H-Bridge is just four transistors and four resistors.

This simple H-Bridge will work fine so long as the motor it drives is a very small one like the one on my potentiometer that does not generate lots of back-EMF. It's also fine to use if you're confident that the software you will write is never going to switch on both inputs (doing so will make those transistors smoke).

To explain it briefly, say we want to turn the motor in a clockwise direction, the PIC would output '1' (5V) onto port RA3. This turns on Q2, which also turns on Q3, allowing the current to flow through the motor from it's left pin to the right.

For the other direction, RA3 must be output '0' and RA4 can be activated '1'. Q2/Q3 are off, but now Q1 switches on, in turn switching on Q4 and now the current can flow through the motor from right to left, making it turn in the other direction.

Our second type of output are electronic switches called relays. The relays I used operate off 12V, which I obtain from the rectified but unregulated 9V AC. The PIC cannot drive a 12V relay directly, and it even does not have the capability to drive a 5V relay directly so we must connect relays to microprocessors via a transistor and a protection diode. A resistor between the transistor base and the microprocessor is also needed to protect the microprocessor from over-current.

As I have five relays, and two for potential future use - that's a lot of transistors, resistors, and diodes to obtain and layout on the PCB, but fortunately there is a semiconductor which has seven transistors and seven diodes built in. It's part number ULN2003A.

The ULN2003A has seven logic (TTL) compatible inputs that can be directly connected to the PIC. It'll then switch the return side of seven outputs, which is where the relays can be connected.

The IR LED is one output that can be directly driven by the PIC safely. Simply a resistor of 220 ohms in series with the output is all that's needed for a red LED.

The tri-colour input LED is also directly driven by the PIC by being in parallel with the relevant outputs to the ULN2003A. So for example, if my 'Bluetooth' input is selected, RA6 goes high (RB5 and RB4 are low), turning on both the relay connected to output 4C of the ULN2003A and the anode for the blue LED in my tri-colour common-cathode LED.

The power state LED is trickier, and became a bit of an afterthought after I progressed the design and realised I now do not have enough spare ports on the PIC16F627A to drive the three colours required.

Part 2 already describes the transistor logic I came up with to control the three LED colours from just the state of the relay power and mute outputs - that being red if both are off, green if power is on, but unmute is off, and blue if power and unmute are both on. This allows me to wire the inputs to these transistors by taking the signals already going to the ULN2003A (specifically the output of RA1 and RA0).

Software

The software was written in BoostC simply because I'm used to it, and have it, and it works well! Also, because pretty much all the code I need I've already written! C is a higher level Language than the PIC assembler Language and even though I'm sure with time and effort writing this in assembler would result in the smallest and most efficient code, BoostC does a great job.

Using the SourceBoost IDE that comes with BoostC, I wrote the program and compiled and linked it down to a hex file for programming the PIC16F627A. The programming was done with my UsbPicProg programmer.

My source code is available on github.com. Should really cut your work down if you're basically building the exact same thing, but as a hobbyist, DO take it, look at it, learn it, fiddle with it and if you improve it, let me know!

What I'd need to code (or copy/adjust from my other projects) is:

  • Initialisation routine to configure the PIC registers in order to enable/disable and configure the variation peripherals
  • A simple loop with task scheduler
  • Processing of RC5 IR commands via an interrupt routine and decoding and applying the result
  • A power on/off routine
  • Routines to select the appropriate input relay
  • Routines to control the motor on the volume control (which would move the motor for a brief period of time)
  • Routines to monitor and react to the rotary encoder direction turned
  • Routine to monitor for a power button press

There is more information about PIC and BoostC amplifier control at Hi-Fi LM3886 5.1 channel amplifier, with digital control - Control Software, but I'll provide some specifics here too because the PIC used is different and there are a few new things here, and lots of things removed!

Initialisation and Configuration

Before a PIC can run code that you've written, it needs some configuration (done during programming), and initialisation (run in the code).

Configuration involves telling the PIC what speed to run at, how to run at that speed (what oscillator), what protection is in use (watchdog, code protection etc) and so on.

Once a PIC powers up initially or after a reset - you need to tell it what pins are inputs, what are outputs, what speed the timers tick at, what speed to run serial ports at, what interrupts are active and so on. These kinds of settings are done in an init routine because they are something you need to set, but these settings can be changed at runtime later (so are not set during programming).

Configuration registers

The PIC16F627A datasheet has details of what the configuration registers may be set as. This varies per PIC so check the specific datasheet. PIC16F627A only has one CONFIG register.

BoostC can have a set of #pragma variables in the source code, which make it through to the hex file and UsbPicProg detects these and writes the result to the PIC during programming.

//Target PIC16F627A configuration word
pragma DATA _CONFIG, _PWRTE_OFF & _WDT_OFF & _INTOSC_OSC_NOCLKOUT & _CP_OFF & _LVP_OFF & _BOREN_OFF & _MCLRE_OFF

My configuration above is basically setting the bits in each configuration register appropriately. Many of these are defaults, however the keys ones are:

Bit Names Bits Use My setting
FOSC 4, 1-0 Oscillator Selection bits 100 - INTOSC oscillator: I/O function on RA6/OSC2/CLKOUT pin, I/O function on RA7/OSC1/CLKIN
WDTE 2 Watchdog Timer Enable bit 0 - which means it's disabled. Enabling the watchdog resets the PIC if code is unresponsive but prevents delays from running.
!PWRTE 3 Power-up Timer Enable 1 - which means it's disabled
MCLRE 5 MCLR Pin Enable 0 - RA5/MCLR/VPP pin function is set as digital input (MCLR internally tied to VDD)
BOREN 6 Brown-out Reset Enable 0 - BOR Reset disabled
LVP 7 Low-Voltage Programming Enable bit 0 - RB4/PGM is digital I/ (high voltage on MCLR must be used for programming)
!CPD 8 Data Code Protection bit 1 = Data memory code protection off
!CP 13 Flash Program Memory Code Protection bit 1 = Code protection off

The final #pragma tells BoostC what speed the system clock is, so that it gets the delay routines accurate. My PIC16F627A is running off its internal clock generator which is 4MHz.

//Set clock frequency
#pragma CLOCK_FREQ	4000000
Initialisation

Initialisation is called from the main() function (the entry point to running most programs). It is called before the endless loop starts in the main() function.

Its purpose is to set the configuration of everything we need, ports etc. Below is my initialise() function:

void initialise() {
    pcon.OSCF = 1; // 4MHz internal osc

    // Configure port A - all outputs
    // RA0 = Mute Relay
    // RA1 = Power Relay
    // RA2 = IR LED
    // RA3 = Motor Up
    // RA4 = Motor Down
    // RA5 = MCLR (any input)
    // RA6 = Relay Audio In 1
    // RA7 = Spare Relay
    trisa = 0x20;
    porta = 0x00;
    // Configure port B
    // RB0 = IR
    // RB1 = Power Switch
    // RB2 = Standby LED
    // RB3 = Ext Decode Relay
    // RB4 = Relay Audio In 3
    // RB5 = Relay Audio In 2
    // RB6 = Rotary Encoder
    // RB7 = Rotary Encoder
    trisb = 0xC3; // RB0, RB1, RB6, RB7 are inputs
    portb = 0x00;

    option_reg = 0;
    option_reg.NOT_RBPU = 0; // enable pull ups

    // ADC setup
    cmcon = 7; //disable comparators

    // Setup for RB0 Interrupt [IR Data]
    option_reg.INTEDG = 0; // RB0 interrupt should occur on falling edge
    intcon.INTF = 0; // Clear RB0 interrupt flag bit
    intcon.INTE = 1; // RB0 Interrupt enabled (for IR)
    
    // 50ms timer0
    //Timer0 Registers Prescaler= 1:256 - TMR0 Preset = 60 - Freq = 19.93 Hz - Period = 0.050176 seconds
    option_reg.T0CS = 0; // bit 5  TMR0 Clock Source Select bit...0 = Internal Clock (CLKO) 1 = Transition on T0CKI pin
    option_reg.T0SE = 0; // bit 4 TMR0 Source Edge Select bit 0 = low/high 1 = high/low
    option_reg.PSA = 0; // bit 3  Prescaler Assignment bit...0 = Prescaler is assigned to the Timer0
    option_reg.PS2 = 1; // bits 2-0  PS2:PS0: Prescaler Rate Select bits
    option_reg.PS1 = 1;
    option_reg.PS0 = 1;
    tmr0 = 60; // preset for timer register
    intcon.T0IF = 0; // Clear timer 1 interrupt flag bit
    intcon.T0IE = 1; // Timer 1 interrupt enabled

    // Timer 2 setup - interrupt every 890us (0.00088800)
    // 4MHz settings
    t2con = 0x38;  //  0 0111 0 00 - 1:8 postscale, timer off, 1:1 prescale
    pr2 = IR_PR2_890US; // Preload timer2 comparator value - 0.00088800s
    pir1.TMR2IF = 0; // Clear timer 2 interrupt flag bit
    pie1.TMR2IE = 1; // Timer 2 interrupt enabled
      
    // Setup for RB6, RB7 Interrupt [Button press and encoder]
    intcon.RBIF = 0; // Clear Port B change interrupt flag bit
    intcon.RBIE = 1; // Port B change interrupt enabled

    // No task at initialisation
    cPortBPrevious = (portb & 0xC0);
    cTask = 0;
      
    // Start up delay to allow things to settle
    delay_s(1);
    // Ensure ports are still at their defaults
    //porta = 0x00;
    portb = 0x00;

    // Enable all interrupts
    intcon.GIE = 1;
    // Enable all unmasked peripheral interrupts (required for TMR2 interrupt)
    intcon.PEIE = 1;
}

A lot is done here - so let's break it down:

  • PCON OSCF - setting this to true (1) this sets the internal oscillator to a speed 4MHz, otherwise it'll run at a slow 48kHz
  • Ports - the TRIS and PORT registers are set. TRIS registers tell a pin whether it is an input or output. For example, portb has four input pins - RB0, RB1, RB6 and RB7 so it is set to 0xC3 (in hex) or 11000011 in binary. The PORT register can be written to for pins set as output pins or read from for pins set as inputs. At initialisation, I set all outputs to off.
  • OPTION !RBPU - Enable the built in Port B weak pull-ups
  • CMCON - here I setup the comparators, which I don't at all, so they are all switched off and all comparator pins are set to allow digital I/O only
  • RB0 - RB0 is an input connected to the IR sensor. Using INTCON and OPTION we can tell the PIC to raise an interrupt if this input changes. During the IR routine, we also change it later to interrupt on going high, but the initial detection is going low
  • Timer 0 - Here I setup the Timer 0, see below
  • Timer 2 - Here I setup the Timer 2, see below
  • RB6/RB7 interrupt - RB6 and RB7 can raise interrupts if a change is detected. These pins connect to my rotary encoder so I've enabled them for interrupts so I can handle them quickly and appropriately.
  • cPortBPrevious - This holds the state of Port B RB6 and RB7 and is used to test the direction the encoder was moved. I need to set it initially in order to correctly detect the direction of the first move
  • cTask - This is used for my task scheduler in the main() loop. It's set to zero for no task to run
  • I delay a few seconds before setting up the external hardware, just to allow the power supply to stabilise more.
  • INTCON GIE and PEIE - Enable all the global and peripheral interrupts

Remember, this initialisation routine only runs when I first plug the mains cord into the unit. When the amplifier is powered off, the PIC is still running 24x7.

Timers

Four timers are built in to the PIC16F627A: Watchdog, Timer0, Timer1, and Timer2, and I use 2 of them. They run independently of the code, and when running, can cause interrupts when they complete a full count (the counter register overflows).

Timer 0

Timer 0 I use as a delay timer to switch off the volume control motor after it has been activated. It has been configured to count to 50 ms (milliseconds). Multiplied by three, this gives 150ms which works out to be a nice period for the motor to operate only briefly - enough time to turn the volume control to make a small difference in audio volume to the amplifier. Without this delay, the motor would not move if it were switched on and switched off immediately.

The interrupt routine counts how many times timer 0 overflows (50ms) and when the count is greater than 2, it'll take action. That is because the longest delay timer 0 can run to on 4MHz is only 65ms.

I decided to use the timer 0 for this delay because the main routine would then switch on the motor and the timer, and then does not have to worry about turning off the motor because the timer will do that via the interrupt routine. This allows the main program to continue processing any other commands without locking up for 150ms if the delay was used in the main code.

Timer 2

Timer 2 is used for timing pulses on the RC5 Infrared communications. This allows the IR commands to be received via interrupts on both this Timer 2 ticking at just shy of 890 μs (microseconds) and the IR receiver changing state.

A great resource for calculating the setup values for a timer is here - http://eng-serve.com/pic/pic_timer.html. By specifying your clock speed, it gives the values needed to setup the timer to interrupt at a period needed.

The code only turns on the timers when they are needed - i.e. an event occurred, or IR state changed. This is for efficiency and would allow the PIC to sleep too (though I didn't implement that in the end).

I found however, probably due to the inaccuracy of the internal oscillator, and the overhead of the instructions for a PIC operating at only 4 MHz, that the timer 2 had to be configured to count 880 μs instead of 890. At this interval, the deocding of the IR signals worked correctly.

Interrupts

There are two ways of detecting events - polling for them in your code by constantly checking an input, or letting the PIC do the checking for you and firing an interrupt.

Polling is often done, but the PIC and C compiler I'm using is not multi-threaded, so you cannot have one thread doing the poll constantly, whilst another thread does other work.

The alternative is an interrupt routine. This routine basically allows us to say 'an event has happened, stop whatever you are doing immediately and handle it'.

I'm using interrupts in my program to handle both timers overflowing, and handling changes in port B input pin states for IR signals and rotary encoder turns.

The main() function

The main() function is where the code starts running, and mine is pretty simple:

  1. Run some initialise code
  2. Loop infinitely
void main()
{
    initialise();
    
    while (1) {
        // Task scheduler
        // If there are tasks to be performed, find out the
        // most recent task from the array and execute it
        while (cTask > 0) {
            if (cTask.TASK_INT_EXT0) {
                rc5Process(); // IR sensor received a signal
                IR_LED = 0; // Ensure LED is off
                cTask.TASK_INT_EXT0 = 0;
            } else if (cTask.TASK_INT_PORTB) {
                checkEncoders();
                cTask.TASK_INT_PORTB = 0;
            }
        }
        
        // Poll power switch
        checkPowerSw();
    }
} 

Inside the infinite loop, there is a task scheduler which checks the cTask variable for any bits that are set in that 8-bit character. This scheduler will allow up to 8 different tasks, and I use only 2.

The if ... else statement then prioritises those tasks in order of importance. This is so if two interrupts happened at pretty much the same time, the most important one would execute first. This priority being to process IR commands first, followed by processing the rotary encoder changed state. Both are unlikely to happen at the same time, and if they do the priority doesn't really matter what wins in this case.

After the scheduler, a routine to poll the power switch constantly runs. In hindsight, I should have hooked the power switch to the RB5 or RB4 so I could have also used the interrupt on change feature for the power switch too, but I didn't think about the power switch when designing the PCB and it was more of an afterthought!

Now I shall move on to describing how each event is handled in some more detail.

Output communication - LEDs, relays, and a motor

The outputs in this project are really simple - all three types (LEDs, relays, motor) literally just need to be turned on, or off.

The PIC16F627A I used has just enough pins to drive each output without needing I/O saving tricks such as a shift register, so that simplifies the programming.

In my .h (header) file, I've named all pins for convenience and easy adjustment (input pins also listed).

#define POWEROUT (porta.0)
#define IR_PIN (portb.0)
#define IR_LED (portb.2)
#define VOL_UP (porta.4)
#define VOL_DOWN (porta.3)
#define RLY_MUTE (porta.0)
#define RLY_POWER (porta.1)
#define RLY_IN1 (porta.6)
#define RLY_IN2 (portb.5)
#define RLY_IN3 (portb.4)
#define RLY_EXTDECODE (portb.3)
#define SW_PWR (portb.1)

To set a pin, e.g. turn on the power relay:

RLY_POWER = 1;

To turn it off again:

RLY_POWER = 0;

And that's it! RLY_POWER maps to pin 1 on PORTA as defined in my header. You could also put porta.1 = 1 (or 0) too. Throughout, you will see many examples of me setting and clearing outputs.

I turn on or off peripherals using various methods - for example, the power on/off routine only deals with the power relay, but the (un)mute relay is operated both in this same power on/off routine (so the amplifier don't pop), and with a separate mute routine.

The volume control motor too is controlled by turning either one direction of the motor or the other on (never both!), but the interrupt routine when timer 2 finished counting turns both directions off.

Input events - checking for IR commands

The code I found and modified for handling RC5 protocol commands works very well for my main amplifier, so I brought the same remote control and used the same code once again for this amplifier remake.

Therefore most of the info in this section is just a repeat of what I had before, but I have adjusted the code further with the goal of making it work correctly on the PIC16F627A and checking the amount of assembler instructions BoostC generated in the compile process. The C code was adjusted and optimised to generate less instructions than before.

This code again uses the Philips RC5 protocol because it is one of the simplest, well documented, and old enough that modern equipment doesn't really use it (so no conflicts)!

The code is originally from ArduinoTamil which I've adjusted to work with BoostC and the PIC16F627A perfectly.

The code is quite clever, because it is using the interrupt routine to handle the logic high and low states of the IR receiver (toggling the edge detection in each interval), and also the interrupt routine for the timer ticks.

By counting an interval of both the timer, and the logic changes, and ensuring that they are the same, it can detect a valid command. The interval can also be used to determine where in the protocol it is currently processing (start bits, control bit, address, and command).

You can read more about my adjustments to the implementation here.

If the command received is valid, my task scheduler is used to process that command. The interrupt sets the bit TASK_INT_EXT0 in the cTask byte, and the main() function will pick that up and run the rc5Process() function.

/******************************************************
  Read and process remote control RC5 commands
*******************************************************/
void rc5Process() {
    IR_LED = 0; // switch off IR LED
    if (rc5_address != 0) { // Addresses above zero are not for this device
        return;
    }

    // Process commands
    if (iPower) { // Don't process the following if power is off
        // Get current volume level
        switch (rc5_command) {
            // For each command, cause the correct action 
            case 13: // Mute (13 / 0x0D / D)
                if (rc5_flickBitOld != rc5_flickBit) { // Prevent repeated muting when holding the button
                    doMute();
                }
                break;
            case 16: // Volume up (16 / 0x10 / E)
                doMotorUp();
                break;
            case 17: // Volume down (17 / 0x11 / F)
                doMotorDown();
                break;
            case 32: // Input right (32 / 0x20 / V)
                if (rc5_flickBitOld != rc5_flickBit) { // Prevent repeated input changing when holding the button
                    doInputUp();
                }
                break;
            case 33: // Input left (33 / 0x21 / U)
                if (rc5_flickBitOld != rc5_flickBit) { // Prevent repeated input changing when holding the button
                    doInputDown();
                }
                break;
        }
    }
    // Process power button regardless of power state
    if (rc5_command == 12) { // Power (12 / 0x0C / A)
        if (rc5_flickBitOld != rc5_flickBit) // Prevent repeated power when holding the button
            doPower();
    }
}

This function checks the address. If that's not zero, then nothing more happens, and the function exits. Otherwise, the command received via IR is processed instead.

The following commands are processed if the power is on only:

  • Command 13 / 0x0D: Mute - do the mute or unmute process
  • Command 16 / 0x10: Volume up - call a function that will rotor the motor clockwise
  • Command 17 / 0x11: Volume down - call a function that will rotor the motor anti-clockwise
  • Command 32 / 0x20: Input right (channel up) - increment the selected input, then activate the appropriate relays
  • Command 33 / 0x21: Input left (channel down) - decrement the selected input, then activate the appropriate relays

Command 12 / 0x0C for Power is always allowed. When this command is received, the power on and off sequence is executed.

Certain commands are also prevented from repeating if the button on the remote is pressed and held down. These are Power, Mute and changing the Input up/down - we don't want these cycling states rapidly. Volume however increases or decreases continuously whilst the user holds the button down - we don't want the user to have to rapidly mash the volume buttons to adjust the volume quickly!

RC5 protocol provide a flick or toggle bit for this, and by comparing the previous value to the newly received value, if it remains the same, we know the button is being held down.

Input events - checking for rotary encoder increments

The rotary incremental encoder is used to also cycle through the active inputs by turning it clockwise or anti-clockwise. My encoder is a high quality one but as I brought it years ago I'm not sure what it is specifically! It does has detents though and with one small turn it will provide mechanical feedback so the user knows they've made one adjustment.

A change in the encoder state is detected by the PICs interrupt routine, using the Port B on-change interupt. The interrupt() routine will flag that a change has happened using the below code:

    // Port change RB4-RB7
    if (intcon.RBIF) {
        // Have to read portb first, otherwise the interrupt flag sets again
        cPortBCurrent = (portb & 0xC0); // Read the last two bits
        cTask.TASK_INT_PORTB = 1;
        intcon.RBIF = 0;
    }

This small routine is simply detecting the interrupt type (RBIF), storing the last two bits of port B (RB6 and RB7) that the encoder is connected to in the cPortBCurrent variable, flagging the TASK_INT_PORTB bit in cTask and clearing the RBIF flag to tell the PIC we're done processing the interrupt and it can return to where it was in the main code.

The task scheduler in the main() routine while loop is what will process the interrupt. First, understand how the encoder works...

The two encoder outputs with also change their state according to this truth table:

Clockwise SequenceABAnti-clockwise SequenceAB
100100
210201
311311
401410

To detect the direction, we actually need to start with the encoders state and store it. When the state changes, a comparison can be made with the previous state in order to determine the direction.

The routine below is a simple if..else comparison routine that will compare the current state of the encoder pins to the previous state, and then call routines to deal with the new position. At the end it stores the current position into the cPortBPrevious variable so that next time the encoder position changes that variable has the last state for turn direction comparison again.

void checkEncoders() {
    if (cPortBCurrent != cPortBPrevious) { // Check bits 6 and 7
        // Input must have changed
        // Clockwise rotation
        // A = 0, B = 0
        // A = 1, B = 0
        // A = 1, B = 1
        // A = 0, B = 1
        if (iPower) { // Don't process the following if power is off
            switch (cPortBPrevious) {
                case 0x00: // A off B off
                    if (cPortBCurrent == 0x40) { // A on B off
                        // Clockwise
                        doInputUp();
                    } else if (cPortBCurrent == 0x80) { // A off B on
                        // Counter clockwise
                        doInputDown();
                    }
                    break;
                case 0x40: // A on B off
                    if (cPortBCurrent == 0xC0) { // A on B on
                        // Clockwise
                        doInputUp();
                    } else if (cPortBCurrent == 0x00) { // A off B off
                        // Counter clockwise
                        doInputDown();
                    }
                    break;
                case 0xC0: // A on B on
                    if (cPortBCurrent == 0x80) { // A off B on
                        // Clockwise
                        doInputUp();
                    } else if (cPortBCurrent == 0x40) { // A on B off
                        // Counter clockwise
                        doInputDown();
                    }
                    break;
                case 0x80: // A off B on
                    if (cPortBCurrent == 0x00) { // A off B off
                        // Clockwise
                        doInputUp();
                    } else if (cPortBCurrent == 0xC0) { // A on B on
                        // Counter clockwise
                        doInputDown();
                    }
                    break;
            }
        }
        cPortBPrevious = cPortBCurrent;
    }
}

Input events - checking the power button

The last input to handle is the power button. This is a polled input because it does not need to be high priority and the main while loop will check the power switch constantly every time the inner loop executes by calling the checkPowerSw() function (which if there are no other tasks, will be very frequently, about every 5 μs).

This function will check if the switch was pressed (therefore shorting the input to ground), wait 700 milliseconds and check again. If it is still pressed it will call the same doPower() function as the IR remote handling routine.

The 700ms delay is to handle both switch contact bouncing (which would make the amplifier power off and back on rapidly), and accidental pressing so the user has to hold the button a brief time to really turn the amplifier on or off.

void checkPowerSw() {
    // check pin 5 on port b has changed
    if (!SW_PWR) { // Switch pressed, wait 700ms
        delay_ms(700);
        if (!SW_PWR) { // if still pressed, activate power on/off sequence
            doPower();
        }
    }
}

Internal functions - power on/off sequence

Let's look at some functions called by the input functions. These are separate functions because whether the user pressed a button on the remote control or a button (or turned an encoder) on the amplifier front, the processing of that input would be the same.

The first function is doPower(). This is called when the power status changes (either by remote control received or press of the power button on the unit). This is a critical process because it is triggering the relays to turn on mains power to those two toroidal transformers and unmuting the speakers once the power supply and amplifiers have stabilised.

During power on - we activate the correct input relay, wait a brief moment, apply the mains switching relay and then wait 2 seconds until the amplifiers are stable before unmuting the speakers. During power off, we immediately mute the speakers, then power off the amplifiers after a second as well as any selected input relay, and then wait 6 seconds so that the power supply drains. It also prevents rapid power cycling, which could damage the amplifier.

The input relays don't need to be switched off, but since the amplifier is mostly in standby leaving them on consumes unnecessary power, even if it is a little!

// Power on routine
void doPower() {
    if (iPower) {
        // Power off sequence
        RLY_MUTE = 0; // Mute amps        
        iPower = 0;
        delay_s(1); // Force a 1 second wait before powering down the amps
        RLY_POWER = 0; // Power off amps
        // Off input relays
        RLY_IN1 = 0;
        RLY_IN2 = 0;
        RLY_IN3 = 0;
        delay_s(6); // Force a 6 second wait before the ability to switch on again (allows electronics to drain)
    } else {
        // Power on sequence
        applyInput(); // Apply last input - In1 [0] is default
        delay_ms(300);
        RLY_POWER = 1; // Power on amps
        // Delay mute
        delay_s(2);
        iPower = 1;
        RLY_MUTE = 1; // Unmute amps
    }
}

Internal functions - input selection relays

Three functions are used to deal with the inputs. The applyInput() function applies whatever selected input is in the variable iActiveInput to the input relays. This is called whenever the amplifier is powered on or off, or when either the doInputDown() or doInputUp() functions are called which handle the change of iActiveInput variable and are called by either the checkEncoders() or rc5Process() functions.

void applyInput() {
    switch (iActiveInput) {
        case 0:
            RLY_IN1 = 1;
            RLY_IN2 = 0;
            RLY_IN3 = 0;
            break;
        case 1:
            RLY_IN1 = 0;
            RLY_IN2 = 1;
            RLY_IN3 = 0;
            break;
        case 2:
            RLY_IN1 = 0;
            RLY_IN2 = 0;
            RLY_IN3 = 1;
            break;
    }
}

/******************************************************
  Functions to adjust the active input
*******************************************************/
void doInputDown() {
    // Decrement the active input
    iActiveInput--;
    if (iActiveInput > 3) // If overflowed (less than 0)
        iActiveInput = 2;
    applyInput();
}

void doInputUp() {
    // Increment the active input
    iActiveInput++;
    if (iActiveInput >= 3)
        iActiveInput = 0;
    applyInput();
}

Internal functions - volume control motor

The volume of the amplifier is controlled by a motorised potentiometer. The functions to move the motor forwards or backwards is only called by rc5Process() since adjusting the volume manually on the unit is just turning the potentiometer mechanically.

The motor H-bridge circuit I'm using is a two input one to reduce the need for additional PIC ports. The only danger with this is the PIC cannot ever turn on both inputs at the same time because that will short the H bridge transistors by turning them all on together. Therefore, the doMotorUp() and doMotorDown() functions will turn the other direction off first before turning on the direction they are supposed to.

The motor should move for about 150ms. For my amplifier that moves the potentiometer enough to make a small adjustment in volume if the volume up or down buttons on the remote are pressed only once.

This delay is handled by timer 0, so the motor move functions will just reset the timer and start it. The interrupt routine that is called once timer 0 overflows will then turn the motor off.

Because the timer is reset each time, pressing and holding the volume buttons on the remote will keep the motorised volume control moving until the user releases the button (or 150 milliseconds after that).

/******************************************************
  Functions to adjust the volume
*******************************************************/
void doMotorDown() {
    VOL_UP = 0; // Always turn the other direction off
    VOL_DOWN = 1;
    // Reset timer0, then start
    iTmr0Counter = 0;
    tmr0 = 60;
}

void doMotorUp() {
    VOL_DOWN = 0; // Always turn the other direction off
    VOL_UP = 1;
    // Reset timer0, then start
    iTmr0Counter = 0;
    tmr0 = 60;
}

PIC code conclusion

That's pretty much most of the code. The whole lot is less than 500 lines of code. It needs only 557 words of flash/code ROM (little over half the tiny 1024 available), 28 bytes of RAM and runs at a clock speed of 4MHz. Most of the code is reuse and optimised from my main amplifier, and pre-amplifier before that, so I didn't spend more than a few hours putting it together (over the course of two weeks).

The result is the control of my amplifier is convenient and reliable, with ease of use by a simple IR remote control, or on unit controls if the remote is unavailable.

Part 5 - Results and Pictures...

References and more reading:
Source code on github.com
The Audio Pages - Elliott Sound Products
HyperPhysics - Transistor logic gate examples
RoboRemo - Simple H Bridge with 4 transistors