Maker.io main logo

DIY Vintage TV VU Meter with peak indicators

2026-06-02 | By Mirko Pavleski

License: General Public License Arduino

Some time ago, in one of my projects, I presented you with a way to turn an old black-and-white mini TV into a Retro Clock. This time, I will describe another project with which you can use your old TV and turn it into a beautiful video effect, and that is a full-screen stereo VU meter.

Again, I will use the Arduino Nano microcontroller and the appropriate library for generating a composite signal. Most often, old TVs do not have a composite input, so we need to modify it in the way described in the previously mentioned video. This is probably the most complex part of the project, so in one of the next videos, I will present you with a much simpler universal way for the "composite in" option on old TV receivers.

fgfr

Otherwise, this device is extremely simple and consists of several components.

- Arduino Nano microcontroller board

- Potentiometer

- Two 1N4001 diodes

- two capacitors

- and four resistors

The input part of the circuit is a so-called envelope follower and constantly monitors the peak of the signal and sends it to the analog input of the Arduino.

vh

The two resistors connected to D7 and D9 in cooperation with the TVout library serve to generate a composite output video signal. The second potentiometer has a very interesting and, at the same time, useful function, and that is the regulation of the reaction speed in relation to the input signal, and this visually means "smoothing" the displayed signal.

gfhy

Now, a few words about the software. Unlike my previous similar projects, where I used two microcontrollers, one for each channel, this time the signal from both channels is processed by one microcontroller, and all this for the sake of simplicity, because here we do not need precise measurements but only a visual impression. We can even completely eliminate the envelope follower circuit and bring the signal directly to the Arduino inputs (preferably through 1 microfarad capacitors).

klk

I designed the code in a way that allows you to easily change many parameters, starting from the thickness and distance between the bars to the peak hold and decay time. You can also adjust the input sensitivity of the VU meter in the following way: float sensitivityGain = 5.0; // Adjust this value (1.0 = normal, 2.0 = 2x sensitivity ...). At first, I had the idea of ​​a scale marked with decibels in the middle between the two channels, but then I left it out because this is just a visual effect, not a precise instrument.

Now let's see how this device works in real conditions.

As with the previous project, when starting, the title first appears, then my logo, and finally the VU meter starts.

jho

The gain potentiometer adjusts the level of the input signal. Now you will see the influence of the other (smoothing) potentiometer. Moving the potentiometer to the left increases the speed of the bars' reaction, and vice versa, to the right decreases the reaction.

By changing the values ​​

#define BAR_WIDTH 1

#define BAR_GAP 1

we can very easily create different shapes of the VU meter.

hjh

Here's what the VU meter looks like on my new Mini LCD TV with built-in composite input.

bnk

And finally, a short conclusion. With this project, you can easily turn your old, useless TV into a beautiful retro video effect device.

bh

Copy Code
#include <TVout.h>
#include <fontALL.h>
#include "MyLogo.h"
#include <avr/pgmspace.h> // For PROGMEM 

TVout TV;

// Screen dimensions
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 106

// VU meter dimensions and positioning
#define VU_WIDTH (SCREEN_WIDTH/2 - 8)   // Half screen width with margins
#define VU_HEIGHT (SCREEN_HEIGHT - 20)  // Increased height for VU meters
#define LEFT_VU_X 6                     // Left margin
#define RIGHT_VU_X (SCREEN_WIDTH/2 + 2) // Right meter position
#define VU_Y 8                          // Top position for VU meters
#define BAR_WIDTH 1
#define BAR_GAP 1

// Label positioning at the bottom
#define LABEL_Y (VU_Y + VU_HEIGHT + 3)  // Labels raised by 1 pixel (was 4, now 3)

// Peak hold settings
#define PEAK_HOLD_TIME 500  // Time in ms to hold the peak
#define PEAK_DECAY_RATE 8    // How fast the peak decays (pixels per second)

// Audio input pins
#define LEFT_INPUT A0
#define RIGHT_INPUT A1
#define SMOOTHING_POT A2    // Potentiometer for smoothing control

// For smoothing the readings
#define MAX_SMOOTHING 10
int leftValues[MAX_SMOOTHING];
int rightValues[MAX_SMOOTHING];
int leftIndex = 0;
int rightIndex = 0;
int currentSmoothing = 5;   // Default smoothing value

// Store previous heights for proper clearing
int prevLeftHeight = 0;
int prevRightHeight = 0;

// Peak hold variables
int leftPeakHeight = 0;
int rightPeakHeight = 0;
int prevLeftPeakHeight = 0;
int prevRightPeakHeight = 0;
unsigned long leftPeakTime = 0;
unsigned long rightPeakTime = 0;

int amplifySignal(int input, float gain) {
  // Apply gain and constrain to valid range
  int amplified = input * gain;
  return constrain(amplified, 0, 1023);
}

void setup() {
  // Initialize TV output
  TV.begin(PAL, SCREEN_WIDTH, SCREEN_HEIGHT);
  
  // Set up font
  TV.select_font(font8x8);

  TV.set_cursor(15, 13);
  TV.print("DIY Arduino");
  TV.set_cursor(4, 40);
  TV.print("Stereo VU Meter");
  TV.draw_rect(3, 38, 121, 13, 1, -1);
  TV.draw_rect(0, 35, 127, 19, 1, -1);
  TV.set_cursor(8, 67);
  TV.print("TVout Library");
  TV.delay(5000);
  TV.clear_screen();
  intro();

  // Initialize smoothing arrays
  for (int i = 0; i < MAX_SMOOTHING; i++) {
    leftValues[i] = 0;
    rightValues[i] = 0;
  }
  
  // Draw static elements
  drawStaticElements();
}

void loop() {
  // Read smoothing potentiometer
  int smoothingRaw = analogRead(SMOOTHING_POT);
  currentSmoothing = map(smoothingRaw, 0, 1023, 1, MAX_SMOOTHING);
  
  // Read and process audio inputs
 // int leftRaw = analogRead(LEFT_INPUT);
//  int rightRaw = analogRead(RIGHT_INPUT);

float sensitivityGain = 5.0; // Adjust this value (1.0 = normal, 2.0 = 2x sensitivity)
int leftRaw = amplifySignal(analogRead(LEFT_INPUT), sensitivityGain);
int rightRaw = amplifySignal(analogRead(RIGHT_INPUT), sensitivityGain);
  
  // Apply smoothing
  leftValues[leftIndex] = leftRaw;
  rightValues[rightIndex] = rightRaw;
  leftIndex = (leftIndex + 1) % currentSmoothing;
  rightIndex = (rightIndex + 1) % currentSmoothing;
  
  long leftSum = 0;
  long rightSum = 0;
  for (int i = 0; i < currentSmoothing; i++) {
    leftSum += leftValues[i];
    rightSum += rightValues[i];
  }
  
  int leftValue = leftSum / currentSmoothing;
  int rightValue = rightSum / currentSmoothing;
  
  // Map the values to the VU meter height
  int leftHeight = map(leftValue, 0, 1023, 0, VU_HEIGHT);
  int rightHeight = map(rightValue, 0, 1023, 0, VU_HEIGHT);
  
  // Update the VU meters
  updateVUMeter(LEFT_VU_X, leftHeight, true);
  updateVUMeter(RIGHT_VU_X, rightHeight, false);
  
  // Update peak values
  updatePeaks(leftHeight, rightHeight);
  
  // Draw peak indicators
  drawPeakIndicators();
  
  // Small delay to control refresh rate
  delay(30);
}

void drawStaticElements() {
  // Clear screen
  TV.clear_screen();
  
  // Draw rounded screen border (1 pixel thick)
  TV.draw_rect(0, 0, SCREEN_WIDTH-1, SCREEN_HEIGHT-1, WHITE);
  
  // Round the corners by drawing pixels at the corners
  TV.set_pixel(0, 1, WHITE);
  TV.set_pixel(1, 0, WHITE);
  TV.set_pixel(SCREEN_WIDTH-2, 0, WHITE);
  TV.set_pixel(SCREEN_WIDTH-1, 1, WHITE);
  TV.set_pixel(0, SCREEN_HEIGHT-2, WHITE);
  TV.set_pixel(1, SCREEN_HEIGHT-1, WHITE);
  TV.set_pixel(SCREEN_WIDTH-2, SCREEN_HEIGHT-1, WHITE);
  TV.set_pixel(SCREEN_WIDTH-1, SCREEN_HEIGHT-2, WHITE);
  
  // Draw VU meter borders
  TV.draw_rect(LEFT_VU_X, VU_Y, VU_WIDTH, VU_HEIGHT, WHITE);
  TV.draw_rect(RIGHT_VU_X, VU_Y, VU_WIDTH, VU_HEIGHT, WHITE);
  
  // Draw continuous bottom line of the frame
  TV.draw_line(0, SCREEN_HEIGHT-1, SCREEN_WIDTH-1, SCREEN_HEIGHT-1, WHITE);

  TV.select_font(font6x8);
  
  // Draw labels at the bottom centered in their sections (raised by 1 pixel)
  TV.print(LEFT_VU_X + (VU_WIDTH - 22)/2, LABEL_Y, "LEFT");
  TV.print(RIGHT_VU_X + (VU_WIDTH - 30)/2, LABEL_Y, "RIGHT");
}

void updateVUMeter(int x, int height, boolean isLeft) {
  int &prevHeight = isLeft ? prevLeftHeight : prevRightHeight;
  
  // If height hasn't changed, do nothing
  if (height == prevHeight) {
    return;
  }
  
  // Calculate inner rectangle bounds (2 pixels narrower on each side, moved 1px right)
  int innerX = x + 3;  // Added 1px to move bars right
  int innerWidth = VU_WIDTH - 4;
  
  // Clear the previous bars if height decreased
  if (height < prevHeight) {
    int clearStartY = VU_Y + VU_HEIGHT - prevHeight;
    int clearEndY = VU_Y + VU_HEIGHT - height;
    
    for (int y = clearStartY; y < clearEndY; y++) {
      // Clear only the inner area of the VU meter
      TV.draw_line(innerX, y, innerX + innerWidth - 1, y, BLACK);
    }
  }
  
  // Draw new bars if height increased
  if (height > prevHeight) {
    int drawStartY = VU_Y + VU_HEIGHT - height;
    int drawEndY = VU_Y + VU_HEIGHT - prevHeight;
    
    for (int y = drawStartY; y < drawEndY; y++) {
      // Only draw bars with the specified pattern
      int barPosition = (VU_Y + VU_HEIGHT - y) % (BAR_WIDTH + BAR_GAP);
      if (barPosition < BAR_WIDTH) {
        TV.draw_line(innerX, y, innerX + innerWidth - 1, y, WHITE);
      }
    }
  }
  // If height decreased but we need to redraw the pattern in the remaining area
  else if (height > 0) {
    // Redraw the bar pattern in the remaining area
    for (int y = VU_Y + VU_HEIGHT - height; y < VU_Y + VU_HEIGHT; y++) {
      int barPosition = (VU_Y + VU_HEIGHT - y) % (BAR_WIDTH + BAR_GAP);
      if (barPosition < BAR_WIDTH) {
        TV.draw_line(innerX, y, innerX + innerWidth - 1, y, WHITE);
      } else {
        TV.draw_line(innerX, y, innerX + innerWidth - 1, y, BLACK);
      }
    }
  }
  
  // Update previous height
  prevHeight = height;
}

void intro() {
  unsigned char w, l, wb;
  int index;
  w = pgm_read_byte(MyLogo);
  l = pgm_read_byte(MyLogo+1);
  if (w & 7)
    wb = w/8 + 1;
  else
    wb = w/8;
  index = wb*(l-1) + 2;
  for (unsigned char i = 1; i < l; i++) {
    TV.bitmap((TV.hres() - w)/2, 0, MyLogo, index, w, i);
    index -= wb;
    TV.delay(50);
  }
  for (unsigned char i = 0; i < (TV.vres() - l); i++) {
    TV.bitmap((TV.hres() - w)/2, i, MyLogo);
    TV.delay(50);
  }
  TV.delay(3000);
  TV.clear_screen();
}

void updatePeaks(int leftHeight, int rightHeight) {
  unsigned long currentTime = millis();
  
  // Update left peak
  if (leftHeight > leftPeakHeight) {
    leftPeakHeight = leftHeight;
    leftPeakTime = currentTime;
  } else if (currentTime - leftPeakTime > PEAK_HOLD_TIME) {
    // Gradually decay the peak after hold time
    int decay = (currentTime - leftPeakTime - PEAK_HOLD_TIME) * PEAK_DECAY_RATE / 1000;
    leftPeakHeight = max(0, leftPeakHeight - decay);
  }
  
  // Update right peak
  if (rightHeight > rightPeakHeight) {
    rightPeakHeight = rightHeight;
    rightPeakTime = currentTime;
  } else if (currentTime - rightPeakTime > PEAK_HOLD_TIME) {
    // Gradually decay the peak after hold time
    int decay = (currentTime - rightPeakTime - PEAK_HOLD_TIME) * PEAK_DECAY_RATE / 1000;
    rightPeakHeight = max(0, rightPeakHeight - decay);
  }
}

void drawPeakIndicators() {
  // Calculate inner rectangle bounds
  int leftInnerX = LEFT_VU_X + 3;
  int rightInnerX = RIGHT_VU_X + 3;
  int innerWidth = VU_WIDTH - 4;
  
  // Ensure peak indicators don't touch the borders (both top and bottom)
  int leftPeakY = VU_Y + VU_HEIGHT - max(1, min(leftPeakHeight, VU_HEIGHT - 1));
  int rightPeakY = VU_Y + VU_HEIGHT - max(1, min(rightPeakHeight, VU_HEIGHT - 1));
  int prevLeftPeakY = VU_Y + VU_HEIGHT - max(1, min(prevLeftPeakHeight, VU_HEIGHT - 1));
  int prevRightPeakY = VU_Y + VU_HEIGHT - max(1, min(prevRightPeakHeight, VU_HEIGHT - 1));
  
  // Clear previous peak indicators if they've moved
  if (prevLeftPeakHeight != leftPeakHeight) {
    TV.draw_line(leftInnerX, prevLeftPeakY, 
                 leftInnerX + innerWidth - 1, prevLeftPeakY, BLACK);
  }
  if (prevRightPeakHeight != rightPeakHeight) {
    TV.draw_line(rightInnerX, prevRightPeakY, 
                 rightInnerX + innerWidth - 1, prevRightPeakY, BLACK);
  }
  
  // Draw new peak indicators (if peak is above 0)
  if (leftPeakHeight > 0) {
    TV.draw_line(leftInnerX, leftPeakY, 
                 leftInnerX + innerWidth - 1, leftPeakY, WHITE);
  }
  if (rightPeakHeight > 0) {
    TV.draw_line(rightInnerX, rightPeakY, 
                 rightInnerX + innerWidth - 1, rightPeakY, WHITE);
  }
  
  // REDRAW THE TOP BORDER to fix any erasure by peak indicators
  TV.draw_line(LEFT_VU_X, VU_Y, LEFT_VU_X + VU_WIDTH, VU_Y, WHITE);
  TV.draw_line(RIGHT_VU_X, VU_Y, RIGHT_VU_X + VU_WIDTH, VU_Y, WHITE);
  
  // Update previous peak heights
  prevLeftPeakHeight = leftPeakHeight;
  prevRightPeakHeight = rightPeakHeight;
}

Have questions or comments? Continue the conversation on TechForum, DigiKey's online community and technical resource.