Appendix B: Signal Processing Basics

Fundamentals for Audio and Sensor Data

This appendix covers signal processing concepts for LAB04 (Keyword Spotting), LAB10 (EMG), and LAB12 (Streaming).

Time Domain vs Frequency Domain

flowchart LR
    T[Time Domain] -->|FFT| F[Frequency Domain]
    F -->|Inverse FFT| T

Time Domain

Signal amplitude over time:

Code
import numpy as np
import matplotlib.pyplot as plt

# Generate a signal: 5Hz + 20Hz components
fs = 1000  # Sampling rate
t = np.linspace(0, 1, fs)
signal = np.sin(2 * np.pi * 5 * t) + 0.5 * np.sin(2 * np.pi * 20 * t)

plt.figure(figsize=(10, 3))
plt.plot(t[:200], signal[:200])
plt.xlabel('Time (s)')
plt.ylabel('Amplitude')
plt.title('Time Domain Signal')
plt.grid(True)
plt.show()

Frequency Domain

Signal components by frequency:

Code
from scipy.fft import fft, fftfreq

# Compute FFT
N = len(signal)
yf = fft(signal)
xf = fftfreq(N, 1/fs)

plt.figure(figsize=(10, 3))
plt.plot(xf[:N//2], np.abs(yf[:N//2]) * 2/N)
plt.xlabel('Frequency (Hz)')
plt.ylabel('Magnitude')
plt.title('Frequency Domain (FFT)')
plt.xlim(0, 50)
plt.grid(True)
plt.show()

Filtering

Low-Pass Filter

Removes high frequencies (keeps slow changes):

Code
from scipy.signal import butter, filtfilt

def lowpass_filter(data, cutoff, fs, order=4):
    """Butterworth low-pass filter"""
    nyquist = fs / 2
    normalized_cutoff = cutoff / nyquist
    b, a = butter(order, normalized_cutoff, btype='low')
    return filtfilt(b, a, data)

# Apply 10Hz low-pass filter
filtered = lowpass_filter(signal, cutoff=10, fs=fs)

plt.figure(figsize=(10, 3))
plt.plot(t[:200], signal[:200], alpha=0.5, label='Original')
plt.plot(t[:200], filtered[:200], label='Filtered (10Hz LP)')
plt.xlabel('Time (s)')
plt.ylabel('Amplitude')
plt.legend()
plt.grid(True)
plt.show()

Band-Pass Filter

Keeps only frequencies in a range (used in EMG):

Code
def bandpass_filter(data, low, high, fs, order=4):
    """Butterworth band-pass filter"""
    nyquist = fs / 2
    low_norm = low / nyquist
    high_norm = high / nyquist
    b, a = butter(order, [low_norm, high_norm], btype='band')
    return filtfilt(b, a, data)

# EMG typically uses 20-450Hz band-pass
# emg_filtered = bandpass_filter(emg_signal, 20, 450, fs=1000)

Filter Frequency Response

Code
from scipy.signal import freqz

# Design filter
b, a = butter(4, 10/(fs/2), btype='low')

# Compute frequency response
w, h = freqz(b, a, worN=2000, fs=fs)

plt.figure(figsize=(10, 4))
plt.subplot(1, 2, 1)
plt.plot(w, 20 * np.log10(abs(h)))
plt.xlabel('Frequency (Hz)')
plt.ylabel('Magnitude (dB)')
plt.title('Magnitude Response')
plt.grid(True)
plt.xlim(0, 50)

plt.subplot(1, 2, 2)
plt.plot(w, np.unwrap(np.angle(h)))
plt.xlabel('Frequency (Hz)')
plt.ylabel('Phase (radians)')
plt.title('Phase Response')
plt.grid(True)
plt.xlim(0, 50)
plt.tight_layout()
plt.show()

Spectrograms

Time-frequency representation:

Code
from scipy.signal import spectrogram

# Create a chirp signal (frequency changes over time)
t = np.linspace(0, 2, 2000)
chirp = np.sin(2 * np.pi * (5 + 20*t) * t)

# Compute spectrogram
f, t_spec, Sxx = spectrogram(chirp, fs=1000, nperseg=128)

plt.figure(figsize=(10, 4))
plt.pcolormesh(t_spec, f, 10 * np.log10(Sxx), shading='gouraud')
plt.ylabel('Frequency (Hz)')
plt.xlabel('Time (s)')
plt.title('Spectrogram')
plt.colorbar(label='Power (dB)')
plt.ylim(0, 100)
plt.show()

MFCCs for Audio

Mel-Frequency Cepstral Coefficients are used in keyword spotting:

Code
import librosa

# Load audio
y, sr = librosa.load('audio.wav', sr=16000)

# Compute MFCCs
mfccs = librosa.feature.mfcc(y=y, sr=sr, n_mfcc=40)

# Plot
plt.figure(figsize=(10, 4))
librosa.display.specshow(mfccs, sr=sr, x_axis='time')
plt.colorbar()
plt.title('MFCCs')
plt.show()

Why MFCCs?

  1. Mel scale: Mimics human hearing (logarithmic)
  2. Compact: ~40 coefficients vs thousands of FFT bins
  3. Robust: Less sensitive to noise than raw spectrograms

Windowing

Divide continuous signals into overlapping frames:

Code
def frame_signal(signal, frame_size, hop_size):
    """Split signal into overlapping frames"""
    frames = []
    for i in range(0, len(signal) - frame_size, hop_size):
        frames.append(signal[i:i + frame_size])
    return np.array(frames)

# Example: 25ms frames, 10ms hop
frame_size = int(0.025 * 1000)  # 25 samples at 1kHz
hop_size = int(0.010 * 1000)    # 10 samples

frames = frame_signal(signal, frame_size, hop_size)
print(f"Signal length: {len(signal)}, Frames: {frames.shape}")
Signal length: 1000, Frames: (98, 25)

Window Functions

Code
from scipy.signal import windows

n = 64  # Window size

plt.figure(figsize=(10, 4))
plt.plot(windows.boxcar(n), label='Rectangular')
plt.plot(windows.hamming(n), label='Hamming')
plt.plot(windows.hann(n), label='Hann')
plt.plot(windows.blackman(n), label='Blackman')
plt.xlabel('Sample')
plt.ylabel('Amplitude')
plt.title('Window Functions')
plt.legend()
plt.grid(True)
plt.show()

Feature Extraction

RMS (Root Mean Square)

Measures signal energy:

Code
def rms(signal):
    return np.sqrt(np.mean(signal**2))

# Example
print(f"RMS: {rms(signal):.4f}")
RMS: 0.7902

Zero Crossing Rate

Counts sign changes (used in speech/music):

Code
def zero_crossing_rate(signal):
    return np.sum(np.diff(np.sign(signal)) != 0) / len(signal)

print(f"ZCR: {zero_crossing_rate(signal):.4f}")
ZCR: 0.0200

Peak Detection

Find local maxima:

Code
from scipy.signal import find_peaks

# Find peaks
peaks, properties = find_peaks(signal[:200], height=0.5, distance=20)

plt.figure(figsize=(10, 3))
plt.plot(signal[:200])
plt.plot(peaks, signal[peaks], 'rx', markersize=10)
plt.xlabel('Sample')
plt.ylabel('Amplitude')
plt.title('Peak Detection')
plt.grid(True)
plt.show()

Quick Reference

Concept Function Use Case
FFT scipy.fft.fft() Frequency analysis
Low-pass butter(..., 'low') Smooth sensor data
Band-pass butter(..., 'band') EMG, audio
Spectrogram scipy.signal.spectrogram() Audio classification
MFCCs librosa.feature.mfcc() Keyword spotting
RMS np.sqrt(np.mean(x**2)) Energy/amplitude

Further Reading