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()