TDA7439/STA540 Amplifier - a small yet high quality amplifier

Part 2 - Software

The software does the main work in this amplifier build. Only one microcontroller is used to control the whole project - the PIC16F873.

The software needs to do the following:

  • Interface with the TDA7439 via the I2C/TWI bus. The TDA7439 is a receiver/follower device only that will receive bytes from the microcontroller so set the input, volume, balance, bass, mid and treble
  • Interface with the MAX7219 using a like SPI serial bus. This will display the active input, volume level and functions for bass, mid, treble and balance when needed
  • Handle the RC5 protocol IR commands via the TSOP4838 infrared receiver
  • Power on/off the amplifier and TDA7439 processor
  • Power on/off the Bluetooth module
  • Save settings periodically to the EEPROM (if changed)
  • Display standby/mute/on status on the RGB LED

All the software has been written in C, specifically BoostC from SourceBoost and it's just over 1000 lines of code. The compiled and assembled program fits in 1950 words, so easily within the PIC16F873 ROM. It also fits just within the limit of the free license of BoostC.

A lot of functions were also reused from my previous projects, such as the MAX7219 code and the IR code. I did make some improvements in efficiency though.

Although writing the assembler directly could have reduced the size further and efficiency, it's a challenging task for a hobby programmer and I'm happier with how much easier it is to write in C and use a compiler to produce the machine code required.

The PIC was programmed using my UsbPicProg.

Update: Software updated. This is to fix a minor issue with not being able to force the amplifier to switch off via remote if the TV is on, and also adding a PNP transistor switch to power on/off the MAX7219 display. MAX7219 bit bang loop has also been optimised.

My source code is available on github.com. Snippets are also below with explanations...

Initialisation and Configuration

Before a PIC can run the code, it needs configuration (written during programming), and initialisation (read once running 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 interrupts are active, the timer pre/post scalers 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 PIC16F873 datasheet has details of what the configuration registers set. This varies per PIC so check the specific datasheet.

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.

The PIC16F873 actually has just one CONFIG register

// Configuration registers - PIC16F873
#pragma DATA _CONFIG, _BODEN_OFF & _PWRTE_OFF & _WDT_OFF & _CP_OFF & _XT_OSC // Brown out reset off, Power-up Timer off, Watchdog timer off, Code Protection off, XT oscillator

// Set clock frequency - 4MHz Crystal
#pragma CLOCK_FREQ  4000000

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 1-0 Oscillator Selection bits 01 - XT oscillator Crystal/Resonator
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
BOREN 6 Brown-out Reset Enable 0 - BOR Reset disabled
LVP 7 Low-Voltage Programming Enable bit 0 - RB4/PGM is digital I/O (high voltage on MCLR must be used for programming)
!CPD 8 Data Code Protection bit 1 = Data memory code protection off
!CP 13-12 Flash Program Memory Code Protection bit 11 = Code protection off

The final #pragma tells BoostC what speed the system clock is (it does NOT tell the microchip what speed to run at), so that it gets the delay routines correct. My PIC16F873 is running off a 4MHz crystal.

Initialisation

Initialisation is called from the main() method (the entry point to running most programs). The initialise() function is called before the endless loop starts in the main() method. Its purpose is to set the configuration of everything we need, ports etc. Below is my method:

There's a lot done here - so breaking it down:

  • Ports - the TRIS and PORT registers are set. TRIS registers tell a pin whether it is an input or output. For example, portb has two input pins - RB0 and RB2 so it is set to 0x05 (in hex) or 00000101 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 except RB3.
  • OPTION !RBPU - Enable the built in Port B weak pull-ups
  • ADCON0 and ADCON1 - set to 0x00 and 0x0F respectively to ensure the ADC (analogue to digital converter) is disabled to allow digital I/0
  • Timer 1 - Here I setup the Timer 1, see below
  • Timer 2 - Here I setup the Timer 2, again see below
  • RB0 interrupt - 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
  • MSSP I2C - Here I setup the MSSP to perform hardware I2C communication.
  • readData() - this reads all the saved variables from the EEPROM
  • I delay a second before setting up the external hardware, just to allow the power supply to stabilise.
  • Then the standby LED is lit, and the code moves on to initialising the MAX7219 with a 2 second test
  • INTCON GIE and PEIE - Finally enable all the global and peripheral interrupts
  • cTask - This is used for my task scheduler in the main() loop. It's set to zero for no task to run
/***********************************************************************************
  Function called once only to initialise variables and setup the PIC registers
************************************************************************************/
void initialise() {
    // IO ports setup
    trisa = 0x00; // all ouptuts
    porta = 0x02; // set to off except LEDLATCH
    //porta = 0x00; // set to off
    trisb = 0x05; // RB0, RB2 are inputs
    portb = 0x08; // set to off, except LEDDISPON (RB3)
    trisc = 0x18; // RC4[SDA] and RC3[SCL] are inputs (for I2C), rest outputs
    portc = 0x18; // set to off, except RC4, RC3 for I2C

    option_reg.NOT_RBPU = 1; // Port B pull-ups disabled
    
    // ADC setup
    adcon0 = 0x00; //  ADC off
    adcon1 = 0x07; // All digital I/O
    
    // Timer calculator: http://eng-serve.com/pic/pic_timer.html
    // Timer 1 setup - interrupt every 131ms seconds 4MHz
    // Timer1 Registers Prescaler= 2 - TMR1 Preset = 0 - Freq = 7.63 Hz - Period = 0.131072 seconds
    t1con.T1CKPS1 = 0;   // bits 5-4  Prescaler Rate Select bits
    t1con.T1CKPS0 = 1;   // bit 4
    t1con.T1OSCEN = 0;   // bit 3 Timer1 Oscillator Enable Control bit 1 = off
    t1con.NOT_T1SYNC = 1;// bit 2 Timer1 External Clock Input Synchronization Control bit...1 = Do not synchronize external clock input
    t1con.TMR1CS = 0;    // bit 1 Timer1 Clock Source Select bit...0 = Internal clock (FOSC/4)
    t1con.TMR1ON = 0;    // bit 0 enables timer
    tmr1h = 0;           // preset for timer1 MSB register
    tmr1l = 0;           // preset for timer1 LSB register

    pie1.TMR1IE = 1; // Timer 1 interrupt
    pir1.TMR1IF = 0; // Clear timer 1 interrupt flag bit

    // Timer 2 setup - interrupt every 890us (0.00088800)
    // 4MHz settings
    // Timer2 Registers Prescaler= 1 - TMR2 PostScaler = 8 - PR2 = 110 - Freq = 1136.36 Hz - Period = 0.000880 seconds
    t2con |= 56;       // bits 6-3 Post scaler 1:1 thru 1:16
    t2con.TMR2ON = 0;  // bit 2 turn timer2 off;
    t2con.T2CKPS1 = 0; // bits 1-0  Prescaler Rate Select bits
    t2con.T2CKPS0 = 0;
    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 RB0 Interrupt [IR Sensor]
    intcon.INTE = 1; // RB0 Interrupt (for IR receive)
    option_reg.INTEDG = 0; // RB0 interrupt should occur on falling edge
    intcon.INTF = 0; // Clear RB0 interrupt flag bit
    
    // Initialise I2C MSSP
    // Master 100KHz
    // TRISC set SCL and SDA pins as inputs above
    // SSPEN (I2C), CKP (clock polarity), SSPM3:SSPM0 -> 1000 = I2C Master mode, 
    sspcon = 0x38; // I2C enabled (SSPEN), Master mode
    sspcon2 = 0x00;
    sspadd = 9; // Clock 100Khz @ 4Mhz Fosc - 4000000 / (4 * (9 + 1))
    sspstat.SMP = 1; // Slew rate disabled
    sspstat.CKE = 0;
    pir2.BCLIF = 0;
    
    // Read in variables from EEPROM
    readData(); 

    delay_s(1);
    RED = 1; // Standby LED

    // Setup and test the MAX7219 8 digit display
    // v1.2 change this to allow for display on pin
    ledOn();
    ledTest();
    delay_s(2);
    // v1.2 clear not needed before off
    ledOff();

    // Interrupt setup
    intcon.PEIE = 1; // Enables all unmasked peripheral interrupts (required for RS232 and I2C)

    // Enable global interrupts
    intcon.GIE = 1;

    // No task at initialisation
    cTask = 0;
}

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 1

Timer 1 is used as a timer to identify if the mute button is being held (in order to enter function mode). It's been set with a simple prescaler to reach an interrupt time of 131ms. A time of 100ms to 150ms feels responsive enough to a button press and 131ms was chosen because it's the next simplest time period after an RC5 repeat message time of 114ms.

When the button is held, the timer is reset on each subsequent command, so that the timer 1 does not interrupt.

Timer 1 is only switched on after pressing the mute button. If the button is pressed briefly, it is switched off again. If held beyond 1.5 seconds, it is also switched off after that period.

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, due to the overhead of the instructions for a PIC running at only 4 MHz, that the timer 2 had to be configured to count 872 μs instead of 890. At this interval, the decoding 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 detect the event and fire an interrupt routine to deal with the event.

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 at once and handle it'.

My program uses interrupts for:

  • External portb change interrupt [intcon.INTF] - this is used for infrared signal decoding
  • Timer 2 overflow interrupt [pir1.TMR2IF] - this is also used for infrared signal decoding
  • Timer 1 overflow interrupt [pir1.TMR1IF] - this is used for counting how long the mute IR button was held

Details of the interrupt service routine for each of these covered further below.

Other interrupts are possible, but not required.

The main() loop and polling

The main() method has an endless while loop in it, and within that is both a task scheduler and input polling.

The task scheduler is there to pick up and process any events that have been flagged as part of the interrupt routine. It's better to process events here so that any executing code is not blocking further interrupts. Not so important in this project as the interrupts are not time critical but it's good practice and I always code a task scheduler like this to handle events.

There's also one piece of polling, mainly because the priority is not high (and I also forgot that interrupt on change is only available on RB4 to RB7 and my circuit used RB2). An optocoupler connected to portb pin 2 (RB2) will go high when my external appliance (TV) is powered on, and low when it is powered off. It's state is checked constantly in the main() loop.

If the RB2 input goes from high to low, and the power is off, the amplifier will power on and a flag (iPowerExternal) will be set so the system knows it's been powered on by the external trigger.

If the amplifier is powered on, and it was powered on by the external trigger and the RB2 input goes high again then it will power off.

/***********************************************************************************
  MAIN PROGRAM
************************************************************************************/
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
                cTask.TASK_INT_EXT0 = 0;
            } 
            if (cTask.TASK_TIMER1_MUTE) {
                // Mute and update display
                doMute();
                showVolAndInput();
                // turn off the timer
                timer1Reset();
                cTask.TASK_TIMER1_MUTE = 0;
            }
            if (cTask.TASK_TIMER1_FUNC) {
                // enter function mode
                iFunctionMode = 1;
                // Show first function
                functionDisplay();
                // turn off the timer
                timer1Reset();
                cTask.TASK_TIMER1_FUNC = 0;
            }
        }
        // Poll for EXT_POWER
        if (!EXT_POWER && !iPower) {
            // Powered on by external appliance
            iPowerExternal = 1;
            // set the input to the trigged input (input 1) = in2 on the tda7439
            iActiveInput = 2;
            doPower();
        }
        if (EXT_POWER && iPower && iPowerExternal) {
            // Powered off by external appliance
            // don't power off if iPowerExternal is zero (i.e. powered on by IR)
            iPowerExternal = 0;
            doPower();
        }
    }
}

IR commands

I've written up processing of IR commands separately. The code on this article is pretty much what has been used on this amplifier project.

The key alteration vs my previous projects is I wanted the ability to control balance, low-bass, bass and treble from the same simple six button remote as my other two amplifiers.

I decided to use the mute button as a function button if it is pressed and held for about 1.5 seconds. This is where the Timer1 described above is put to use and basically if the mute button is pressed, the Timer1 will start.

When the mute button is held, timer 1 will be switched off, reset and switched on again. As RC5 messages repeat every 114ms, this means the timer gets switched off before it interrupts. The iMuteHeld variable counts how many times the button was held for.

A count of how many times the mute command repeats (iMuteHeld) is used to detect how long the mute button was held for. Brief presses will make this variable count once or twice and once the button is released, the timer 1 will overflow causing the interrupt routine to flag the mute task TASK_TIMER1_MUTE.

If the mute button is held long enough for the RC5 message to repeat 15 times or more though, the timer is not turned back on and the flag to enter function mode is set, TASK_TIMER1_FUNC. 15 times is picked because the button hold should occur every 114ms, corresponding to about 1.7 seconds. Displaying function mode is done in this routine so there's immediate feedback that the button has now been held long enough. This cannot be done for the mute routine otherwise the amplifier would go in and out of mute whilst the button was held. That's why timer 1 is required so I can identify that the button has been released

The main() routine takes care of the subsequent commands to either mute (button pressed briefly and released) or enter function mode (button held).

In function mode, the input/channel up/down buttons change between each function (balance, low-bass, bass, treble), and the volume up/down buttons alter the value up/down. Pressing the mute button briefly again exits the function mode and returns the buttons to their normal purpose.

My adjusted rc5Process() function is below:

/***********************************************************************************
  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
        // 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
                        // reset mute held flag
                        iMuteHeld = 0;
                        if (iFunctionMode == 0) {
                            // reset timer
                            timer1Reset();
                            // start call timer
                            t1con.TMR1ON = 1;
                        } else {
                            // exit function mode
                            iFunctionMode = 0;
                            showVolAndInput();
                            // turn off the timer
                            timer1Reset();
                        }
                    } else {
                        // Button held - this should reset timer1 before it interrupts, therefore avoiding processing the command until the button is released
                        iMuteHeld++;
                        // Reset timer
                        timer1Reset();
                        if (iMuteHeld > 14) {
                            // flag for entering function mode
                            cTask.TASK_TIMER1_FUNC = 1;
                        } else {
                            // Turn on the timer again
                            t1con.TMR1ON = 1;
                        }
                    }
                    break;
                case 16: // Volume up (16 / 0x10 / E)
                    if (iFunctionMode == 0) {
                        doVolumeUp();
                        showVolAndInput();
                    } else {
                        functionRaise();
                    }
                    break;
                case 17: // Volume down (17 / 0x11 / F)
                    if (iFunctionMode == 0) {
                        doVolumeDown();
                        showVolAndInput();
                    } else {
                        functionLower();
                    }
                    break;
                case 32: // Input right (32 / 0x20 / V)
                    if (rc5_flickBitOld != rc5_flickBit) { // Prevent repeated input changing when holding the button
                        if (iFunctionMode == 0) {
                            doInputUp();
                            showVolAndInput();
                        } else {
                            functionUp();
                        }
                    }
                    break;
                case 33: // Input left (33 / 0x21 / U)
                    if (rc5_flickBitOld != rc5_flickBit) { // Prevent repeated input changing when holding the button
                        if (iFunctionMode == 0) {
                            doInputDown();
                            showVolAndInput();
                        } else {
                            functionDown();
                        }
                    }
                    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
                // exit function mode
                iFunctionMode = 0;
                iPowerExternal = 0;
                // power up or down
                doPower();
            }
        }
        rc5_flickBitOld = rc5_flickBit;
    }
}

Processing of the mute timer once the button is released is done in the timer 1 interrupt:

/***********************************************************************************
  Interrupt handler
************************************************************************************/
void interrupt(void) {
    ...snip...
    // timer 1 interrupt - process mute button released
    if (pir1.TMR1IF) {
        // timer 1 will interrupt every 131ms with a 1:2 prescaler at 4MHz
        // This is just longer than an RC5 message
        
        // If held more than 14 times (114ms * 14 is just over 1.5 seconds), enter function mode
        if (iMuteHeld < 15) {
            // flag for muting
            cTask.TASK_TIMER1_MUTE = 1;
        }
        
        pir1.TMR1IF = 0; // Clear interrupt flag
    }
}

TDA7439 - I2C

The TDA7439 is an I2C follower/receiver device. The PIC will be the instructor/sender device and will send addresses and commands down the I2C bus.

The I2C protocol is bi-directional, but in this case the only command sent back from the TDA7439 is an ACK byte. I2C is a really flexible bus that allows multiple devices on the same bus and each command will be preceded with a start bit, a 7 bit address, and a read/write bit. Before the command is sent, the receiver should identifier that a start bit was sent, and an address matches that devices' internal address. If so, an ACK bit is sent back. The command (one or more bytes) will follow, with an ACK bit allowed to be sent back after each byte.

Two wires are used for I2C - the data wire and the clock wire. The sender/leader always controls the clock, and in the case of the TDA7439, the sender/leader can also always control the data wire, though I do let the TDA7439 control the data wire for the ACK bit.

The TDA7439 datasheet shows that the address is 0x88. Technically, the address is 0x44 because only the first 7-bits are the address. The read/write bit is always zero though (the sender always writes data) and the TDA7439 datasheet has already left shifted the address for us, and 0x88 is the full 8-bit byte that would be sent.

My PIC16F873 has a hardware MSSP module which has hardware support for the I2C protocol, so I'm letting the PIC take care of the communication. The speed has been set to 100kbps which is the standard I2C clock speed. Sounds slow, but for sending just three bytes, it's pretty much instant!

After some research, I found this initialisation code worked well:

    // Initialise I2C MSSP
    // Master 100KHz
    // TRISC set SCL and SDA pins as inputs above
    // SSPEN (I2C), CKP (clock polarity), SSPM3:SSPM0 -> 1000 = I2C Master mode, 
    sspcon = 0x38; // I2C enabled (SSPEN), Master mode
    sspcon2 = 0x00;
    sspadd = 9; // Clock 100Khz @ 4Mhz Fosc - 4000000 / (4 * (9 + 1))
    sspstat.SMP = 1; // Slew rate disabled
    sspstat.CKE = 0;
    pir2.BCLIF = 0;

After initialising, using the hardware I2C is easy. The tricky part is before any activity happens, bits 0 to 4 in the SSPCON2 register need to be checked to ensure they are all zero and wait if they are not. Bit 3 in SSPSTAT must also be clear.

The while loop in i2cWait() handles this by blocking the execution until all those bits are cleared.

The rest is easy. Sending a start bit is done by setting SEN in the SSPCON2 register. A data byte is sent by writing a byte to SSPBUF. Sending a stop bit is done by setting PEN in the SSPCON2 register.

/***********************************************************************************
  I2C methods using MSSP
  https://www.hobbytronics.co.uk/tutorials-code/tutorials-microchip/hi-tech-c-i2c-master
************************************************************************************/
// i2c_Wait - wait for I2C transfer to finish
void i2cWait(void) {
    // sspcon2 bit 4 ACKEN: Initiate Acknowledge sequence on SDA and SCL pins and transmit ACKDT data bit. Automatically cleared by hardware.
    // sspcon2 bit 3 RCEN: Enables Receive mode for I2C
    // sspcon2 bit 2 PEN: Initiate STOP condition on SDA and SCL pins. Automatically cleared by hardware
    // sspcon2 bit 1 RSEN: Initiate Repeated START condition on SDA and SCL pins. Automatically cleared by hardware.
    // sspcon2 bit 0 SEN: Initiate START condition on SDA and SCL pins. Automatically cleared by hardware.
    // sspstat bit 3: Indicates that a START bit has been detected last
    while ((sspcon2 & 0x1F) || (sspstat & 0x04));
}

// i2c_Start - Start I2C communication
void i2cStart(void) {
    i2cWait();
    sspcon2.SEN = 1;
}

// i2c_Stop - Stop I2C communication
void i2cStop(void) {
    i2cWait();
    sspcon2.PEN = 1;
}

// i2cWrite - Sends one byte of data
void i2cWrite(char data) {
    i2cWait();
    sspbuf = data;
}

TDA7439 - Commands

As for what bytes to send - the TDA7439 datasheet is really helpful with that and it's simple to control.

Most of the time, only three byes need to be sent. The first is the address - always 0x88. The second is a sub-address. That will be either 0x00 to 0x07 - eight possible sub addresses which represent input select, input gain, volume, bass, mid, treble, right attenuation and left attenuation.

The final byte is the value. This can vary per sub address / function, but the datasheet has great examples.

During testing, I captured the communication using Sigrox PulseView and a cheap logic analyser. This example shows the volume being set.

PulseView capture of TDA7439 volume

Volume is adjusted by sending sub address 0x02. Then the volume attenuation is sent with the final byte - in this case 0x31. 0x31 translates to 49 in decimal, or 0011 0001 in bits. The decimal value directly translates to attenuation, so 49 is -49dB (actually 49 is invalid as the highest value before muting is 47 - I corrected this in software after my capture).

It's different vs the PGA8310 where sending 0 means mute, but here 0 means no attenuation - full volume! Therefore, my volume up function actually does iVolume-- (reduce attenuation), and volume down does iVolume++ (increase attenuation).

The overall attenuation will be adjusted by the input gain, which I set to 3dB for most inputs, so the attenuation displayed is actually attenuation plus gain.

For other commands, the communication is similar, however the value byte (the last one) varies per function. Input is easy - send 0 as the sub address byte, then either 0, 1, 2 or 3 for the input selection, but note that 0=IN4, 1=IN3, 2=IN2 and 3=IN1.

Input gain is 1 followed by a value from 0 to 15 where the gain is 2x that. So, for 6dB gain (x4), send the byte as 3 (0000 0011). Input gain is per input, but to avoid any doubt my code just sends the input gain whenever the input is changed anyway.

Bass (low bass in my setup), Mid (bass), and Treble are all similar. Sub address 3, 4 or 5, followed by a number where 7 (0000 0111) is the neutral point. Then it gets a little confusing, data 0 to 6 is -14dB to -2dB, where 6 is the least attenuation. Then 8 to 15 is for 14dB to 2dB gain, where 15 is the least gain.

There is a pattern and flipping/inverting the last three bits will give a number that can be multiplied by 2 to get the decibel adjustment, for example both -10 and 10 dB have 010. Flip that to 101 and you get 5, times 2 gives 10. Bit 3 is then set for positive, unset for negative. I just did the translation in if statements though.

Speaker attenuation is similar to volume, but for some reason it allows a much greater value - down to 72dB. You can use the individual attenuation for a balance control by attenuating either the left or right speaker.

/***********************************************************************************
  TDA7439 methods
  Sub addresses:
  0x00 = input selector (data 0x00 to 0x03)
  0x01 = input gain (data 0x00 to 0x0F - for 0dB to 30dB)
  0x02 = volume (data 0x00 to 0x38  or 0 to 56 for attenuation in 1dB steps. 56 = mute)
  0x03 = bass gain (data 0x00 to 0x06 for -14dB to -2dB, 0x07 for 0dB, 0x08 to 0x0E for 14dB to 2dB)
  0x04 = mid gain (data 0x00 to 0x06 for -14dB to -2dB, 0x07 for 0dB, 0x08 to 0x0E for 14dB to 2dB)
  0x05 = treble gain (data 0x00 to 0x06 for -14dB to -2dB, 0x07 for 0dB, 0x08 to 0x0E for 14dB to 2dB)
  0x06 = R speaker attenuation (data 0x00 to 0x78 or 0 to 120 in 1dB steps. >=78 = mute)
  0x07 = L speaker attenuation (data 0x00 to 0x78 or 0 to 120 in 1dB steps. >=78 = mute)
************************************************************************************/
void tda7439Write(char bSubAddress, char bData) {
    i2cStart();
    i2cWrite(tda7439_addr); // send TDA7439 i2c address
    i2cWrite(bSubAddress); // send TDA7439 sub address
    i2cWrite(bData); // send data byte
    i2cStop();
}

void tda7439SetVolume() {
    if (iMute)
        tda7439Write(0x02, 56);
    else
        tda7439Write(0x02, iVolume);
}

void tda7439SetInput() {
    RLY_BLUETOOTH = (iActiveInput == 3);
    i2cStart();
    i2cWrite(tda7439_addr); // send TDA7439 i2c address
    i2cWrite(0x10); // send TDA7439 sub address - 0x10 will increment address from 0 to 7 (all of them)
    i2cWrite(iActiveInput); // send data byte
    i2cWrite(tda7439_gains[iActiveInput]); // send data byte
    i2cStop();
}

void tda7439SetBass() {
    tda7439Write(0x03, iBass);
}

void tda7439SetMid() {
    tda7439Write(0x04, iMid);
}

void tda7439SetTreble() {
    tda7439Write(0x05, iTreble);
}

void tda7439SetBalance() {
    // use address incrementing
    i2cStart();
    i2cWrite(tda7439_addr); // send TDA7439 i2c address
    i2cWrite(0x16); // send TDA7439 sub address - 0x16 will increment address from 6 to 7 (r+l attenuation)
    if (iAttenuateL > 0) {
        // If balance is negative, attenuate L speaker
        i2cWrite(0);
        i2cWrite(iAttenuateL);
    } else {
        // otherwise attenuate R speaker
        i2cWrite(iAttenuateR);
        i2cWrite(0);
    }
    i2cStop();
}

The TDA7439 also allows a combination of values to be set in one command with a feature known as auto address incrementing. This works by sending a sub address, plus 0x10 (bit 4). The TDA7439 will then process the first data byte it receives into the address sent (ignoring bit 4) but allows a second or more subsequent data bytes which will be loaded into the next sub address, until a stop condition occurs.

I use this to apply the balance (speaker attenuation) - done by method tda7439SetBalance(). To do each speaker separately, we could send two separate set of commands - e.g., to set the attenuation of both L and R channels to 0, we would send [start] 0x88, 0x06 and 0x00 [end], followed by [start] 0x88, 0x07, 0x00.

Here 0x88 is the TDA7439 address, 0x06 / 0x07 are the sub addresses for R and L attenuation respectively, and 0x00 is for no attenuation. But using address incrementing it could be sent as one command of 0x88, 0x16, 0x00, 0x00. The 0x16 is address incrementing, starting at 0x06. The first 0x00 byte sent goes into R attenuation, the second 0x00 byte sent goes into L attenuation (because the sub address is auto incremented from 0x06 to 0x07).

This is useful for the balance control, but it's also useful for the input selection and power on routines.

For the input selection, done by method tda7439SetInput(), I also apply the input gain at the same time. This allows me to use 4 bytes instead of 6. Sub address 0x10 is sent which is for 'Input selector' command, with address incrementing. The byte for the input to select follows, then the byte for the input gain. The stop bit tells the TDA7439 there's no more bytes to send so nothing goes into the remaining sub-addresses.

A hardcoded array of input gains for each input is in my .h file, where I use no gain for the Bluetooth and a 6dB gain for the other three inputs.

// 6dB gain except for bluetooth
// order in3, in3, in2, in1
const char tda7439_gains[] = {3, 3, 3, 0}; 

For the power on routine, I restore the selected input, gain, volume, bass, mid, treble and L/R attenuation from the prior values saved into the EEPROM. Here the sub address sent is 0x10, meaning start at sub address 0x00 (input selector). 8 data bytes are sent to cover all settings, so including the address and sub address, 10 bytes in total. It would have been 18 bytes with start/stop bits if every piece of data is sent separately.

void tda7439WriteAll() {
    RLY_BLUETOOTH = (iActiveInput == 3);
    // use address incrementing
    i2cStart();
    i2cWrite(tda7439_addr); // send TDA7439 i2c address
    i2cWrite(0x10); // send TDA7439 sub address - 0x10 will increment address from 0 to 7 (all of them)
    i2cWrite(iActiveInput); // send data byte
    i2cWrite(tda7439_gains[iActiveInput]); // send data byte
    i2cWrite(iVolume); // send data byte
    i2cWrite(iBass); // send data byte
    i2cWrite(iMid); // send data byte
    i2cWrite(iTreble); // send data byte
    if (iAttenuateL > 0) {
        // If balance is negative, attenuate L speaker
        i2cWrite(0);
        i2cWrite(iAttenuateL);
    } else {
        // otherwise attenuate R speaker
        i2cWrite(iAttenuateR);
        i2cWrite(0);
    }
    i2cStop();
}

TDA7439 - Volume Up/Down

The global variable iVolume handles the volume level of the system. It used to send directly to the TDA7439 via the above tda7439SetVolume().

Controlling the iVolume level is done only by the IR commands, but I've created separate methods for clarity.

The volume up method will unmute the system if it was muted, otherwise it will decrement the iVolume variable (this is equivalent to reducing attenuation). At max volume (when iVolume is zero), if it is decremented again and overflows to 255 then the iVolume is kept at zero.

Volume down is increasing attenuation, and once attenuation passes 47 the amplifier enters mute status.

After adjusting iVolume up or down, each method calls tda7439SetVolume() to apply the adjusted attenuation.

/***********************************************************************************
  Functions for volume control
************************************************************************************/
void doVolumeUp() {
    // Increase level (decrease attenuation --)
    if (iVolume == 56) { // come out of mute
        iMute = 0;
        iVolume = 47;
    } else {
        iVolume--;
    }
    
    if (iVolume > 56) { // overflowed
        iVolume = 0;
    }
    tda7439SetVolume();
}

void doVolumeDown() {
    // Decrease level (increase attenuation ++)
    iVolume++;
    if (iVolume > 47) { // muted
        iMute = 1;
        iVolume = 56;
    }
    tda7439SetVolume();
}

TDA7439 - Input Change

Changing the input is another set of up/down methods. That's because my simple remote only has channel up/down buttons, but if you were using something different you could have four dedicated buttons for each input. Changing the input is rarely done though, and cycling though them is quick enough.

These methods are simple. They adjust iActiveInput, overflowing it back to 0 if it goes beyond 3, or back to 3 if it goes below 0 (which is 255 for an overflowed unsigned byte). Then tda7439SetInput() is called to apply the newly selected input.

/***********************************************************************************
  Functions to change selected input
************************************************************************************/
void doInputUp() {
    // Decrement the active input
    iActiveInput--;
    if (iActiveInput > 4) // If overflowed (less than 0)
        iActiveInput = 3;
    tda7439SetInput();
}

void doInputDown() {
    // Increment the active input
    iActiveInput++;
    if (iActiveInput >= 4)
        iActiveInput = 0;
    tda7439SetInput();
}

TDA7439 - Functions Tone and Balance

My basic remote and setup gives power, mute, volume up/down and input up/down. The TDA7439 has additional controls though to allow adjustments of three tone bands and balance.

To adjust these settings the mute button is held for 1.5 seconds after which the function mode will be entered. In this mode, input (channel) up/down change between the four functions - Lo Bass, Bass, Treble and Balance.

As explained above, timers are used to determine how long the mute button is held for and if it is pressed and released briefly the amplifier will mute instead.

The volume up/down keys the alter the value of the selected function, for example, if Lo Bass is selected then pressing volume up will increase the bass.

Exiting the function mode happens when the mute key is pressed again. In function mode, the volume and input cannot be changed, but in practise this is fine because we'd want to be listening to one source at a particular comfortable volume to then go and change the tone and balance.

To handle all this, when function mode is entered an iFunctionMode variable is set. The rc5Process() method will then call the alternate functions for volume up/down and input up/down - functionRaise(), functionLower(), functionUp() and functionDown().

The functionUp() and functionDown() methods are simple and are just used for cycling between one of the four functions, in different directions. The functionDisplay() method (detailed later) deals with displaying the selected function and current value.

The functionRaise() and functionLower() methods are a little trickier as they behave a little differently depending on the function selected.

The tone functions (named in my code as bass, mid and treble per TDA7439 datasheet), call reusable methods functionValueRaise() and functionValueLower(). These deal with the odd configuration of bytes to send to the TDA7439 (though I think there is a pattern with inverted signed digits, it's also complicated).

They check against 7 (1111) being the flat position (this means no gain or attenuation of the tone control). Where it gets confusing is to raise the tone control to +2dB, the byte needs to now change to 14 (1110). It then gets lowered by 1 for each additional +2dB gain, until we reach 8 (1000) which represents the maximum +14dB gain. When cutting tone, 6 means -2dB through to 0 which means -14dB.

So functionValueRaise() needs to increment the value if it's less than 7 (cut), but when it reaches 7 it needs to jump from 7 to 14 in order to increment to +2dB, and then decrement from there.

The functionValueLower() is similar. In boost mode (>7), it needs to increment, unless the value is 14 when it jumps down to 7. Then it will decrement (until 0).

These methods return the correctly adjusted value which is used in functionRaise()/functionLower() to set the relevant iBass, iMid or iTreble variables that are then sent to the TDA7439 by tda7439SetBass(), tda7439SetMid() or tda7439SetTreble().

Balance is different. There are two variables for attenuating the left or right channels. Assuming balance is flat, pressing volume down will attenuate the right speaker. This is because the volume down key is on the left on my remote so attenuating the right speaker makes the sound more dominant from the left.

Pressing volume up will reduce the right speaker attenuation until it's back to zero (no attenuation), and then it will start attenuating the left speaker.

After adjusting the left or right balance variables, the result is sent to the TDA7439 using tda7439SetBalance(), which as I mentioned before sends out both bytes in auto-address incrementing mode.

char functionValueRaise(char iValue) {
    if (iValue == 7) // if flat, adjust to the 2dB position
        iValue = 14;
    else if (iValue > 8) // if boost, adjust to the next 2dB position (minus), stop at 8
        iValue--;
    else if (iValue < 7) // if cut, adjust to the next -2dB position (plus)
        iValue++;
    return iValue;
}

char functionValueLower(char iValue) {
    if (iValue == 14) // if 2dB boost, adjust to the flat position
        iValue = 7;
    else if (iValue > 7) // if boost, adjust to the next -2dB position (plus)
        iValue++;
    else if (iValue != 0) // if cut, adjust to the next 2dB position (negative), stop at 0
        iValue--;
    return iValue;
}

void functionRaise() {
    switch (iFunctionMode) {
        case 1: // Bass
            iMid = functionValueRaise(iMid);
            tda7439SetMid();
            break;
        case 2: // Treble
            iTreble = functionValueRaise(iTreble);
            tda7439SetTreble();
            break;
        case 3: // Lo Bass
            iBass = functionValueRaise(iBass);
            tda7439SetBass();
            break;
        case 4: // Balance
            if (iAttenuateL == 0) {
                // If left is zero, attentuate right
                iAttenuateR++;
                if (iAttenuateR > 78)
                    iAttenuateR = 78;
            } else {
                // otherwise reduce attentuation on left
                iAttenuateL--;
            }
            tda7439SetBalance();
            break;
    }
    
    functionDisplay();
}

void functionLower() {
    switch (iFunctionMode) {
        case 1: // Bass
            iMid = functionValueLower(iMid);
            tda7439SetMid();
            break;
        case 2: // Treble
            iTreble = functionValueLower(iTreble);
            tda7439SetTreble();
            break;
        case 3: // Lo Bass
            iBass = functionValueLower(iBass);
            tda7439SetBass();
            break;
        case 4: // Balance
            if (iAttenuateR == 0) {
                // If right is zero, attentuate left
                iAttenuateL++;
                if (iAttenuateL > 78)
                    iAttenuateL = 78;
            } else {
                // otherwise reduce attentuation on right
                iAttenuateR--;
            }
            tda7439SetBalance();
            break;
    }

    functionDisplay();
}

void functionUp() {
    iFunctionMode++;
    if (iFunctionMode > 4)
        iFunctionMode = 1;
    functionDisplay();
}

void functionDown() {
    iFunctionMode--;
    if (iFunctionMode < 1)
        iFunctionMode = 4;
    functionDisplay();
}

MAX7219

The MAX7219 is a handy chip that handles multiplexing of LED displays. It uses a serial synchronous protocol similar to SPI, but the latch line is not a chip select but rather a pulse to say 'now process the data I've sent' and it cannot be used with other 'true' SPI chips on the same bus. The PIC16F873 has hardware SPI, but it's on the same MSSP module that is already used for the I2C bus that the TDA7439 is connected to.

Therefore, I'm bit-banging the communication to the MAX7219. I use the same method as my Hi-Fi 5.1 LM3886 amplifier to communicate with the MAX7219, but since there is only one of the modules in this build the data commands are not repeated and the methods are simplified for processing only one array of ledData.

Also, to improve code speed and reduce size, the array of letters to segments has been bit shifted in the source code rather than done in the execution.

Finally, since the PIC16 family does not have instructions for multiply and divide (including modulus), I've split the data bytes into digits by using a function that will loop and decrement the value by 10 each time, leaving the units behind and a value that is incremented in each loop which is used for the tens.

The display intensity has been set to 9/32 (0x04) which is quite low brightness. This seems an ideal brightness so that the digits do not glare when watching TV but are still clearly visible through the smoke black (translucent) front plastic.

All methods associated with the MAX7219 are listed shown below:

/***********************************************************************************
  LED Display Functions
************************************************************************************/
// Converts a string of characters into decoded 7 segment bytes for LED character array
// Optionally writes these bytes to LED displays
void ledPrint(unsigned char *s) {
    char dig;
    for (dig = 0; dig < 8; dig++) {
        if (*s) {
            ledChar(dig, *s++);
        } else {
            ledData1[dig] = 0;
        }
    }
}

// Converts an ASCII char to decoded byte and places it in LED array
// Support decimal places
//void ledChar(char iCol, char iChar, char iHasDot) {
void ledChar(char iCol, char iChar) {
    char iDecoded = displayASCIItoSeg[iChar - 0x20];
    // dot is unused in this implementation
    //if (iHasDot)
    //    iDecoded = iDecoded | 0x80;
    ledData1[iCol] = iDecoded;
}

void ledSetDigits(char iValue, char iCol) {
    // start by left filling
    ledData1[iCol] = 0;
    
    // determine to second (left most) digit
    char dig2 = 0;
    // take off 10 from the value until it's less than 10
    while (iValue >= 10) {
       iValue -= 10;
       // each time we take off 10, the left most digit is incremented
       dig2++;
    }
    
    // if the left most digit is greater than zero, set the value into the array, adding on 48 to get the ascii value
    if (dig2 != 0)
        ledChar(iCol, dig2 + 48);
    // set the unit value in the array (what's left on iValue), adding on 48 to get the ascii value
    ledChar(iCol + 1, iValue + 48);
}

// Clears the LED display
void ledClear() {
    // Clear all data in the array, then write
    for (char n = 0; n < 8; n++) { 
        ledData1[n] = 0;
    }
    ledWrite();
}

// LED test function
void ledTest() {
    ledPrint("Init on");
    ledWrite();
}


// Setup the MAX7219 LED displays by sending the required configuration bytes
void ledSetup() {
    // Scan limit
    ledLatchDown();
    ledSendChar(0x0B);
    ledSendChar(0x07); // Scan all 8 digits
    ledLatchUp();
    
    // Set no decode mode
    ledLatchDown();
    ledSendChar(0x09);
    ledSendChar(0);
    ledLatchUp();
    
    // Set intensity
    ledLatchDown();
    ledSendChar(0x0A);
    ledSendChar(0x05); // 4: 9/32
    ledLatchUp();
}

// Start LED display
void ledOn() {
    // v1.2 Power on and setup
    LEDDATA = 0;
    LEDCLOCK = 0;
    LEDLATCH = 1;
    LEDDISPON = 0;
    delay_ms(250);
    ledSetup();
    // No shutdown
    ledLatchDown();
    ledSendChar(0x0C);
    ledSendChar(0x01); // no shutdown
    ledLatchUp();
    // Startup time delay
    delay_us(250);
}

// Shutdown LED display
void ledOff() {
    // Shutdown
    ledLatchDown();
    ledSendChar(0x0C);
    ledSendChar(0x00); // shutdown
    ledLatchUp();
    // v1.2, remove the power
    LEDDISPON = 0;
    delay_ms(100);
}

// Write the bytes set in each array to the MAX7219 LED displays
void ledWrite() {
    char n;
    char d = 7; // Characters in array are written out backwards so start from furthest (7)
    
    // Loop through digits 0 to 8 (addressed as 1 to 9)
    for (n = 1; n < 9; n++) { 
        ledLatchDown();
        // Writing character to device
        ledSendChar(n); // Digit to write
        ledSendChar(ledData1[d]); // Digit to write
        ledLatchUp();
        d--; // Decrement array counter
    }
}

void ledLatchDown() {
    // Load the new register
    LEDDATA = 0;
    LEDCLOCK = 0;
    LEDLATCH = 0; // Clock must fall so that last bit is clocked out
}

void ledLatchUp() {
    // Load the new register
    LEDLATCH = 1;
    LEDCLOCK = 0; // Clock must fall after latch raised
    LEDDATA = 0; // v1.2 Data to default
    delay_us(LEDDELAYUS); // Need to delay before starting again
}

// Write a single piece of data (byte) out serially
// Uses bit-banging to control protocol and delays
void ledSendChar(char iData) {
    // v1.2 made bit-bang loop more efficient
    for (char cBitSelect = 0x80; cBitSelect; cBitSelect >>= 1) { // This code puts the byte onto the shift register output
        LEDCLOCK = 0; // Clock cleared
        // LEDDATA pin set to the result of testing the bit in iData with logical AND
        if (iData & cBitSelect)
            LEDDATA = 1;
        else
            LEDDATA = 0;
        LEDCLOCK = 1; // Clock set high, so bit is loaded onto the shift register
    }
}

Display volume and input

When the unit is on, and not in function mode, the current selected input and volume level is always shown.

This method is called whenever the input or volume is changed, when powering on, or exiting function mode.

Limitations on what letters can be displayed on 7 segment displays meant I had to think a bit creatively. Having only eight digits too gives me limitations, but here's what I came up with:

  • If muted, the display simply prints Snd OFF (meaning sound off)
  • Otherwise, the first 3 digits/characters display the input - Blu (for Bluetooth), In1, In2 and In3 for the auxiliary inputs 1 to 3
  • The fourth digit displays a minus sign if the volume is attenuation, or no sign if it's gain
  • Fifth and sixth digits are split from the iVolume variable, adjusted by the input gain. Since this is never a three digit number, only two digits are required.
  • Seventh and eighth digits just display dB, so it's obvious (at least to anyone knowing the existence of decibels) that the number displayed refers to volume
/***********************************************************************************
  Function to print the current input and volume to the LED display
************************************************************************************/
void showVolAndInput() {
    if (iMute) {
        ledPrint("Snd OFF"); // Media Centre PC
        ledWrite();
    } else {
        // show the input on the first 3 characters
        switch (iActiveInput) {
            case 0:
                ledPrint("In3"); // Input 3
                break;
            case 1:
                ledPrint("In2"); // Input 2
                break;
            case 2:
                ledPrint("In1"); // Input 1
                break;
            case 3:
                ledPrint("blu"); // Bluetooth
                break;
        }
        // show volume on next 5 characters e.g. -18db
        // Volume can be 0 to 55
        // Invert the volume to a negative gain (as a positive number) i.e. -56 to 0
        char cGain = iVolume;
        // work out an adjustment to gain based on the input gain value
        char cGainAdj = (tda7439_gains[iActiveInput] * 2);
        // adjust the final gain
        if (cGain < cGainAdj) {
            ledData1[3] = 0;
            cGain = cGainAdj - cGain;
        } else {
            // negative gain (attenuation)
            ledChar(3, '-');
            cGain -= cGainAdj;
        }
        
        ledSetDigits(cGain, 4);
        
        ledChar(6, 'd'); 
        ledChar(7, 'b'); 
        // Write Result to LED display
        ledWrite();
    }
    
    // Display green LED if volume is 0 (effectively mute) or mute is on
    if ((iVolume == 56) || iMute) {
        // Green on, blue off
        BLUE = 0;
        GREEN = 1;
    } else {
        // Green off, blue on
        BLUE = 1;
        GREEN = 0;
    }
}

Display function

In function mode, the display needs to give feedback about what we're adjusting. As input/volume cannot be adjusted in function mode anyway, the entire display is dedicated to displaying the function to be altered and its current setting. There are still the same limitations though - eight characters and a limited number of legible letters.

The method called is functionDisplay(). This prints the active function on the first four digits, either Lo b (for Low-Bass), Bass, Treb (for Treble) or Bal (for Balance).

If the balance is anything off centre, the letter L or r is shown instead of Bal to indicate left or right speaker attenuation.

To translate the tone values of iBass, iMid and iTreble to dB readings, functionValueDisplay() is used. This prints the sign on character 5, followed by the gain or cut value on the next digit.

All functions are adjusting the decibel value, so the remaining eighth digit always displays 'd', meaning dB, but I can't fit that!

/***********************************************************************************
  Functions to display and adjust amp functions i.e. bass, treble, balance
************************************************************************************/
void functionValueDisplay(char iValue) {
    // translate value to dB
    char isCut = 0;//(iValue < 7);
    if (iValue == 7) {
        // flat
        iValue = 0;
    } else if (iValue > 7) {
        // boost
        // 14=2dB 13=4dB ... 8=14dB
        iValue = (15 - iValue) * 2;
    } else {
        // cut 0=-14dB 1=-12db ... 6=-2dB
        isCut = 1;
        iValue = (7 - iValue) * 2;
    }
    // write characters to array
    if (isCut)
        ledChar(4, '-');
    ledSetDigits(iValue, 5);
}

void functionDisplay() {
    switch (iFunctionMode) {
        case 1: // Bass
            ledPrint("bASS"); // Bass
            functionValueDisplay(iMid);
            break;
        case 2: // Treble
            ledPrint("Treb"); // Treble
            functionValueDisplay(iTreble);
            break;
        case 3: // Lo Bass
            ledPrint("Lo b"); // Lo Bass
            functionValueDisplay(iBass);
            break;
        case 4: // Balance
            if (iAttenuateL > 0) { // left attenuation
                ledPrint("L   -"); // Balance
                ledSetDigits(iAttenuateL, 5);
            } else if (iAttenuateR > 0) {
                ledPrint("r   -"); // Balance
                ledSetDigits(iAttenuateR, 5);
            } else {
                ledPrint("BAL   0"); // Balance
            }
            break;
    }
    ledChar(7, 'd'); 
    // Write Result to LED display
    ledWrite();
}

Power on/off

This is similar to my other two amplifiers. A global variable iPower holds the current power status (1 or 0 for on or off). If it's zero, then the amplifier must power on.

Power on does the following:

  1. Make the LED green
  2. Disable timer 1 (just in case it was running)
  3. Print a HELLO message
  4. Switch on the main power relay
  5. Delay 2.6 seconds, by flashing the LED blue and green 26 times at 100ms each
  6. Write all the data to the TDA7439, which will activate the last selected input and apply the last used volume, as well as the tone/balance settings
  7. Display the current input and volume on the display (replacing the hello message)

Power off does the following:

  1. Make the LED green
  2. Disable timer 1 (just in case it was running)
  3. Print a Goodbye message
  4. Switch off the main power relay
  5. Switch off the Bluetooth relay too (don't want to leave this active when the amplifier is off)
  6. Wait 6 seconds
  7. Switch off the MAX7219
  8. Save current settings to the EEPROM
  9. Make the LED red
/***********************************************************************************
  Power on or off routine
************************************************************************************/
void doPower() {
    if (iPower) {
        // Switch on green (for muted)
        BLUE = 0;
        GREEN = 1;
        RED = 0;
        
        // Disable timer 1
        timer1Reset();

        // Goodbye!
        ledPrint("Goodbye");
        ledWrite();
        
        iPower = 0;

        RLY_POWER = 0; // Power off amps
        RLY_BLUETOOTH = 0; // Power off bluetooth
        delay_s(6); // Force a 6 second wait before the ability to switch on again (allows electronics to drain)
        
        ledOff();
        
        saveData(); // save data to eeprom to restore after power loss
        
        // Switch on red (for standby)
        BLUE = 0;
        GREEN = 0;
        RED = 1;
    } else {
        // Power on sequence
        // Switch on green (for muted)
        BLUE = 0;
        GREEN = 1;
        RED = 0;
        
        ledOn();
        ledPrint("HELLO");
        ledWrite();

        // v1.2 add delay after switching LED but before switching amp
        DELAY_SHORT;
        
        // Power on amps
        RLY_POWER = 1;
        iPower = 1;

        // Delay mute
        // Flash Green/Blue for 2.6 seconds
        char l;
        for (l=0; l<27; l++) {
            // Green off, blue on
            BLUE = 1;
            GREEN = 0;
            DELAY_SHORT;
            // Blue off, green on
            BLUE = 0;
            GREEN = 1;
            DELAY_SHORT;
        }
        
        // Write all values to TDA7439 (since it is now powered) - this will also unmute the volume
        tda7439WriteAll();
        // show the volume and input
        showVolAndInput();
    }
}

EEPROM - saving data

Even though the PIC16F873 will remain operating in standby/off in my amplifier, if I do unplug the device (or suffer a power cut), I don't want to lose the preferred settings for volume and input and in particular low-bass, bass, treble and balance.

The EEPROM on the PIC16F873 can be used to permanently save up to 128 bytes of data. I only need to save 7 bytes, plus 1 byte to indicate data was saved previously for reading during power on.

Data is written to the EEPROM whenever the amplifier is powered off. This is to minimise writes to the EEPROM in order to prolong its life. Furthermore, a data value is only written to the EEPROM if it changed compared to what is already in the EEPROM.

Below are the EEPROM methods:

/***********************************************************************************
  EEPROM read and write methods
************************************************************************************/
void saveData() {
    char didWrite = 0;
    // only write value if it is different
    didWrite += eepromWrite(1, iVolume);
    didWrite += eepromWrite(2, iAttenuateL);
    didWrite += eepromWrite(3, iAttenuateR);
    didWrite += eepromWrite(4, iBass);
    didWrite += eepromWrite(5, iMid);
    didWrite += eepromWrite(6, iTreble);
    didWrite += eepromWrite(7, iActiveInput);
    
    if (didWrite)
        eepromWrite(0, 10); // To indicate EEPROM has been saved
}

char eepromWrite(char address, char data) {
    char didWrite = 0;
    if (eepromRead(address) != data) {
        char intconsave = intcon;
        
        // Load address and data
        eeadr = address;
        eedata = data;
    
        eecon1.EEPGD = 0; // Point to DATA memory
        //eecon1.CFGS = 0; // Access EEPROM
        eecon1.WREN = 1; // Enable writes
        
        // Required write sequence
        intcon = 0;
        eecon2 = 0x55; // Write 55h
        eecon2 = 0xAA; // Write 0AAh
        eecon1.WR = 1; // Set WR bit to begin write
        intcon = intconsave;
        eecon1.WREN = 0; // Disable writes on write complete (EEIF set)
        while(!pir2.EEIF); // Wait for the interrupt bit EEIF to be set
        pir2.EEIF = 0; // Clear EEIF
        didWrite = 1;
    }
    return didWrite;
}

/******************************************************
  Function to read the current variables from ROM
*******************************************************/
void readData() {
    // Read initial values from EEPROM
    // Do not read other variables if the EEPROM has not been saved before
    // as all default will be 0xFF
    if (eepromRead(0) == 10) {
        iVolume = eepromRead(1);
        iAttenuateL = eepromRead(2);
        iAttenuateR = eepromRead(3);
        iBass = eepromRead(4);
        iMid = eepromRead(5);
        iTreble = eepromRead(6);
        iActiveInput = eepromRead(7);
    }
}

char eepromRead(char address) {
    // Load address
    eeadr = address;
    eecon1.EEPGD = 0; // Point to DATA memory
    //eecon1.CFGS = 0; // Access EEPROM
    
    // Read, data is available in eedata the next cycle.
    eecon1.RD = 1;
    
    // Return value
    return eedata;
}

Part 3 - Conclusion, Pictures...

References and more reading:
Source code on github.com
PIC 16F87X Datasheet
Source Boost
Sigrox PulseView software
Dring Engineering Services - IC Timer Calculator and Source Code Generator
Tamil Arduino - IR RC5 decoding code using interrupts
CCS - 7-segment LED display in 3-digit with 2x7W amplifier
ASCII Codes Table
MAX7219 Datasheet
Electronics StackExchange - Starting with I2C on PIC18s
Teachmemicro - PIC16 I2C Tutorial
Hobby Tronics - I2C Sample Code