commit 80f7522159f900478fd423b4c22ac38e692cba7b
Author: Pbopbo
Date: Thu Feb 26 13:27:58 2026 +0100
Closed loop test suite, vibe coded, needs some refinement.
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..72f1588
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,14 @@
+test_results/
+__pycache__/
+*.pyc
+*.pyo
+*.pyd
+.Python
+*.so
+*.egg-info/
+dist/
+build/
+.venv/
+venv/
+*.wav
+*.png
diff --git a/QUICKSTART.md b/QUICKSTART.md
new file mode 100644
index 0000000..933a8cd
--- /dev/null
+++ b/QUICKSTART.md
@@ -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
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..7dab643
--- /dev/null
+++ b/README.md
@@ -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
diff --git a/ai_stuff/prompts.md b/ai_stuff/prompts.md
new file mode 100644
index 0000000..9619b09
--- /dev/null
+++ b/ai_stuff/prompts.md
@@ -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.
\ No newline at end of file
diff --git a/config.yaml b/config.yaml
new file mode 100644
index 0000000..7216964
--- /dev/null
+++ b/config.yaml
@@ -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
diff --git a/example_test_result.yaml b/example_test_result.yaml
new file mode 100644
index 0000000..3104716
--- /dev/null
+++ b/example_test_result.yaml
@@ -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
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..7ab305e
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,5 @@
+numpy>=1.24.0
+scipy>=1.10.0
+sounddevice>=0.4.6
+PyYAML>=6.0
+matplotlib>=3.7.0
diff --git a/run_test.py b/run_test.py
new file mode 100644
index 0000000..fa812c1
--- /dev/null
+++ b/run_test.py
@@ -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()
diff --git a/src/__init__.py b/src/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/audio_tests.py b/src/audio_tests.py
new file mode 100644
index 0000000..b30995c
--- /dev/null
+++ b/src/audio_tests.py
@@ -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()
diff --git a/test_audio_playback.py b/test_audio_playback.py
new file mode 100644
index 0000000..f0eab80
--- /dev/null
+++ b/test_audio_playback.py
@@ -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}")
diff --git a/view_results.py b/view_results.py
new file mode 100644
index 0000000..6e73e0a
--- /dev/null
+++ b/view_results.py
@@ -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()