Build a Real-Time Guitar Tuner
Detect musical notes from microphone input using FFT and a desktop GUI.
Introduction
30 Second Summary
Every guitarist knows the frustration of playing a chord that sounds just slightly off. A good tuner fixes that instantly, but have you ever wondered how one actually detects pitch from sound waves?
In this project, you will build a real-time guitar tuner in Python. Your app listens through your microphone, identifies the note you're playing, and displays exactly how sharp or flat you are with a visual tuning indicator.
What You'll Build
A desktop guitar tuner application that uses Fast Fourier Transform and Harmonic Product Spectrum algorithms to detect pitch from live microphone input and display real-time tuning feedback in a dark-themed GUI.
By the end of this project, you'll have:
- A real-time audio capture pipeline that reads microphone input using PyAudio and filters out background noise.
- A pitch detection engine powered by FFT and Harmonic Product Spectrum to identify musical notes with cents-level accuracy.
- A dark-themed desktop GUI with a visual tuning indicator, note display, and frequency readout built with CustomTkinter.
- Secret Mission: add a reference tone player that generates and plays target frequencies for each guitar string so you can tune by ear.
Do I need a guitar?
Not at all. Any sound source works. You can hum, whistle, or use an online tone generator to test your tuner.
You will need a working microphone and basic Python knowledge (variables, functions, loops, classes).
Set Up Your Project Environment
Before you can detect pitch from a guitar, you need the right tools installed. Your tuner depends on three Python libraries that handle audio capture, math, and the visual interface.
In this step, you will create a project folder, set up an isolated Python environment, install the required packages, and confirm your microphone is accessible.
In this step, get ready to:
- Create a project folder and virtual environment.
- Install PyAudio, NumPy, and CustomTkinter.
- Test that your microphone is accessible from Python.
Create a project folder and virtual environment
A virtual environment is an isolated copy of Python where you install packages without affecting the rest of your system. Every Python project should have its own virtual environment to avoid version conflicts.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Visual Studio Code and press Enter to open it.
- Open the integrated terminal by pressing Ctrl+` (the backtick key, above Tab on most keyboards).
Where is the backtick key?
The backtick (`) is directly above the Tab key and to the left of the number 1 on most keyboards. On macOS, the shortcut is the same: Ctrl+` (not Cmd).
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir guitar-tuner
cd guitar-tuner
- Confirm the folder exists by running:
ls
You should see an empty output since the folder is brand new.
- Create a virtual environment by running:
python -m venv venv
What does this command do?
The python -m venv venv command tells Python to create a virtual environment in a folder called venv inside your project directory. This folder contains its own Python interpreter and a place to install packages separately from your system Python.
Now you need to activate the virtual environment so that any packages you install go into this isolated space.
macOS
- Activate the virtual environment by running:
source venv/bin/activate
Windows
- Activate the virtual environment by running:
venv\Scripts\activate
Your terminal prompt now shows (venv) at the beginning. This confirms the virtual environment is active.
Don't see (venv) in your prompt?
On Windows, you may need to allow script execution first. Run Set-ExecutionPolicy -Scope CurrentUser RemoteSigned in PowerShell, then try the activate command again.
On macOS, make sure you typed source before the path. Without it, the shell tries to run the script as a program and it will fail.
Why isn't my virtual environment activating?
Install the required packages
Your tuner needs three libraries. PyAudio captures audio from your microphone. NumPy handles the math for frequency analysis. CustomTkinter provides a modern-looking desktop GUI.
- Install all three packages by running:
pip install pyaudio==0.2.14 numpy==2.4.6 customtkinter==5.2.2
Why pin version numbers?
Pinning exact versions (like numpy==2.4.6) guarantees the code works the same way every time. Newer versions can change behaviour or drop support for your Python version.
For example, NumPy 2.5.0 requires Python 3.12 or higher. Since you are on Python 3.11, you need 2.4.6 which is the latest version supporting your Python.
PyAudio failing to install on macOS?
PyAudio requires the PortAudio system library on macOS. If you see an error mentioning a missing header file, install PortAudio first with Homebrew by running:
brew install portaudio
Then retry the pip install command above. If you do not have Homebrew installed, visit brew.sh to set it up first.
On Windows, PyAudio 0.2.14 includes a prebuilt wheel, so it should install without extra dependencies.
Help me fix my PyAudio installation error.
- Verify all three packages installed by running:
pip list
You should see PyAudio, numpy, and customtkinter in the output list along with their version numbers.
Test microphone access
Before building the full tuner, you need to confirm Python can actually read audio from your microphone. A quick test script will open a short audio stream and check that data comes through.
- In VS Code, click File in the top menu bar.
- Click New File.
- Save the file immediately as test_mic.py inside your guitar-tuner folder.
- Copy the following code into test_mic.py:
import pyaudio
import numpy as np
# Set up a short audio stream to test mic access
pa = pyaudio.PyAudio()
stream = pa.open(
format=pyaudio.paInt16,
channels=1,
rate=44100,
input=True,
frames_per_buffer=4096
)
# Read one buffer of audio data
raw_data = stream.read(4096)
audio_data = np.frombuffer(raw_data, dtype=np.int16)
print(f"Received {len(audio_data)} samples of audio data.")
print("Microphone access confirmed!")
# Clean up the stream
stream.stop_stream()
stream.close()
pa.terminate()
What does this code do?
- Opens a PyAudio stream configured for 44100 Hz, mono, 16-bit audio (the same settings your tuner will use).
- Reads one buffer of 4096 frames from your microphone.
- Converts the raw bytes into a NumPy array and prints the sample count to prove audio data flowed through.
- Closes everything cleanly so the microphone is released.
- Save test_mic.py.
- Run the test by executing this command in your terminal:
python test_mic.py
Your OS may ask for microphone permission
When the script runs for the first time, macOS or Windows may display a pop-up asking whether to allow Python (or VS Code) to access your microphone. Click Allow or Yes to proceed.
You should see output like this:
Received 4096 samples of audio data.
Microphone access confirmed!
Seeing an error about no input devices?
Make sure your microphone is plugged in or enabled in your system sound settings. On macOS, check System Settings > Sound > Input and verify a device is selected.
On Windows, check Settings > System > Sound > Input and ensure a microphone is listed and not muted.
Help me fix my microphone access error in Python.
- Now that the test passed, delete the test file by running:
rm test_mic.py
Windows equivalent
On Windows, use del test_mic.py instead of rm.
Your environment is ready to go. Next up, you will build the audio capture module that continuously reads sound from your microphone in real time.
Build the Audio Capture Module
Your guitar tuner needs to listen to the microphone continuously and turn raw sound into numbers that Python can analyze.
In this step, you'll build an AudioStream module that opens the microphone, reads audio data into a NumPy array, and filters out silence so you only process meaningful sound.
In this step, get ready to:
- Create the AudioStream class with microphone configuration.
- Add a read() method with silence detection.
- Test the module to confirm it captures live audio.
Create audio_stream.py with an AudioStream class
The AudioStream class wraps PyAudio to handle microphone access. It stores audio settings like sample rate and buffer size, then provides methods to start, read from, and stop the audio stream.
- Create a new file called audio_stream.py inside your guitar-tuner folder.
- Add the imports, class definition, and lifecycle methods by pasting this code:
import pyaudio
import numpy as np
class AudioStream:
def __init__(self, sample_rate=44100, buffer_size=4096, silence_threshold=0.01):
# Audio capture settings
self.sample_rate = sample_rate
self.buffer_size = buffer_size
self.silence_threshold = silence_threshold
# Stream references (initialized when start() is called)
self.pa = None
self.stream = None
def start(self):
# Initialize PyAudio and open a mono 16-bit input stream
self.pa = pyaudio.PyAudio()
self.stream = self.pa.open(
format=pyaudio.paInt16,
channels=1,
rate=self.sample_rate,
input=True,
frames_per_buffer=self.buffer_size
)
def stop(self):
# Safely close the stream and release PortAudio resources
if self.stream is not None:
self.stream.stop_stream()
self.stream.close()
if self.pa is not None:
self.pa.terminate()
What does this code do?
The __init__ method stores configuration without opening any hardware connection yet. A sample rate of 44100 Hz captures CD-quality audio. The buffer size of 4096 frames means each read grabs about 93 milliseconds of audio.
The start() method initializes PyAudio and opens a mono microphone stream using 16-bit integer format. The stop() method safely closes everything with None checks so it never crashes if called before start().
- Save audio_stream.py.
- You won't see output yet. The next chunk adds the read() method that actually captures audio data.
Add signal validation
The read() method does two things: it captures raw audio bytes and converts them to a float array, then checks the volume level (RMS) to filter out silence.
- Add the read() method to your AudioStream class, above the stop() method:
def read(self):
try:
# Read raw bytes from the microphone buffer
raw_data = self.stream.read(self.buffer_size, exception_on_overflow=False)
except IOError:
return None
# Convert 16-bit integers to float64 values between -1.0 and 1.0
audio_data = np.frombuffer(raw_data, dtype=np.int16).astype(np.float64)
audio_data = audio_data / 32768.0
# Calculate volume (RMS) and ignore silence
rms = np.sqrt(np.mean(audio_data ** 2))
if rms < self.silence_threshold:
return None
return audio_data
What does this code do?
The method reads a chunk of raw bytes from the microphone. It passes exception_on_overflow=False to silently skip frames if the buffer fills faster than we read, which prevents crashes during heavy processing.
Next, it converts the 16-bit integer samples into float values between -1.0 and 1.0 (dividing by 32768, the max value of a 16-bit signed integer). This normalized format is what FFT algorithms expect.
Finally, it computes the RMS (root mean square) of the buffer to measure volume. If the RMS is below the silence threshold (0.01), it returns None so the tuner doesn't try to detect pitch from background noise.
- Save audio_stream.py.
- No output yet. The next substep adds a standalone test to verify everything works.
Test the module standalone
Python's if __name__ == "__main__" pattern lets you run a module directly for testing without affecting imports.
- Add this test block at the bottom of audio_stream.py, outside the class (no indentation):
if __name__ == "__main__":
audio = AudioStream()
audio.start()
print("Listening... (Ctrl+C to stop)")
try:
while True:
data = audio.read()
if data is not None:
# Print the current volume level
rms = np.sqrt(np.mean(data ** 2))
print(f"Volume: {rms:.4f}")
except KeyboardInterrupt:
pass
finally:
audio.stop()
print("Stopped.")
What does this code do?
This creates an AudioStream instance, starts listening, and continuously reads audio data. When a sound is loud enough to pass the silence threshold, it prints the volume level.
Pressing Ctrl+C triggers a KeyboardInterrupt which exits the loop cleanly. The finally block ensures the audio stream always closes properly, even if an error occurs.
- Save audio_stream.py.
- Run the module by entering this command in your terminal:
python audio_stream.py
- Speak or clap near your microphone. You should see Volume: readings appear, like Volume: 0.0312.
- Stay quiet for a moment. The output should stop because the silence filter is working.
- Press Ctrl+C to stop the script. You should see Stopped. printed.
Not seeing any volume readings?
- Make sure your microphone is not muted in your system sound settings.
- Check that no other application (like a video call) is using your microphone exclusively.
- If you see an error about no input device, confirm your microphone is connected and selected as the default input device in your OS sound preferences.
Still stuck? Help me debug why my PyAudio stream isn't picking up microphone input.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file.
ⓧ I'd like to double check the full code
Here's the complete audio_stream.py file for comparison:
import pyaudio
import numpy as np
class AudioStream:
def __init__(self, sample_rate=44100, buffer_size=4096, silence_threshold=0.01):
self.sample_rate = sample_rate
self.buffer_size = buffer_size
self.silence_threshold = silence_threshold
self.pa = None
self.stream = None
def start(self):
self.pa = pyaudio.PyAudio()
self.stream = self.pa.open(
format=pyaudio.paInt16,
channels=1,
rate=self.sample_rate,
input=True,
frames_per_buffer=self.buffer_size
)
def read(self):
try:
raw_data = self.stream.read(self.buffer_size, exception_on_overflow=False)
except IOError:
return None
audio_data = np.frombuffer(raw_data, dtype=np.int16).astype(np.float64)
audio_data = audio_data / 32768.0
rms = np.sqrt(np.mean(audio_data ** 2))
if rms < self.silence_threshold:
return None
return audio_data
def stop(self):
if self.stream is not None:
self.stream.stop_stream()
self.stream.close()
if self.pa is not None:
self.pa.terminate()
if __name__ == "__main__":
audio = AudioStream()
audio.start()
print("Listening... (Ctrl+C to stop)")
try:
while True:
data = audio.read()
if data is not None:
rms = np.sqrt(np.mean(data ** 2))
print(f"Volume: {rms:.4f}")
except KeyboardInterrupt:
pass
finally:
audio.stop()
print("Stopped.")
Your audio pipeline is working. Next up, you'll feed this audio data into a pitch detection algorithm that uses the Fast Fourier Transform to identify musical notes.
Implement Pitch Detection with FFT and HPS
In the previous step, you built an audio capture module that reads raw microphone data and filters out silence. Now it's time to turn that raw audio into something meaningful: a musical note name.
To identify which note is being played, you need to find the dominant frequency in the audio signal. You'll use two mathematical techniques for this: the Fast Fourier Transform (FFT) to decompose audio into its component frequencies, and the Harmonic Product Spectrum (HPS) algorithm to isolate the true fundamental pitch from its overtones.
In this step, get ready to:
- Build a PitchDetector class that applies FFT and HPS to detect the fundamental frequency.
- Convert detected frequencies into note names, octaves, and cents deviation.
- Test the pitch detector by playing a guitar string and verifying the output.
Create pitch_detector.py with the PitchDetector class
The FFT transforms a chunk of audio samples from the time domain into the frequency domain, revealing which frequencies are present. However, guitar strings produce harmonics (multiples of the fundamental frequency) that can confuse a simple peak-finding approach. The HPS algorithm solves this by downsampling the frequency spectrum and multiplying the copies together, which reinforces the true fundamental while suppressing overtones.
- Create a new file called pitch_detector.py in your guitar-tuner folder, then paste the following code:
import numpy as np
# All 12 chromatic note names in order
NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
# Standard guitar tuning frequencies in Hz
GUITAR_STRINGS = {
"E2": 82.41,
"A2": 110.00,
"D3": 146.83,
"G3": 196.00,
"B3": 246.94,
"E4": 329.63,
}
class PitchDetector:
def __init__(self, sample_rate=44100, buffer_size=4096, hps_harmonics=5):
self.sample_rate = sample_rate
self.buffer_size = buffer_size
self.hps_harmonics = hps_harmonics
# Guitar frequency range to detect
self.min_freq = 60.0
self.max_freq = 1500.0
def detect(self, audio_data):
# Apply Hann window to reduce spectral leakage
windowed = audio_data * np.hanning(len(audio_data))
# Compute FFT and get magnitude spectrum
fft_result = np.fft.rfft(windowed)
magnitude = np.abs(fft_result)
# Harmonic Product Spectrum: multiply downsampled copies
hps = magnitude.copy()
for h in range(2, self.hps_harmonics + 1):
decimated = magnitude[::h]
hps[:len(decimated)] *= decimated
# Limit search to guitar frequency range
min_index = int(self.min_freq * self.buffer_size / self.sample_rate)
max_index = int(self.max_freq * self.buffer_size / self.sample_rate)
if min_index >= max_index or max_index > len(hps):
return None
# Find the dominant frequency
peak_index = np.argmax(hps[min_index:max_index]) + min_index
frequency = peak_index * self.sample_rate / self.buffer_size
if frequency < self.min_freq or frequency > self.max_freq:
return None
# Convert frequency to MIDI note number
midi_note = 12 * np.log2(frequency / 440.0) + 69
note_index = int(round(midi_note)) % 12
octave = int(round(midi_note)) // 12 - 1
note_name = NOTE_NAMES[note_index]
# Cents deviation from nearest note (negative = flat, positive = sharp)
cents = (midi_note - round(midi_note)) * 100
closest_string = self._find_closest_string(frequency)
return {
"frequency": frequency,
"note_name": note_name,
"octave": octave,
"full_note": f"{note_name}{octave}",
"cents": cents,
"closest_string": closest_string,
}
def _find_closest_string(self, frequency):
# Find which standard guitar string is nearest to this frequency
closest = None
min_distance = float("inf")
for string_name, string_freq in GUITAR_STRINGS.items():
distance = abs(frequency - string_freq)
if distance < min_distance:
min_distance = distance
closest = string_name
return closest
What does this code do?
This module handles the full pipeline from raw audio to identified musical note:
- NOTE_NAMES and GUITAR_STRINGS define the reference data: the 12 chromatic notes and the standard tuning frequencies for each guitar string.
- np.hanning() applies a Hann window to smooth the edges of the audio buffer, preventing false frequency spikes at the boundaries.
- np.fft.rfft() computes the Fast Fourier Transform on the windowed audio, converting time-domain samples into a frequency-domain magnitude spectrum.
- The HPS loop downsamples the magnitude spectrum by factors 2 through 5 and multiplies the results together. This makes the fundamental frequency's peak grow while harmonic peaks get scattered and diminished.
- _find_closest_string() compares the detected frequency against all six standard guitar string frequencies and returns the nearest match.
- Save pitch_detector.py.
- You won't see any output yet. The next chunk adds a test runner so you can verify detection.
Understand the frequency-to-note conversion
The detect method contains a mathematical conversion from raw frequency (in Hz) to a musical note name and tuning accuracy. Here is how each piece works.
How does frequency become a note name?
The formula midi_note = 12 * numpy.log2(frequency / 440.0) + 69 converts any frequency to a MIDI note number. MIDI note 69 is A4 (440 Hz), and each increment of 1 represents one semitone.
The integer part of the MIDI number tells you which note and octave you are on. The fractional part tells you how far off you are from a perfect note, measured in cents (hundredths of a semitone). A cents value of -50 to +50 maps to flat through sharp.
The guitar range filter (60 Hz to 1500 Hz) ensures the detector ignores frequencies outside what a guitar can produce, avoiding false readings from background noise or electrical hum.
Test pitch detection standalone
To confirm the detector works, you'll add a test block that reads audio from your microphone and prints the detected note in real-time.
- In pitch_detector.py, add the following code at the very bottom of the file, below the class:
if __name__ == "__main__":
from audio_stream import AudioStream
audio = AudioStream()
detector = PitchDetector()
audio.start()
print("Playing a note... (Ctrl+C to stop)")
try:
while True:
data = audio.read()
if data is not None:
result = detector.detect(data)
if result:
print(
f"Note: {result['full_note']} | "
f"Freq: {result['frequency']:.1f} Hz | "
f"Cents: {result['cents']:+.1f} | "
f"String: {result['closest_string']}"
)
except KeyboardInterrupt:
pass
finally:
audio.stop()
print("Stopped.")
What does this test code do?
This __main__ block imports AudioStream from the module you built in Step 2, then continuously reads audio and feeds it to the detector. When a pitch is found, it prints the note name, frequency, cents deviation, and closest guitar string.
Press Ctrl+C to stop the loop at any time.
- Save pitch_detector.py.
- Run the pitch detector by running this command in your terminal:
python pitch_detector.py
- Play a guitar string (or hum a steady note) near your microphone. You should see output like Note: A2 | Freq: 110.2 Hz | Cents: +1.3 | String: A2 printed repeatedly.
- Press Ctrl+C to stop the script once you have confirmed it detects notes correctly.
Not seeing any output?
- Make sure you are making sound loud enough for the microphone to pick up. Speaking or humming loudly works if you do not have a guitar handy.
- Check that audio_stream.py is in the same guitar-tuner folder as pitch_detector.py. The import will fail if they are in different directories.
- If you see a Permission error, confirm your OS has granted microphone access to your terminal application.
Help me debug why pitch_detector.py isn't detecting any notes from my microphone.
✔️ Awesome, I've got everything!
Great. Double check you have saved pitch_detector.py with all the code above.
ⓧ I'd like to double check the full code
import numpy as np
NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
GUITAR_STRINGS = {
"E2": 82.41,
"A2": 110.00,
"D3": 146.83,
"G3": 196.00,
"B3": 246.94,
"E4": 329.63,
}
class PitchDetector:
def __init__(self, sample_rate=44100, buffer_size=4096, hps_harmonics=5):
self.sample_rate = sample_rate
self.buffer_size = buffer_size
self.hps_harmonics = hps_harmonics
self.min_freq = 60.0
self.max_freq = 1500.0
def detect(self, audio_data):
windowed = audio_data * np.hanning(len(audio_data))
fft_result = np.fft.rfft(windowed)
magnitude = np.abs(fft_result)
hps = magnitude.copy()
for h in range(2, self.hps_harmonics + 1):
decimated = magnitude[::h]
hps[:len(decimated)] *= decimated
min_index = int(self.min_freq * self.buffer_size / self.sample_rate)
max_index = int(self.max_freq * self.buffer_size / self.sample_rate)
if min_index >= max_index or max_index > len(hps):
return None
peak_index = np.argmax(hps[min_index:max_index]) + min_index
frequency = peak_index * self.sample_rate / self.buffer_size
if frequency < self.min_freq or frequency > self.max_freq:
return None
midi_note = 12 * np.log2(frequency / 440.0) + 69
note_index = int(round(midi_note)) % 12
octave = int(round(midi_note)) // 12 - 1
note_name = NOTE_NAMES[note_index]
cents = (midi_note - round(midi_note)) * 100
closest_string = self._find_closest_string(frequency)
return {
"frequency": frequency,
"note_name": note_name,
"octave": octave,
"full_note": f"{note_name}{octave}",
"cents": cents,
"closest_string": closest_string,
}
def _find_closest_string(self, frequency):
closest = None
min_distance = float("inf")
for string_name, string_freq in GUITAR_STRINGS.items():
distance = abs(frequency - string_freq)
if distance < min_distance:
min_distance = distance
closest = string_name
return closest
if __name__ == "__main__":
from audio_stream import AudioStream
audio = AudioStream()
detector = PitchDetector()
audio.start()
print("Playing a note... (Ctrl+C to stop)")
try:
while True:
data = audio.read()
if data is not None:
result = detector.detect(data)
if result:
print(
f"Note: {result['full_note']} | "
f"Freq: {result['frequency']:.1f} Hz | "
f"Cents: {result['cents']:+.1f} | "
f"String: {result['closest_string']}"
)
except KeyboardInterrupt:
pass
finally:
audio.stop()
print("Stopped.")
Your pitch detector is working. Next up, you'll build the visual GUI so the tuning feedback appears as a live progress bar instead of terminal text.
Build the GUI with CustomTkinter
Your pitch detection engine works, but raw terminal output isn't exactly user-friendly when you're mid-strum. You need a visual interface that shows the detected note, how far off you are, and which direction to adjust.
In this step, you'll build a dark-themed desktop window using CustomTkinter. The GUI will display the note name, frequency, cents deviation, and a color-coded tuning bar that tells you at a glance whether you're sharp, flat, or in tune.
In this step, get ready to:
- Create the TunerApp window class with dark mode and a grid layout.
- Build display widgets for note name, frequency, and a tuning indicator bar.
- Add update methods that change the display based on pitch detection results.
Create the TunerApp class and window layout
CustomTkinter wraps Python's built-in Tkinter library with modern, dark-themed widgets. You'll create a new file that defines the main application window and the top section where the detected note and frequency appear.
- In the VS Code Explorer sidebar, right-click inside your guitar-tuner folder and select New File.
- Name the file gui.py.
- Add the following code to set up the window and top-section labels:
import customtkinter
# Force dark appearance for all widgets
customtkinter.set_appearance_mode("dark")
class TunerApp(customtkinter.CTk):
def __init__(self):
super().__init__()
# Window configuration
self.title("Guitar Tuner")
self.geometry("500x400")
self.resizable(False, False)
# Center all widgets in the window
self.grid_columnconfigure(0, weight=1)
# Large note name display (e.g., "A4")
self.note_label = customtkinter.CTkLabel(
self, text="--", font=("Arial", 72)
)
self.note_label.grid(row=0, column=0, padx=20, pady=(30, 5))
# Frequency readout below the note name
self.freq_label = customtkinter.CTkLabel(
self, text="--- Hz", font=("Arial", 16), text_color="gray60"
)
self.freq_label.grid(row=1, column=0, padx=20, pady=(0, 20))
What does this code do?
This sets up the application shell and the top display area.
- customtkinter.set_appearance_mode("dark") applies dark theme globally before any widgets are created.
- TunerApp inherits from customtkinter.CTk, which is the main window class (equivalent to Tkinter's Tk).
- grid_columnconfigure(0, weight=1) makes column 0 expand to fill the window width, centering all widgets.
- note_label uses a 72pt font so the note name is instantly readable from a distance.
You won't see a change yet. The next chunk adds the tuning indicator that makes the display functional.
Build the tuning indicator and info labels
The tuning indicator is a horizontal progress bar with FLAT on the left and SHARP on the right. When the bar sits at the center (0.5), the note is perfectly in tune.
- In gui.py, add the following code directly below the self.freq_label.grid(...) line, still inside the __init__ method:
# Tuning indicator section: FLAT | progress bar | SHARP
indicator_frame = customtkinter.CTkFrame(self, fg_color="transparent")
indicator_frame.grid(row=2, column=0, padx=40, pady=10, sticky="ew")
indicator_frame.grid_columnconfigure(1, weight=1)
self.flat_label = customtkinter.CTkLabel(
indicator_frame, text="FLAT", font=("Arial", 14), text_color="gray60"
)
self.flat_label.grid(row=0, column=0, padx=(0, 10))
# Progress bar: 0.0 = very flat, 0.5 = in tune, 1.0 = very sharp
self.progress_bar = customtkinter.CTkProgressBar(
indicator_frame, width=300, height=20, mode="determinate"
)
self.progress_bar.grid(row=0, column=1, sticky="ew")
self.progress_bar.set(0.5)
self.sharp_label = customtkinter.CTkLabel(
indicator_frame, text="SHARP", font=("Arial", 14), text_color="gray60"
)
self.sharp_label.grid(row=0, column=2, padx=(10, 0))
# Cents deviation readout
self.cents_label = customtkinter.CTkLabel(
self, text="0 cents", font=("Arial", 14), text_color="gray60"
)
self.cents_label.grid(row=3, column=0, padx=20, pady=(5, 10))
# Closest guitar string indicator
self.string_label = customtkinter.CTkLabel(
self, text="Closest string: --", font=("Arial", 14), text_color="gray60"
)
self.string_label.grid(row=4, column=0, padx=20, pady=(0, 10))
# Status message at the bottom
self.status_label = customtkinter.CTkLabel(
self, text="Listening...", font=("Arial", 12), text_color="gray50"
)
self.status_label.grid(row=5, column=0, padx=20, pady=(10, 20))
What does this code do?
This builds the middle and bottom sections of the tuner display.
- indicator_frame is a transparent container that holds the FLAT label, progress bar, and SHARP label in a horizontal row.
- The progress bar starts at 0.5 (center position). Values below 0.5 mean flat, values above 0.5 mean sharp.
- cents_label shows the numeric deviation. string_label shows which guitar string is closest to the detected pitch.
- status_label at the bottom provides feedback like "Listening..." or error messages.
You won't see a change yet. The next chunk adds the methods that update these widgets with real data.
Add the update and reset methods
The GUI needs two methods. One updates all the display widgets when a note is detected. The other resets everything to a blank state when no signal is present.
- Below the __init__ method (outdented one level so it's a new method of the class), add the update_display method:
def update_display(self, note_name, frequency, cents, closest_string):
# Update all labels with detected values
self.note_label.configure(text=note_name)
self.freq_label.configure(text=f"{frequency:.1f} Hz")
self.cents_label.configure(text=f"{cents:+.0f} cents")
self.string_label.configure(text=f"Closest string: {closest_string}")
# Map cents (-50 to +50) to bar position (0.0 to 1.0)
bar_value = 0.5 + (cents / 100.0)
bar_value = max(0.0, min(1.0, bar_value))
self.progress_bar.set(bar_value)
# Color code accuracy: green = great, yellow = close, red = far off
if abs(cents) <= 5:
color = ("#2ecc71", "#27ae60")
elif abs(cents) <= 15:
color = ("#f39c12", "#e67e22")
else:
color = ("#e74c3c", "#c0392b")
self.progress_bar.configure(progress_color=color)
What does this code do?
This method receives pitch detection results and updates every widget in the GUI.
- The bar position formula 0.5 + (cents / 100.0) converts a -50 to +50 cent range into a 0.0 to 1.0 range. Zero cents lands at exactly 0.5 (center).
- The color tuple format ("#2ecc71", "#27ae60") provides light and dark mode variants. CustomTkinter picks the appropriate one automatically.
- Green appears within 5 cents (practically in tune). Yellow within 15 cents (close). Red for anything further off.
You won't see a change yet. The reset method and test block below will let you run the GUI.
- Below the update_display method, add the show_no_signal method and the standalone test block:
def show_no_signal(self):
# Reset all widgets to their default "waiting" state
self.note_label.configure(text="--")
self.freq_label.configure(text="--- Hz")
self.cents_label.configure(text="0 cents")
self.string_label.configure(text="Closest string: --")
self.progress_bar.set(0.5)
self.progress_bar.configure(progress_color=("gray50", "gray40"))
# Test the GUI standalone with sample data
if __name__ == "__main__":
app = TunerApp()
app.update_display("A4", 440.0, -3.2, "E4")
app.mainloop()
What does this code do?
- show_no_signal() resets the display to dashes and a neutral gray bar. The tuning loop will call this when no sound is detected.
- The __main__ block creates the app and immediately calls update_display with sample values (A4 at 440 Hz, slightly flat by 3.2 cents) so you can see the full layout.
- Save gui.py.
- Run the GUI test by running this command in your terminal:
python gui.py
You should see a dark-themed window titled "Guitar Tuner" with "A4" in large text, "440.0 Hz" below it, and a progress bar slightly left of center (indicating 3.2 cents flat). Close the window when you're done.
Window not appearing?
- Make sure your virtual environment is still active (you should see (venv) in your terminal prompt). If not, reactivate it.
- Check for indentation errors. The update_display and show_no_signal methods should be indented one level (4 spaces) inside the class, not inside __init__.
- If you see a ModuleNotFoundError for customtkinter, confirm it's installed by running pip list and looking for customtkinter.
Why isn't my CustomTkinter window showing up when I run gui.py?
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
Here's the complete gui.py file for comparison:
import customtkinter
customtkinter.set_appearance_mode("dark")
class TunerApp(customtkinter.CTk):
def __init__(self):
super().__init__()
self.title("Guitar Tuner")
self.geometry("500x400")
self.resizable(False, False)
self.grid_columnconfigure(0, weight=1)
self.note_label = customtkinter.CTkLabel(
self, text="--", font=("Arial", 72)
)
self.note_label.grid(row=0, column=0, padx=20, pady=(30, 5))
self.freq_label = customtkinter.CTkLabel(
self, text="--- Hz", font=("Arial", 16), text_color="gray60"
)
self.freq_label.grid(row=1, column=0, padx=20, pady=(0, 20))
indicator_frame = customtkinter.CTkFrame(self, fg_color="transparent")
indicator_frame.grid(row=2, column=0, padx=40, pady=10, sticky="ew")
indicator_frame.grid_columnconfigure(1, weight=1)
self.flat_label = customtkinter.CTkLabel(
indicator_frame, text="FLAT", font=("Arial", 14), text_color="gray60"
)
self.flat_label.grid(row=0, column=0, padx=(0, 10))
self.progress_bar = customtkinter.CTkProgressBar(
indicator_frame, width=300, height=20, mode="determinate"
)
self.progress_bar.grid(row=0, column=1, sticky="ew")
self.progress_bar.set(0.5)
self.sharp_label = customtkinter.CTkLabel(
indicator_frame, text="SHARP", font=("Arial", 14), text_color="gray60"
)
self.sharp_label.grid(row=0, column=2, padx=(10, 0))
self.cents_label = customtkinter.CTkLabel(
self, text="0 cents", font=("Arial", 14), text_color="gray60"
)
self.cents_label.grid(row=3, column=0, padx=20, pady=(5, 10))
self.string_label = customtkinter.CTkLabel(
self, text="Closest string: --", font=("Arial", 14), text_color="gray60"
)
self.string_label.grid(row=4, column=0, padx=20, pady=(0, 10))
self.status_label = customtkinter.CTkLabel(
self, text="Listening...", font=("Arial", 12), text_color="gray50"
)
self.status_label.grid(row=5, column=0, padx=20, pady=(10, 20))
def update_display(self, note_name, frequency, cents, closest_string):
self.note_label.configure(text=note_name)
self.freq_label.configure(text=f"{frequency:.1f} Hz")
self.cents_label.configure(text=f"{cents:+.0f} cents")
self.string_label.configure(text=f"Closest string: {closest_string}")
bar_value = 0.5 + (cents / 100.0)
bar_value = max(0.0, min(1.0, bar_value))
self.progress_bar.set(bar_value)
if abs(cents) <= 5:
color = ("#2ecc71", "#27ae60")
elif abs(cents) <= 15:
color = ("#f39c12", "#e67e22")
else:
color = ("#e74c3c", "#c0392b")
self.progress_bar.configure(progress_color=color)
def show_no_signal(self):
self.note_label.configure(text="--")
self.freq_label.configure(text="--- Hz")
self.cents_label.configure(text="0 cents")
self.string_label.configure(text="Closest string: --")
self.progress_bar.set(0.5)
self.progress_bar.configure(progress_color=("gray50", "gray40"))
if __name__ == "__main__":
app = TunerApp()
app.update_display("A4", 440.0, -3.2, "E4")
app.mainloop()
Your tuner now has a proper face. Next up, you'll wire the audio capture, pitch detection, and this GUI together into a single real-time application.
Connect Everything into a Working Tuner
You now have three standalone modules: AudioStream captures raw audio, PitchDetector identifies notes from that audio, and TunerApp displays results in a dark-themed window.
The missing piece is a conductor that orchestrates these modules in real-time. In this step, you will create main.py to wire them together into a live guitar tuner that reads audio, detects pitch, and updates the display continuously without freezing the GUI.
In this step, get ready to:
- Create main.py to connect audio capture, pitch detection, and the GUI.
- Add graceful error handling and clean shutdown behaviour.
- Run the tuner and verify it detects guitar notes in real-time.
Create the main entry point
A GUI application needs a non-blocking event loop. If you read audio in a simple while True loop, the window freezes because the GUI never gets a chance to redraw.
The solution is app.after(), which schedules a function to run after a delay without blocking the GUI event loop.
- Create a new file called main.py inside your guitar-tuner folder.
- Add the following code to main.py:
import sys
from audio_stream import AudioStream
from pitch_detector import PitchDetector
from gui import TunerApp
def main():
# Create instances of all three modules
app = TunerApp()
audio = AudioStream()
detector = PitchDetector()
# Try to start the microphone stream
try:
audio.start()
except Exception as e:
app.status_label.configure(text=f"Mic error: {e}", text_color="red")
app.mainloop()
return
app.status_label.configure(text="Listening...")
def tuning_loop():
# Read audio and detect pitch each cycle
data = audio.read()
if data is not None:
result = detector.detect(data)
if result:
app.update_display(
result["full_note"],
result["frequency"],
result["cents"],
result["closest_string"],
)
else:
app.show_no_signal()
else:
app.show_no_signal()
# Schedule the next update in 50ms
app.after(50, tuning_loop)
def on_close():
# Clean up audio resources before closing
audio.stop()
app.destroy()
# Register the close handler and start the loop
app.protocol("WM_DELETE_WINDOW", on_close)
app.after(200, tuning_loop)
app.mainloop()
if __name__ == "__main__":
main()
What does this code do?
This file orchestrates your three modules into a real-time loop:
- The try/except around audio.start() catches microphone errors and displays them in the GUI instead of crashing.
- The tuning_loop() function reads one buffer of audio, detects the pitch, and updates the display. At the end, it reschedules itself 50ms later.
- The on_close() function ensures the audio stream is stopped before the window is destroyed, preventing resource leaks.
- The initial app.after(200, tuning_loop) waits 200ms before starting detection, giving the audio buffer time to fill.
Why app.after() instead of a while loop?
GUI frameworks like CustomTkinter run their own event loop (mainloop()). A blocking while True loop would prevent the GUI from processing clicks, redraws, and window events.
Using app.after(50, tuning_loop) hands control back to the GUI between each audio read. This gives you roughly 20 updates per second while keeping the interface responsive.
- Save main.py.
Seeing a red underline on the imports?
If VS Code shows import errors for audio_stream, pitch_detector, or gui, make sure all four files are in the same guitar-tuner folder. Also confirm your virtual environment is selected as the Python interpreter in VS Code.
help me fix import errors in my guitar tuner project
Run the complete tuner
With all four files in place, it is time to see the tuner in action.
- Launch the tuner by running this command in your terminal:
python main.py
The dark-themed Guitar Tuner window should appear with the status label showing "Listening..." at the bottom.
- Play an open A string on your guitar (or hum a steady note near your microphone).
- Watch the large note label update to show the detected note (e.g. A2 for the open A string).
- Observe the progress bar shift left for flat, right for sharp, and stay centred when in tune.
- Close the window normally by clicking the X button to confirm the app exits cleanly without errors in the terminal.
What should I see?
When no sound is playing, the display shows "--" and the bar stays centred (grey). As soon as you play a note, the note name appears in large text, the frequency and cents labels update, and the bar turns green (within 5 cents), yellow (within 15 cents), or red (more than 15 cents off).
Window opens but notes aren't detected?
- Make sure your microphone is not muted in your operating system's sound settings.
- Play the note close to the microphone. The silence threshold filters out quiet background noise.
- If you see "Mic error" in red at the bottom, another application may be using your microphone. Close other apps that use the mic and try again.
help me debug why my guitar tuner isn't detecting notes
✔️ Awesome, I've got everything!
Great. Double check you have saved main.py and that your tuner detects notes when you play near the microphone.
ⓧ I'd like to double check the full code
Here is the complete main.py for comparison:
import sys
from audio_stream import AudioStream
from pitch_detector import PitchDetector
from gui import TunerApp
def main():
app = TunerApp()
audio = AudioStream()
detector = PitchDetector()
try:
audio.start()
except Exception as e:
app.status_label.configure(text=f"Mic error: {e}", text_color="red")
app.mainloop()
return
app.status_label.configure(text="Listening...")
def tuning_loop():
data = audio.read()
if data is not None:
result = detector.detect(data)
if result:
app.update_display(
result["full_note"],
result["frequency"],
result["cents"],
result["closest_string"],
)
else:
app.show_no_signal()
else:
app.show_no_signal()
app.after(50, tuning_loop)
def on_close():
audio.stop()
app.destroy()
app.protocol("WM_DELETE_WINDOW", on_close)
app.after(200, tuning_loop)
app.mainloop()
if __name__ == "__main__":
main()
Your guitar tuner is fully functional. You have captured live audio, processed it with FFT and HPS, and displayed the results in a responsive desktop app. Next up, the Secret Mission challenges you to add reference tone playback so you can tune by ear.
Secret mission
Add a Reference Tone Player
Your tuner listens and detects notes. But what if you could also hear the target note? Add a reference tone player that generates a pure sine wave at each guitar string's frequency, so beginners can tune by ear even without a trained musical ear.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- The guitar-tuner project folder on your Desktop (contains all Python files).
- The venv virtual environment folder inside your project (about 50-100 MB).
Keep everything running
No action needed. Choose this if you're still actively building or want to keep your tuner ready to use.
- Your project stays on your Desktop in the guitar-tuner folder.
- The virtual environment stays intact with all packages installed.
- Run python main.py any time you want to tune your guitar.
Pause - I'll come back to this later
Deactivate the virtual environment to free up your terminal session. Your files and packages stay on disk for next time.
- Deactivate the virtual environment by running:
deactivate
- When you come back, reactivate the environment with venv\Scripts\activate (Windows) or source venv/bin/activate (macOS/Linux).
Delete - I don't want to use this again
Remove all project files and start fresh. This deletes your code and the virtual environment permanently.
- Deactivate the virtual environment first by running:
deactivate
- Navigate to your Desktop and delete the entire project folder.
macOS
cd ~/Desktop
rm -rf guitar-tuner
Windows
cd ~/Desktop
rmdir /s /q guitar-tuner
- Confirm the folder is gone by running ls and checking that guitar-tuner no longer appears.
Nice Work!
Nice Work!
You just built a fully functional real-time guitar tuner from scratch. Your app captures live microphone input, applies digital signal processing to identify musical notes, and displays tuning feedback in a polished desktop interface.
You've learned how to:
- Build a real-time audio capture pipeline using PyAudio that filters silence and streams microphone data as NumPy arrays.
- Implement FFT-based pitch detection with the Harmonic Product Spectrum algorithm to identify musical notes from complex audio signals.
- Connect real-time audio processing to a responsive CustomTkinter GUI using non-blocking scheduling with app.after().
- Secret Mission: Added reference tone generation so beginners can tune by ear, using threaded playback to keep the tuner responsive.
Ready to quiz yourself?