Closed loop test suite, vibe coded, needs some refinement.
This commit is contained in:
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
test_results/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
*.so
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.venv/
|
||||
venv/
|
||||
*.wav
|
||||
*.png
|
||||
103
QUICKSTART.md
Normal file
103
QUICKSTART.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Quick Start Guide
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### 1. Run Your First Test
|
||||
|
||||
```bash
|
||||
python run_test.py \
|
||||
--pcb-version "v1.0" \
|
||||
--pcb-revision "A" \
|
||||
--software-version "initial" \
|
||||
--notes "First test run"
|
||||
```
|
||||
|
||||
**What happens:**
|
||||
- Auto-detects your Scarlett audio interface
|
||||
- Plays test tones at 7 frequencies (100 Hz to 8 kHz)
|
||||
- Records input/output on both channels
|
||||
- Calculates latency, THD, and SNR
|
||||
- Saves results to `test_results/YYYYMMDD_HHMMSS_results.yaml`
|
||||
|
||||
### 2. View Results
|
||||
|
||||
```bash
|
||||
# List all test results
|
||||
python view_results.py --list
|
||||
|
||||
# View specific test (example)
|
||||
python view_results.py test_results/20260226_123456_results.yaml
|
||||
|
||||
# View example result
|
||||
python view_results.py example_test_result.yaml
|
||||
```
|
||||
|
||||
### 3. Compare Different PCB Versions
|
||||
|
||||
Run multiple tests with different metadata:
|
||||
|
||||
```bash
|
||||
# Test PCB v1.0
|
||||
python run_test.py --pcb-version "v1.0" --pcb-revision "A" --software-version "abc123"
|
||||
|
||||
# Test PCB v2.0
|
||||
python run_test.py --pcb-version "v2.0" --pcb-revision "A" --software-version "abc123"
|
||||
|
||||
# Compare by viewing both YAML files
|
||||
python view_results.py test_results/20260226_120000_results.yaml
|
||||
python view_results.py test_results/20260226_130000_results.yaml
|
||||
```
|
||||
|
||||
## Understanding the Output
|
||||
|
||||
Each test produces metrics at 7 frequencies:
|
||||
|
||||
- **Latency (ms)**: Delay between channels (should be near 0 for loopback)
|
||||
- **THD Input (%)**: Distortion in channel 1 (lower is better)
|
||||
- **THD Output (%)**: Distortion in channel 2 (lower is better)
|
||||
- **SNR Input (dB)**: Signal quality in channel 1 (higher is better)
|
||||
- **SNR Output (dB)**: Signal quality in channel 2 (higher is better)
|
||||
|
||||
**Good values:**
|
||||
- THD: < 0.1% (< 0.01% is excellent)
|
||||
- SNR: > 80 dB (> 90 dB is excellent)
|
||||
- Latency: < 5 ms for loopback
|
||||
|
||||
## Configuration
|
||||
|
||||
Edit `config.yaml` to customize test parameters:
|
||||
|
||||
```yaml
|
||||
test_tones:
|
||||
frequencies: [1000] # Test only 1 kHz
|
||||
duration: 3.0 # Shorter test (3 seconds)
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Audio device not found:**
|
||||
```bash
|
||||
python -c "import sounddevice as sd; print(sd.query_devices())"
|
||||
```
|
||||
Update `device_name` in `config.yaml` to match your device.
|
||||
|
||||
**Permission errors:**
|
||||
Make scripts executable (Linux/Mac):
|
||||
```bash
|
||||
chmod +x run_test.py view_results.py
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Run baseline tests with known-good hardware
|
||||
2. Test different PCB revisions
|
||||
3. Track software version changes
|
||||
4. Archive YAML files for long-term tracking
|
||||
5. Build comparison scripts as needed
|
||||
107
README.md
Normal file
107
README.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# Closed Loop Audio Test Suite
|
||||
|
||||
Simple Python-based testing system for PCB audio hardware validation.
|
||||
|
||||
## Features
|
||||
|
||||
- **Automated Testing**: Latency, THD, and SNR measurements across multiple frequencies
|
||||
- **Metadata Tracking**: PCB version, revision, software version, timestamps, notes
|
||||
- **YAML Output**: Human-readable structured results
|
||||
- **Simple Workflow**: Run tests, view results, compare versions
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Install Dependencies
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 2. Run a Test
|
||||
|
||||
```bash
|
||||
python run_test.py \
|
||||
--pcb-version "v2.1" \
|
||||
--pcb-revision "A" \
|
||||
--software-version "a3f2b1c" \
|
||||
--notes "Replaced capacitor C5"
|
||||
```
|
||||
|
||||
### 3. View Results
|
||||
|
||||
```bash
|
||||
# View specific test
|
||||
python view_results.py test_results/20260226_123456_results.yaml
|
||||
|
||||
# List all tests
|
||||
python view_results.py --list
|
||||
|
||||
# View latest test
|
||||
python view_results.py test_results/*.yaml | tail -1
|
||||
```
|
||||
|
||||
## Test Metrics
|
||||
|
||||
- **Latency**: Round-trip delay between input and output channels (ms)
|
||||
- **THD**: Total Harmonic Distortion for input and output (%)
|
||||
- **SNR**: Signal-to-Noise Ratio for input and output (dB)
|
||||
|
||||
Tests run at multiple frequencies: 100 Hz, 250 Hz, 500 Hz, 1 kHz, 2 kHz, 4 kHz, 8 kHz
|
||||
|
||||
## Output Format
|
||||
|
||||
Results are saved as YAML files in `test_results/`:
|
||||
|
||||
```yaml
|
||||
metadata:
|
||||
test_id: 20260226_123456
|
||||
timestamp: '2026-02-26T12:34:56.789012'
|
||||
pcb_version: v2.1
|
||||
pcb_revision: A
|
||||
software_version: a3f2b1c
|
||||
notes: Replaced capacitor C5
|
||||
test_results:
|
||||
- frequency_hz: 1000
|
||||
latency_ms: 2.345
|
||||
thd_input_percent: 0.012
|
||||
thd_output_percent: 0.034
|
||||
snr_input_db: 92.5
|
||||
snr_output_db: 89.2
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Edit `config.yaml` to customize:
|
||||
- Audio device settings
|
||||
- Test frequencies
|
||||
- Test duration
|
||||
- Output options
|
||||
|
||||
## Hardware Setup
|
||||
|
||||
```
|
||||
Laptop <-> Audio Interface (Scarlett) <-> DUT <-> Audio Interface (Scarlett) <-> Laptop
|
||||
Output Channels 1&2 Input Channels 1&2
|
||||
```
|
||||
|
||||
The system auto-detects Focusrite Scarlett audio interfaces.
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
closed_loop_audio_test_suite/
|
||||
├── config.yaml # Test configuration
|
||||
├── run_test.py # Main test runner
|
||||
├── view_results.py # Results viewer
|
||||
├── src/
|
||||
│ └── audio_tests.py # Core test functions
|
||||
└── test_results/ # YAML output files
|
||||
└── YYYYMMDD_HHMMSS_results.yaml
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- Each test run creates a timestamped YAML file
|
||||
- Results are self-contained with full metadata
|
||||
- Compare tests by opening multiple YAML files
|
||||
- Archive old tests by moving YAML files to subdirectories
|
||||
38
ai_stuff/prompts.md
Normal file
38
ai_stuff/prompts.md
Normal file
@@ -0,0 +1,38 @@
|
||||
|
||||
I want to perform tests of hardware. I need a good system. The main thing is a repo with python scripts. What should they output? I want to be able to trigger a test run for a PCB where i can give some infos about the PCB: Version/Revision, Adjustments (comment), Software version (git commit hash e.g.). And i want the results to be structuredtly saved, with all the information, + datetime. The test results are a latency, a THD input vs output, a SNR input vs output, in the future possible also images. But for now 4 numbers basically. What are my options to structure this nicely, run tests easily and see the results
|
||||
|
||||
|
||||
|
||||
# Closed Loop Audio Test Suite
|
||||
|
||||
## Overview
|
||||
|
||||
Laptop <-> Audio Interface -> Beacon -> nrf Ref Board or Scout -> Audio Interface (-> Laptop)
|
||||
Audio Interface -> Audio Interface (Loop back)
|
||||
|
||||
Compare the two paths
|
||||
Loop back vs Radio
|
||||
|
||||
|
||||
## Capabilities
|
||||
- Measure latency (round trip)
|
||||
- Measure THD input vs output
|
||||
- Measure SNR input vs output
|
||||
- Compare fourier transform of input vs output
|
||||
|
||||
For now start with detecting the audio interface and play a test tone on both channels.
|
||||
Record the audio on both input channels and measure the latency. (most likley 0ms right now, dont be confused)
|
||||
Calculate the THD and SNR for both channels.
|
||||
|
||||
|
||||
Use the connected scarlett focusrite interface, figure out how to use it.
|
||||
|
||||
Play a test tone (that is not repeating) to figure out the latency between input channel 1 and input channel 2.
|
||||
|
||||
Visualize all the results.
|
||||
|
||||
Play a sine in different frequencies, and for every frequency 5 sec long and do thd of channel 1 and of channel 2, to compare the quality loss.
|
||||
|
||||
Dont do fourier yet.
|
||||
|
||||
Do a simple project.
|
||||
17
config.yaml
Normal file
17
config.yaml
Normal file
@@ -0,0 +1,17 @@
|
||||
# Hardware Test Configuration
|
||||
|
||||
audio:
|
||||
sample_rate: 44100
|
||||
channels: 2
|
||||
device_name: "Scarlett" # Will auto-detect Focusrite Scarlett
|
||||
|
||||
test_tones:
|
||||
frequencies: [100, 250, 500, 1000, 2000, 4000, 8000] # Hz
|
||||
duration: 5.0 # seconds per frequency
|
||||
amplitude: 0.5 # 0.0 to 1.0
|
||||
latency_runs: 5 # Number of latency measurements to average
|
||||
|
||||
output:
|
||||
results_dir: "test_results"
|
||||
save_plots: true
|
||||
save_raw_audio: false
|
||||
50
example_test_result.yaml
Normal file
50
example_test_result.yaml
Normal file
@@ -0,0 +1,50 @@
|
||||
metadata:
|
||||
test_id: 20260226_123456
|
||||
timestamp: '2026-02-26T12:34:56.789012'
|
||||
pcb_version: v2.1
|
||||
pcb_revision: A
|
||||
software_version: a3f2b1c8d9e
|
||||
notes: Baseline test with new capacitor values
|
||||
test_results:
|
||||
- frequency_hz: 100
|
||||
latency_ms: 2.341
|
||||
thd_input_percent: 0.015
|
||||
thd_output_percent: 0.042
|
||||
snr_input_db: 88.5
|
||||
snr_output_db: 85.2
|
||||
- frequency_hz: 250
|
||||
latency_ms: 2.338
|
||||
thd_input_percent: 0.012
|
||||
thd_output_percent: 0.038
|
||||
snr_input_db: 90.3
|
||||
snr_output_db: 87.1
|
||||
- frequency_hz: 500
|
||||
latency_ms: 2.345
|
||||
thd_input_percent: 0.011
|
||||
thd_output_percent: 0.035
|
||||
snr_input_db: 91.7
|
||||
snr_output_db: 88.9
|
||||
- frequency_hz: 1000
|
||||
latency_ms: 2.342
|
||||
thd_input_percent: 0.009
|
||||
thd_output_percent: 0.029
|
||||
snr_input_db: 93.2
|
||||
snr_output_db: 90.5
|
||||
- frequency_hz: 2000
|
||||
latency_ms: 2.339
|
||||
thd_input_percent: 0.013
|
||||
thd_output_percent: 0.041
|
||||
snr_input_db: 92.1
|
||||
snr_output_db: 89.3
|
||||
- frequency_hz: 4000
|
||||
latency_ms: 2.346
|
||||
thd_input_percent: 0.018
|
||||
thd_output_percent: 0.052
|
||||
snr_input_db: 89.8
|
||||
snr_output_db: 86.7
|
||||
- frequency_hz: 8000
|
||||
latency_ms: 2.344
|
||||
thd_input_percent: 0.024
|
||||
thd_output_percent: 0.067
|
||||
snr_input_db: 87.4
|
||||
snr_output_db: 84.1
|
||||
5
requirements.txt
Normal file
5
requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
numpy>=1.24.0
|
||||
scipy>=1.10.0
|
||||
sounddevice>=0.4.6
|
||||
PyYAML>=6.0
|
||||
matplotlib>=3.7.0
|
||||
96
run_test.py
Normal file
96
run_test.py
Normal file
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import yaml
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from src.audio_tests import run_single_test, run_latency_test
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Run PCB hardware audio tests')
|
||||
parser.add_argument('--pcb-version', required=True, help='PCB version (e.g., v2.1)')
|
||||
parser.add_argument('--pcb-revision', required=True, help='PCB revision (e.g., A, B, C)')
|
||||
parser.add_argument('--software-version', required=True, help='Software version (git commit hash)')
|
||||
parser.add_argument('--notes', default='', help='Adjustments or comments about this test')
|
||||
parser.add_argument('--config', default='config.yaml', help='Path to config file')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
with open(args.config, 'r') as f:
|
||||
config = yaml.safe_load(f)
|
||||
|
||||
timestamp = datetime.now()
|
||||
test_id = timestamp.strftime('%Y%m%d_%H%M%S')
|
||||
|
||||
results_dir = Path(config['output']['results_dir'])
|
||||
results_dir.mkdir(exist_ok=True)
|
||||
|
||||
test_output_dir = results_dir / test_id
|
||||
test_output_dir.mkdir(exist_ok=True)
|
||||
|
||||
save_plots = config['output'].get('save_plots', False)
|
||||
|
||||
print(f"Starting audio test run: {test_id}")
|
||||
print(f"PCB: {args.pcb_version} Rev {args.pcb_revision}")
|
||||
print(f"Software: {args.software_version}")
|
||||
if save_plots:
|
||||
print(f"Plots will be saved to: {test_output_dir}")
|
||||
print("-" * 60)
|
||||
|
||||
print("\n[1/2] Running chirp-based latency test (5 measurements)...")
|
||||
try:
|
||||
latency_stats = run_latency_test(config, num_measurements=5,
|
||||
save_plots=save_plots, output_dir=test_output_dir)
|
||||
print(f"✓ Latency: avg={latency_stats['avg']:.3f}ms, "
|
||||
f"min={latency_stats['min']:.3f}ms, max={latency_stats['max']:.3f}ms")
|
||||
except Exception as e:
|
||||
print(f"✗ Error: {e}")
|
||||
latency_stats = {'avg': 0.0, 'min': 0.0, 'max': 0.0, 'std': 0.0, 'error': str(e)}
|
||||
|
||||
print("\n[2/2] Running frequency sweep tests...")
|
||||
test_results = []
|
||||
frequencies = config['test_tones']['frequencies']
|
||||
|
||||
for i, freq in enumerate(frequencies, 1):
|
||||
print(f"Testing frequency {i}/{len(frequencies)}: {freq} Hz...", end=' ', flush=True)
|
||||
try:
|
||||
result = run_single_test(freq, config, save_plots=save_plots, output_dir=test_output_dir)
|
||||
test_results.append(result)
|
||||
print("✓")
|
||||
except Exception as e:
|
||||
print(f"✗ Error: {e}")
|
||||
test_results.append({
|
||||
'frequency_hz': freq,
|
||||
'error': str(e)
|
||||
})
|
||||
|
||||
output_data = {
|
||||
'metadata': {
|
||||
'test_id': test_id,
|
||||
'timestamp': timestamp.isoformat(),
|
||||
'pcb_version': args.pcb_version,
|
||||
'pcb_revision': args.pcb_revision,
|
||||
'software_version': args.software_version,
|
||||
'notes': args.notes
|
||||
},
|
||||
'latency_test': latency_stats,
|
||||
'test_results': test_results
|
||||
}
|
||||
|
||||
output_file = results_dir / f"{test_id}_results.yaml"
|
||||
with open(output_file, 'w') as f:
|
||||
yaml.dump(output_data, f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
print("-" * 60)
|
||||
print(f"✓ Test complete! Results saved to: {output_file}")
|
||||
if save_plots:
|
||||
print(f"✓ Plots saved to: {test_output_dir}/")
|
||||
print(f"\nTo view results: python view_results.py {output_file}")
|
||||
print(f"To view plots: ls {test_output_dir}/*.png")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
0
src/__init__.py
Normal file
0
src/__init__.py
Normal file
281
src/audio_tests.py
Normal file
281
src/audio_tests.py
Normal file
@@ -0,0 +1,281 @@
|
||||
import numpy as np
|
||||
import sounddevice as sd
|
||||
from scipy import signal
|
||||
from typing import Tuple, Dict, List
|
||||
import matplotlib.pyplot as plt
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def find_audio_device(device_name: str = "Scarlett") -> tuple:
|
||||
devices = sd.query_devices()
|
||||
|
||||
for idx, device in enumerate(devices):
|
||||
if device_name.lower() in device['name'].lower():
|
||||
if device['max_input_channels'] >= 2 and device['max_output_channels'] >= 2:
|
||||
return (idx, idx)
|
||||
|
||||
default_device = sd.default.device
|
||||
if hasattr(default_device, '__getitem__'):
|
||||
input_dev = int(default_device[0]) if default_device[0] is not None else 0
|
||||
output_dev = int(default_device[1]) if default_device[1] is not None else 0
|
||||
else:
|
||||
input_dev = output_dev = int(default_device) if default_device is not None else 0
|
||||
|
||||
input_info = devices[input_dev]
|
||||
output_info = devices[output_dev]
|
||||
|
||||
if input_info['max_input_channels'] >= 2 and output_info['max_output_channels'] >= 2:
|
||||
print(f"Using default device - Input: {input_info['name']}, Output: {output_info['name']}")
|
||||
return (input_dev, output_dev)
|
||||
|
||||
raise RuntimeError(f"No suitable audio device found with 2+ input/output channels")
|
||||
|
||||
|
||||
def generate_test_tone(frequency: float, duration: float, sample_rate: int, amplitude: float = 0.5) -> np.ndarray:
|
||||
t = np.linspace(0, duration, int(sample_rate * duration), endpoint=False)
|
||||
tone = amplitude * np.sin(2 * np.pi * frequency * t)
|
||||
return tone
|
||||
|
||||
|
||||
def generate_chirp(duration: float, sample_rate: int, f0: float = 100, f1: float = 8000, amplitude: float = 0.5) -> np.ndarray:
|
||||
t = np.linspace(0, duration, int(sample_rate * duration), endpoint=False)
|
||||
chirp = amplitude * signal.chirp(t, f0, duration, f1, method='linear')
|
||||
return chirp
|
||||
|
||||
|
||||
def play_and_record(tone: np.ndarray, sample_rate: int, device_id: tuple, channels: int = 2) -> np.ndarray:
|
||||
output_signal = np.column_stack([tone, tone])
|
||||
|
||||
input_dev, output_dev = device_id
|
||||
recording = sd.playrec(output_signal, samplerate=sample_rate,
|
||||
channels=channels, device=(input_dev, output_dev), blocking=True)
|
||||
|
||||
return recording
|
||||
|
||||
|
||||
def calculate_latency(channel_1: np.ndarray, channel_2: np.ndarray, sample_rate: int) -> Tuple[float, np.ndarray, np.ndarray]:
|
||||
correlation = signal.correlate(channel_2, channel_1, mode='full')
|
||||
lag = np.argmax(np.abs(correlation)) - (len(channel_1) - 1)
|
||||
latency_ms = (lag / sample_rate) * 1000
|
||||
lags = np.arange(-len(channel_1) + 1, len(channel_1))
|
||||
return latency_ms, correlation, lags
|
||||
|
||||
|
||||
def calculate_latency_stats(channel_1: np.ndarray, channel_2: np.ndarray, sample_rate: int, num_runs: int = 5) -> Dict:
|
||||
latencies = []
|
||||
for _ in range(num_runs):
|
||||
latency, _, _ = calculate_latency(channel_1, channel_2, sample_rate)
|
||||
latencies.append(latency)
|
||||
|
||||
return {
|
||||
'avg': float(np.mean(latencies)),
|
||||
'min': float(np.min(latencies)),
|
||||
'max': float(np.max(latencies)),
|
||||
'std': float(np.std(latencies))
|
||||
}
|
||||
|
||||
|
||||
def calculate_thd(signal_data: np.ndarray, sample_rate: int, fundamental_freq: float) -> float:
|
||||
# Use middle segment (seconds 2-4) for calculation
|
||||
segment_start = int(sample_rate * 2)
|
||||
segment_end = int(sample_rate * 4)
|
||||
segment_end = min(segment_end, len(signal_data))
|
||||
|
||||
if segment_start >= len(signal_data):
|
||||
segment_start = len(signal_data) // 4
|
||||
segment_end = len(signal_data) * 3 // 4
|
||||
|
||||
signal_segment = signal_data[segment_start:segment_end]
|
||||
|
||||
fft = np.fft.rfft(signal_segment)
|
||||
freqs = np.fft.rfftfreq(len(signal_segment), 1/sample_rate)
|
||||
|
||||
fundamental_idx = np.argmin(np.abs(freqs - fundamental_freq))
|
||||
fundamental_power = np.abs(fft[fundamental_idx])**2
|
||||
|
||||
harmonics_power = 0
|
||||
for n in range(2, 6):
|
||||
harmonic_freq = n * fundamental_freq
|
||||
if harmonic_freq > sample_rate / 2:
|
||||
break
|
||||
harmonic_idx = np.argmin(np.abs(freqs - harmonic_freq))
|
||||
harmonics_power += np.abs(fft[harmonic_idx])**2
|
||||
|
||||
if fundamental_power == 0:
|
||||
return 0.0
|
||||
|
||||
thd = np.sqrt(harmonics_power / fundamental_power) * 100
|
||||
return thd
|
||||
|
||||
|
||||
def calculate_snr(signal_data: np.ndarray, sample_rate: int, fundamental_freq: float) -> float:
|
||||
# Use middle segment (seconds 2-4) for calculation
|
||||
segment_start = int(sample_rate * 2)
|
||||
segment_end = int(sample_rate * 4)
|
||||
segment_end = min(segment_end, len(signal_data))
|
||||
|
||||
if segment_start >= len(signal_data):
|
||||
segment_start = len(signal_data) // 4
|
||||
segment_end = len(signal_data) * 3 // 4
|
||||
|
||||
signal_segment = signal_data[segment_start:segment_end]
|
||||
|
||||
fft = np.fft.rfft(signal_segment)
|
||||
freqs = np.fft.rfftfreq(len(signal_segment), 1/sample_rate)
|
||||
|
||||
fundamental_idx = np.argmin(np.abs(freqs - fundamental_freq))
|
||||
fundamental_power = np.abs(fft[fundamental_idx])**2
|
||||
|
||||
total_power = np.sum(np.abs(fft)**2)
|
||||
noise_power = total_power - fundamental_power
|
||||
|
||||
if noise_power <= 0:
|
||||
return 100.0
|
||||
|
||||
snr_db = 10 * np.log10(fundamental_power / noise_power)
|
||||
return snr_db
|
||||
|
||||
|
||||
def run_single_test(frequency: float, config: Dict, save_plots: bool = False, output_dir: Path = None) -> Dict:
|
||||
sample_rate = config['audio']['sample_rate']
|
||||
duration = config['test_tones']['duration']
|
||||
amplitude = config['test_tones']['amplitude']
|
||||
device_name = config['audio']['device_name']
|
||||
channels = config['audio']['channels']
|
||||
|
||||
device_ids = find_audio_device(device_name)
|
||||
|
||||
tone = generate_test_tone(frequency, duration, sample_rate, amplitude)
|
||||
recording = play_and_record(tone, sample_rate, device_ids, channels)
|
||||
|
||||
channel_1 = recording[:, 0]
|
||||
channel_2 = recording[:, 1]
|
||||
|
||||
thd_ch1 = calculate_thd(channel_1, sample_rate, frequency)
|
||||
thd_ch2 = calculate_thd(channel_2, sample_rate, frequency)
|
||||
snr_ch1 = calculate_snr(channel_1, sample_rate, frequency)
|
||||
snr_ch2 = calculate_snr(channel_2, sample_rate, frequency)
|
||||
|
||||
if save_plots and output_dir:
|
||||
plot_test_results(channel_1, channel_2, frequency, sample_rate, output_dir)
|
||||
|
||||
return {
|
||||
'frequency_hz': int(frequency),
|
||||
'thd_ch1_loopback_percent': round(float(thd_ch1), 3),
|
||||
'thd_ch2_dut_percent': round(float(thd_ch2), 3),
|
||||
'snr_ch1_loopback_db': round(float(snr_ch1), 2),
|
||||
'snr_ch2_dut_db': round(float(snr_ch2), 2)
|
||||
}
|
||||
|
||||
|
||||
def plot_test_results(channel_1: np.ndarray, channel_2: np.ndarray, frequency: float,
|
||||
sample_rate: int, output_dir: Path):
|
||||
fig, ax = plt.subplots(1, 1, figsize=(12, 6))
|
||||
|
||||
# Calculate samples needed for 5 full waves
|
||||
waves_to_show = 5
|
||||
period_samples = int(sample_rate / frequency)
|
||||
samples_needed = waves_to_show * period_samples
|
||||
|
||||
# Center around 1 second, but ensure we have enough samples
|
||||
center_sample = int(sample_rate * 1.0)
|
||||
start_sample = max(0, center_sample - samples_needed // 2)
|
||||
end_sample = min(len(channel_1), start_sample + samples_needed)
|
||||
|
||||
# Adjust if we're at the end of the recording
|
||||
if end_sample - start_sample < samples_needed:
|
||||
end_sample = min(len(channel_1), start_sample + samples_needed)
|
||||
start_sample = max(0, end_sample - samples_needed)
|
||||
|
||||
time = np.arange(start_sample, end_sample) / sample_rate * 1000
|
||||
ch1_segment = channel_1[start_sample:end_sample]
|
||||
ch2_segment = channel_2[start_sample:end_sample]
|
||||
|
||||
ax.plot(time, ch1_segment, label='Channel 1 (Loopback)', alpha=0.7, linewidth=1.2)
|
||||
ax.plot(time, ch2_segment, label='Channel 2 (DUT)', alpha=0.7, linewidth=1.2)
|
||||
ax.set_xlabel('Time (ms)')
|
||||
ax.set_ylabel('Amplitude')
|
||||
ax.set_title(f'Time Domain Comparison - {frequency} Hz (5 waves @ 1 sec)')
|
||||
ax.legend()
|
||||
ax.grid(True, alpha=0.3)
|
||||
|
||||
plt.tight_layout()
|
||||
plot_file = output_dir / f'{frequency}Hz_analysis.png'
|
||||
plt.savefig(plot_file, dpi=150, bbox_inches='tight')
|
||||
plt.close()
|
||||
|
||||
|
||||
def run_latency_test(config: Dict, num_measurements: int = 5, save_plots: bool = False, output_dir: Path = None) -> Dict:
|
||||
sample_rate = config['audio']['sample_rate']
|
||||
duration = 1.0
|
||||
amplitude = config['test_tones']['amplitude']
|
||||
device_name = config['audio']['device_name']
|
||||
channels = config['audio']['channels']
|
||||
|
||||
device_ids = find_audio_device(device_name)
|
||||
|
||||
chirp_signal = generate_chirp(duration, sample_rate, amplitude=amplitude)
|
||||
|
||||
latencies = []
|
||||
last_recording = None
|
||||
last_correlation = None
|
||||
last_lags = None
|
||||
|
||||
for i in range(num_measurements):
|
||||
recording = play_and_record(chirp_signal, sample_rate, device_ids, channels)
|
||||
|
||||
channel_1 = recording[:, 0]
|
||||
channel_2 = recording[:, 1]
|
||||
|
||||
latency, correlation, lags = calculate_latency(channel_1, channel_2, sample_rate)
|
||||
latencies.append(latency)
|
||||
|
||||
if i == num_measurements - 1:
|
||||
last_recording = recording
|
||||
last_correlation = correlation
|
||||
last_lags = lags
|
||||
|
||||
latency_stats = {
|
||||
'avg': float(np.mean(latencies)),
|
||||
'min': float(np.min(latencies)),
|
||||
'max': float(np.max(latencies)),
|
||||
'std': float(np.std(latencies))
|
||||
}
|
||||
|
||||
if save_plots and output_dir and last_recording is not None:
|
||||
channel_1 = last_recording[:, 0]
|
||||
channel_2 = last_recording[:, 1]
|
||||
plot_latency_test(channel_1, channel_2, last_correlation, last_lags, latency_stats['avg'],
|
||||
sample_rate, output_dir)
|
||||
|
||||
return latency_stats
|
||||
|
||||
|
||||
def plot_latency_test(channel_1: np.ndarray, channel_2: np.ndarray, correlation: np.ndarray,
|
||||
lags: np.ndarray, latency_ms: float, sample_rate: int, output_dir: Path):
|
||||
fig, axes = plt.subplots(2, 1, figsize=(12, 8))
|
||||
|
||||
time = np.arange(len(channel_1)) / sample_rate * 1000
|
||||
plot_samples = min(int(sample_rate * 0.9), len(channel_1))
|
||||
|
||||
axes[0].plot(time[:plot_samples], channel_1[:plot_samples], label='Channel 1 (Loopback)', alpha=0.7, linewidth=0.8)
|
||||
axes[0].plot(time[:plot_samples], channel_2[:plot_samples], label='Channel 2 (DUT)', alpha=0.7, linewidth=0.8)
|
||||
axes[0].set_xlabel('Time (ms)')
|
||||
axes[0].set_ylabel('Amplitude')
|
||||
axes[0].set_title(f'Chirp Signal Time Domain (Latency: {latency_ms:.3f} ms)')
|
||||
axes[0].legend()
|
||||
axes[0].grid(True, alpha=0.3)
|
||||
|
||||
lag_time = lags / sample_rate * 1000
|
||||
axes[1].plot(lag_time, correlation)
|
||||
axes[1].set_xlabel('Lag (ms)')
|
||||
axes[1].set_ylabel('Correlation')
|
||||
axes[1].set_title('Cross-Correlation: Channel 1 (Loopback) vs Channel 2 (DUT)')
|
||||
axes[1].axvline(x=latency_ms, color='r', linestyle='--', label=f'Peak at {latency_ms:.3f} ms')
|
||||
axes[1].legend()
|
||||
axes[1].grid(True, alpha=0.3)
|
||||
|
||||
plt.tight_layout()
|
||||
plot_file = output_dir / 'latency_chirp_analysis.png'
|
||||
plt.savefig(plot_file, dpi=150, bbox_inches='tight')
|
||||
plt.close()
|
||||
70
test_audio_playback.py
Normal file
70
test_audio_playback.py
Normal file
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env python3
|
||||
import numpy as np
|
||||
import sounddevice as sd
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
|
||||
print("Audio Device Diagnostic Test")
|
||||
print("=" * 60)
|
||||
|
||||
devices = sd.query_devices()
|
||||
print("\nAvailable devices:")
|
||||
for i, dev in enumerate(devices):
|
||||
print(f"{i}: {dev['name']}")
|
||||
print(f" Inputs: {dev['max_input_channels']}, Outputs: {dev['max_output_channels']}")
|
||||
print(f" Sample Rate: {dev['default_samplerate']}")
|
||||
print()
|
||||
|
||||
config_path = Path('config.yaml')
|
||||
with open(config_path, 'r') as f:
|
||||
config = yaml.safe_load(f)
|
||||
|
||||
sample_rate = config['audio']['sample_rate']
|
||||
duration = 1.0
|
||||
frequency = 1000
|
||||
|
||||
print(f"\nGenerating {frequency}Hz tone for {duration}s at {sample_rate}Hz...")
|
||||
t = np.linspace(0, duration, int(sample_rate * duration), endpoint=False)
|
||||
tone = 0.3 * np.sin(2 * np.pi * frequency * t)
|
||||
|
||||
print(f"Tone amplitude: min={tone.min():.3f}, max={tone.max():.3f}, mean={tone.mean():.3f}")
|
||||
print(f"Tone shape: {tone.shape}")
|
||||
|
||||
default_device = 6
|
||||
|
||||
print(f"\nTest Configuration:")
|
||||
print(f"Using device: {default_device} ({devices[default_device]['name']})")
|
||||
print(f"This routes through PulseAudio to the Scarlett 2i2")
|
||||
|
||||
stereo_tone = np.column_stack([tone, tone])
|
||||
print(f"Stereo signal shape: {stereo_tone.shape}")
|
||||
|
||||
print("\nPlaying and recording...")
|
||||
print("(You should hear a 1kHz tone through the Scarlett outputs)")
|
||||
print("(Connect Scarlett outputs to inputs for loopback test)")
|
||||
print()
|
||||
|
||||
try:
|
||||
recording = sd.playrec(stereo_tone, samplerate=sample_rate,
|
||||
channels=2,
|
||||
device=default_device,
|
||||
blocking=True)
|
||||
|
||||
print("Recording completed!")
|
||||
print(f"Recording shape: {recording.shape}")
|
||||
print(f"Channel 1 - min={recording[:, 0].min():.6f}, max={recording[:, 0].max():.6f}, RMS={np.sqrt(np.mean(recording[:, 0]**2)):.6f}")
|
||||
print(f"Channel 2 - min={recording[:, 1].min():.6f}, max={recording[:, 1].max():.6f}, RMS={np.sqrt(np.mean(recording[:, 1]**2)):.6f}")
|
||||
|
||||
ch1_rms = np.sqrt(np.mean(recording[:, 0]**2))
|
||||
ch2_rms = np.sqrt(np.mean(recording[:, 1]**2))
|
||||
|
||||
if ch1_rms < 0.001 and ch2_rms < 0.001:
|
||||
print("\n⚠️ WARNING: Very low signal detected - likely just noise!")
|
||||
print("The audio output may not be reaching the input.")
|
||||
elif ch1_rms > 0.01 or ch2_rms > 0.01:
|
||||
print("\n✓ Good signal detected!")
|
||||
else:
|
||||
print("\n⚠️ Low signal - check connections")
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n✗ Error: {e}")
|
||||
146
view_results.py
Normal file
146
view_results.py
Normal file
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def print_separator(char='-', length=70):
|
||||
print(char * length)
|
||||
|
||||
|
||||
def format_value(value, suffix=''):
|
||||
if isinstance(value, float):
|
||||
return f"{value:.3f}{suffix}"
|
||||
return f"{value}{suffix}"
|
||||
|
||||
|
||||
def display_results(yaml_file: Path):
|
||||
with open(yaml_file, 'r') as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
metadata = data['metadata']
|
||||
results = data['test_results']
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print(f" AUDIO TEST RESULTS")
|
||||
print("=" * 70)
|
||||
|
||||
print("\n📋 Test Metadata:")
|
||||
print_separator()
|
||||
print(f" Test ID: {metadata['test_id']}")
|
||||
print(f" Timestamp: {metadata['timestamp']}")
|
||||
print(f" PCB Version: {metadata['pcb_version']}")
|
||||
print(f" PCB Revision: {metadata['pcb_revision']}")
|
||||
print(f" Software Version: {metadata['software_version']}")
|
||||
if metadata.get('notes'):
|
||||
print(f" Notes: {metadata['notes']}")
|
||||
|
||||
if 'latency_test' in data:
|
||||
latency = data['latency_test']
|
||||
print("\n🎯 Latency Test (Chirp):")
|
||||
print_separator()
|
||||
if 'error' in latency:
|
||||
print(f" Error: {latency['error']}")
|
||||
else:
|
||||
print(f" Average: {latency['avg']:.3f} ms")
|
||||
print(f" Min: {latency['min']:.3f} ms")
|
||||
print(f" Max: {latency['max']:.3f} ms")
|
||||
print(f" Std Dev: {latency['std']:.4f} ms")
|
||||
|
||||
print("\n📊 Frequency Sweep Results:")
|
||||
print_separator()
|
||||
|
||||
header = f"{'Freq (Hz)':<12} {'THD CH1 (%)':<13} {'THD CH2 (%)':<13} {'SNR CH1 (dB)':<14} {'SNR CH2 (dB)':<14}"
|
||||
print(header)
|
||||
print(f"{'':12} {'(Loopback)':<13} {'(DUT)':<13} {'(Loopback)':<14} {'(DUT)':<14}")
|
||||
print_separator()
|
||||
|
||||
for result in results:
|
||||
if 'error' in result:
|
||||
print(f"{result['frequency_hz']:<12} ERROR: {result['error']}")
|
||||
else:
|
||||
freq = result['frequency_hz']
|
||||
thd_ch1 = format_value(result.get('thd_ch1_loopback_percent', result.get('thd_input_percent', 0)))
|
||||
thd_ch2 = format_value(result.get('thd_ch2_dut_percent', result.get('thd_output_percent', 0)))
|
||||
snr_ch1 = format_value(result.get('snr_ch1_loopback_db', result.get('snr_input_db', 0)))
|
||||
snr_ch2 = format_value(result.get('snr_ch2_dut_db', result.get('snr_output_db', 0)))
|
||||
|
||||
print(f"{freq:<12} {thd_ch1:<13} {thd_ch2:<13} {snr_ch1:<14} {snr_ch2:<14}")
|
||||
|
||||
print_separator()
|
||||
|
||||
valid_results = [r for r in results if 'error' not in r]
|
||||
if valid_results:
|
||||
avg_thd_ch1 = sum(r.get('thd_ch1_loopback_percent', r.get('thd_input_percent', 0)) for r in valid_results) / len(valid_results)
|
||||
avg_thd_ch2 = sum(r.get('thd_ch2_dut_percent', r.get('thd_output_percent', 0)) for r in valid_results) / len(valid_results)
|
||||
avg_snr_ch1 = sum(r.get('snr_ch1_loopback_db', r.get('snr_input_db', 0)) for r in valid_results) / len(valid_results)
|
||||
avg_snr_ch2 = sum(r.get('snr_ch2_dut_db', r.get('snr_output_db', 0)) for r in valid_results) / len(valid_results)
|
||||
|
||||
print("\n📈 Average Values:")
|
||||
print_separator()
|
||||
print(f" THD CH1 (Loopback): {avg_thd_ch1:.3f} %")
|
||||
print(f" THD CH2 (DUT): {avg_thd_ch2:.3f} %")
|
||||
print(f" SNR CH1 (Loopback): {avg_snr_ch1:.2f} dB")
|
||||
print(f" SNR CH2 (DUT): {avg_snr_ch2:.2f} dB")
|
||||
|
||||
print("\n" + "=" * 70 + "\n")
|
||||
|
||||
|
||||
def list_all_results(results_dir: Path):
|
||||
yaml_files = sorted(results_dir.glob("*_results.yaml"))
|
||||
|
||||
if not yaml_files:
|
||||
print("No test results found.")
|
||||
return
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print(" AVAILABLE TEST RESULTS")
|
||||
print("=" * 70 + "\n")
|
||||
|
||||
for yaml_file in yaml_files:
|
||||
try:
|
||||
with open(yaml_file, 'r') as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
metadata = data['metadata']
|
||||
timestamp = datetime.fromisoformat(metadata['timestamp'])
|
||||
|
||||
print(f"📄 {yaml_file.name}")
|
||||
print(f" Date: {timestamp.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
print(f" PCB: {metadata['pcb_version']} Rev {metadata['pcb_revision']}")
|
||||
print(f" SW: {metadata['software_version']}")
|
||||
if metadata.get('notes'):
|
||||
print(f" Note: {metadata['notes']}")
|
||||
print()
|
||||
except Exception as e:
|
||||
print(f"⚠️ {yaml_file.name} (corrupted - skipping)")
|
||||
print()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='View PCB test results')
|
||||
parser.add_argument('file', nargs='?', help='YAML results file to view')
|
||||
parser.add_argument('--list', action='store_true', help='List all available test results')
|
||||
parser.add_argument('--results-dir', default='test_results', help='Directory containing results')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
results_dir = Path(args.results_dir)
|
||||
|
||||
if args.list or not args.file:
|
||||
list_all_results(results_dir)
|
||||
else:
|
||||
yaml_file = Path(args.file)
|
||||
if not yaml_file.exists():
|
||||
yaml_file = results_dir / args.file
|
||||
|
||||
if not yaml_file.exists():
|
||||
print(f"Error: File not found: {yaml_file}")
|
||||
return 1
|
||||
|
||||
display_results(yaml_file)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user