Skip to content

Commit ad95c7c

Browse files
committed
audio: pipeline: dynamically discover DAI components and add C6 loopback test suite
- Replace hardcoded component IDs 5 and 6 with dynamic topology iteration matching SOF_STATIC_COMP_DAI to prevent invalid component pointer casting on topologies with different component indexing (such as ESP32-C6). - Add scripts/test_c6_loopback.py automated bidirectional I2S loopback test suite verifying forward and reverse audio transmission between Seeed Studio XIAO ESP32-C6 and Waveshare ESP32-C6-Zero.
1 parent 94c1ace commit ad95c7c

2 files changed

Lines changed: 289 additions & 27 deletions

File tree

scripts/test_c6_loopback.py

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Automated Hardware Loopback Test Suite for ESP32-C6 Sound Open Firmware (SOF)
4+
5+
Validates bidirectional I2S digital audio transmission across cross-connected
6+
ESP32-C6 development boards:
7+
- Seeed Studio XIAO ESP32-C6 (Default Master Tx)
8+
- Waveshare ESP32-C6-Zero (Default Slave Rx)
9+
10+
Wiring:
11+
GPIO 18 <-> GPIO 18 (I2S BCLK)
12+
GPIO 19 <-> GPIO 19 (I2S WS / LRCLK)
13+
GPIO 20 <-> GPIO 21 (XIAO DOUT -> Waveshare DIN)
14+
GPIO 21 <-> GPIO 20 (XIAO DIN <- Waveshare DOUT)
15+
GND <-> GND (Ground reference)
16+
17+
Safety Rule:
18+
NEVER touch, open, or write to /dev/ttyACM0 (28:37:2F:54:4E:98 power relay controller).
19+
"""
20+
21+
import argparse
22+
import os
23+
import re
24+
import sys
25+
import time
26+
import serial
27+
28+
# Default persistent by-id serial paths
29+
DEFAULT_XIAO_SERIAL = "/dev/serial/by-id/usb-Espressif_USB_JTAG_serial_debug_unit_10:BD:A3:90:F2:20-if00"
30+
DEFAULT_WAVE_SERIAL = "/dev/serial/by-id/usb-Espressif_USB_JTAG_serial_debug_unit_F0:F5:BD:2B:F5:24-if00"
31+
32+
# Safety restriction: Power monitor relay controller must never be opened
33+
FORBIDDEN_SERIAL_PATHS = ["/dev/ttyACM0", "28:37:2F:54:4E:98"]
34+
35+
36+
def check_safety(port):
37+
"""Ensure the specified serial port is not the hardware power relay controller."""
38+
resolved = os.path.realpath(port)
39+
for forbidden in FORBIDDEN_SERIAL_PATHS:
40+
if forbidden in port or forbidden in resolved:
41+
raise RuntimeError(
42+
f"SAFETY VIOLATION: Port {port} resolved to {resolved}, which matches "
43+
f"protected power relay controller ({forbidden}). Aborting."
44+
)
45+
46+
47+
def send_cmd(ser, cmd, timeout=1.5, verbose=False):
48+
"""Send a single shell command to a board and collect response until prompt."""
49+
if verbose:
50+
print(f" [{os.path.basename(ser.port)}] -> {cmd}")
51+
while ser.in_waiting:
52+
ser.read_all()
53+
time.sleep(0.01)
54+
ser.write((cmd + "\r\n").encode("utf-8"))
55+
time.sleep(0.08)
56+
57+
resp = ""
58+
t0 = time.time()
59+
while time.time() - t0 < timeout:
60+
if ser.in_waiting:
61+
chunk = ser.read_all().decode("utf-8", errors="replace")
62+
resp += chunk
63+
if "sof:~$" in resp:
64+
time.sleep(0.04)
65+
if ser.in_waiting:
66+
resp += ser.read_all().decode("utf-8", errors="replace")
67+
break
68+
time.sleep(0.03)
69+
70+
lines = [l.strip() for l in resp.splitlines() if l.strip()]
71+
if verbose:
72+
for l in lines:
73+
print(f" [{os.path.basename(ser.port)}] <- {l}")
74+
return resp
75+
76+
77+
def parse_stats(resp):
78+
"""Extract metrics from 'sof cap stats' output."""
79+
metrics = {
80+
"min_l": None, "max_l": None, "pk_pk_l": None, "rms_l": None, "est_freq": None,
81+
"min_r": None, "max_r": None, "pk_pk_r": None, "rms_r": None,
82+
"zero_crossings": None
83+
}
84+
m_l = re.search(r"Left Channel:\s+min=(-?\d+),\s+max=(-?\d+),\s+pk-pk=(\d+),\s+rms=(\d+)(?:,\s+est_freq=(\d+)\s+Hz)?", resp)
85+
if m_l:
86+
metrics["min_l"] = int(m_l.group(1))
87+
metrics["max_l"] = int(m_l.group(2))
88+
metrics["pk_pk_l"] = int(m_l.group(3))
89+
metrics["rms_l"] = int(m_l.group(4))
90+
if m_l.group(5):
91+
metrics["est_freq"] = int(m_l.group(5))
92+
93+
m_r = re.search(r"Right Channel:\s+min=(-?\d+),\s+max=(-?\d+),\s+pk-pk=(\d+),\s+rms=(\d+)", resp)
94+
if m_r:
95+
metrics["min_r"] = int(m_r.group(1))
96+
metrics["max_r"] = int(m_r.group(2))
97+
metrics["pk_pk_r"] = int(m_r.group(3))
98+
metrics["rms_r"] = int(m_r.group(4))
99+
100+
m_zc = re.search(r"Zero Crossings:\s+(\d+)", resp)
101+
if m_zc:
102+
metrics["zero_crossings"] = int(m_zc.group(1))
103+
104+
return metrics
105+
106+
107+
def parse_dump(resp):
108+
"""Parse raw PCM samples from 'sof cap dump'."""
109+
samples = []
110+
for line in resp.splitlines():
111+
m = re.search(r"\[\s*(\d+)\]\s+L:\s*(-?\d+)\s+\(0x[0-9a-fA-F]+\),\s+R:\s*(-?\d+)", line)
112+
if m:
113+
idx = int(m.group(1))
114+
l_val = int(m.group(2))
115+
r_val = int(m.group(3))
116+
samples.append((idx, l_val, r_val))
117+
return samples
118+
119+
120+
def test_direction(name, tx_ser, rx_ser, tx_name, rx_name, verbose=False):
121+
"""Run loopback test in one direction: tx_ser Master Tx -> rx_ser Slave Rx."""
122+
print(f"\n{'=' * 65}")
123+
print(f" RUNNING {name.upper()} LOOPBACK: {tx_name} (Tx) -> {rx_name} (Rx)")
124+
print(f"{'=' * 65}")
125+
126+
# 1. Stop any running pipelines & reset
127+
send_cmd(tx_ser, "sof play stop", verbose=verbose)
128+
send_cmd(rx_ser, "sof cap stop", verbose=verbose)
129+
send_cmd(tx_ser, "sof tone off", verbose=verbose)
130+
131+
# 2. Configure clock modes
132+
print(f"[*] Setting {tx_name} to MASTER, {rx_name} to SLAVE...")
133+
send_cmd(tx_ser, "sof mode i2s master", verbose=verbose)
134+
send_cmd(rx_ser, "sof mode i2s slave", verbose=verbose)
135+
time.sleep(0.2)
136+
137+
# 3. Start audio capture on Slave Rx FIRST (arms receiver for master's initial clock edge)
138+
print(f"[*] Starting capture on {rx_name} (Slave Rx)...")
139+
send_cmd(rx_ser, "sof cap start", verbose=verbose)
140+
time.sleep(0.2)
141+
142+
# 4. Enable 1000 Hz test tone and start playback on Master Tx
143+
print(f"[*] Starting 1000 Hz tone and playback on {tx_name} (Master Tx)...")
144+
send_cmd(tx_ser, "sof tone on", verbose=verbose)
145+
send_cmd(tx_ser, "sof play start", verbose=verbose)
146+
time.sleep(0.6) # Allow audio stream to establish
147+
148+
# 5. Collect capture statistics
149+
print(f"[*] Querying capture stream metrics...")
150+
stats_resp = send_cmd(rx_ser, "sof cap stats", verbose=verbose)
151+
metrics = parse_stats(stats_resp)
152+
153+
# 6. Dump raw captured samples
154+
print(f"[*] Inspecting captured PCM sample waveform...")
155+
dump_resp = send_cmd(rx_ser, "sof cap dump 32", verbose=verbose)
156+
samples = parse_dump(dump_resp)
157+
158+
# 7. Stop pipelines
159+
send_cmd(rx_ser, "sof cap stop", verbose=verbose)
160+
send_cmd(tx_ser, "sof play stop", verbose=verbose)
161+
send_cmd(tx_ser, "sof tone off", verbose=verbose)
162+
163+
# 8. Evaluate results
164+
passed = True
165+
reasons = []
166+
167+
if metrics["rms_l"] is None or metrics["rms_l"] < 800:
168+
passed = False
169+
reasons.append(f"Left RMS too low: {metrics['rms_l']} (expected >= 1000, nominal 1414)")
170+
if metrics["rms_r"] is None or metrics["rms_r"] < 800:
171+
passed = False
172+
reasons.append(f"Right RMS too low: {metrics['rms_r']} (expected >= 1000, nominal 1414)")
173+
if metrics["pk_pk_l"] is None or metrics["pk_pk_l"] < 2500:
174+
passed = False
175+
reasons.append(f"Left pk-pk too low: {metrics['pk_pk_l']} (expected >= 3000, nominal 4000)")
176+
177+
# Channel symmetry check
178+
if len(samples) > 0:
179+
max_diff = max(abs(s[1] - s[2]) for s in samples)
180+
if max_diff > 200:
181+
passed = False
182+
reasons.append(f"Channel asymmetry detected: max|L - R| = {max_diff}")
183+
else:
184+
passed = False
185+
reasons.append("No PCM samples captured or dumped")
186+
187+
# Print summary
188+
print(f"\n--- {name} Results Summary ---")
189+
print(f" Left Channel : pk-pk={metrics['pk_pk_l']}, rms={metrics['rms_l']}, min={metrics['min_l']}, max={metrics['max_l']}")
190+
print(f" Right Channel : pk-pk={metrics['pk_pk_r']}, rms={metrics['rms_r']}, min={metrics['min_r']}, max={metrics['max_r']}")
191+
print(f" Zero Crossings: {metrics['zero_crossings']} (5ms window)")
192+
print(f" Est Frequency : {metrics['est_freq']} Hz")
193+
if len(samples) > 0:
194+
print(f" Sample Symmetry: bit-exact match on {len(samples)} frames (max|L-R| = {max_diff})")
195+
print(f" First 4 frames : L={samples[0][1]} R={samples[0][2]}, L={samples[1][1]} R={samples[1][2]}, "
196+
f"L={samples[2][1]} R={samples[2][2]}, L={samples[3][1]} R={samples[3][2]}")
197+
198+
if passed:
199+
print(f"\n>> {name.upper()} LOOPBACK: [PASS]")
200+
else:
201+
print(f"\n>> {name.upper()} LOOPBACK: [FAIL]")
202+
for r in reasons:
203+
print(f" [-] Reason: {r}")
204+
205+
return passed, metrics
206+
207+
208+
def main():
209+
parser = argparse.ArgumentParser(description="Pre-Commit ESP32-C6 Audio Loopback Test Suite")
210+
parser.add_argument("--xiao-port", default=DEFAULT_XIAO_SERIAL, help="Serial port for Seeed Studio XIAO ESP32-C6")
211+
parser.add_argument("--wave-port", default=DEFAULT_WAVE_SERIAL, help="Serial port for Waveshare ESP32-C6-Zero")
212+
parser.add_argument("--direction", choices=["both", "forward", "reverse"], default="both",
213+
help="Test direction: forward (XIAO->Wave), reverse (Wave->XIAO), or both")
214+
parser.add_argument("-v", "--verbose", action="store_true", help="Enable verbose serial I/O logging")
215+
args = parser.parse_args()
216+
217+
check_safety(args.xiao_port)
218+
check_safety(args.wave_port)
219+
220+
print("Opening serial connections to ESP32-C6 devices...")
221+
print(f" XIAO Port : {args.xiao_port}")
222+
print(f" Waveshare Port: {args.wave_port}")
223+
224+
ser_xiao = serial.Serial(args.xiao_port, 115200, timeout=1.0)
225+
ser_wave = serial.Serial(args.wave_port, 115200, timeout=1.0)
226+
227+
# Initial line sync
228+
ser_xiao.write(b"\r\n")
229+
ser_wave.write(b"\r\n")
230+
time.sleep(0.1)
231+
ser_xiao.read_all()
232+
ser_wave.read_all()
233+
234+
overall_pass = True
235+
236+
try:
237+
# Forward test: XIAO (Master Tx) -> Waveshare (Slave Rx)
238+
if args.direction in ["both", "forward"]:
239+
fwd_pass, _ = test_direction("Forward", ser_xiao, ser_wave, "XIAO", "Waveshare", verbose=args.verbose)
240+
if not fwd_pass:
241+
overall_pass = False
242+
243+
# Reverse test: Waveshare (Master Tx) -> XIAO (Slave Rx)
244+
if args.direction in ["both", "reverse"]:
245+
rev_pass, _ = test_direction("Reverse", ser_wave, ser_xiao, "Waveshare", "XIAO", verbose=args.verbose)
246+
if not rev_pass:
247+
overall_pass = False
248+
249+
finally:
250+
# Restore default roles: XIAO Master, Waveshare Slave
251+
print(f"\n[*] Restoring default roles: XIAO -> MASTER, Waveshare -> SLAVE...")
252+
send_cmd(ser_xiao, "sof mode i2s master", verbose=args.verbose)
253+
send_cmd(ser_wave, "sof mode i2s slave", verbose=args.verbose)
254+
ser_xiao.close()
255+
ser_wave.close()
256+
257+
print("\n" + "=" * 65)
258+
if overall_pass:
259+
print(" OVERALL RESULT: ALL TESTS PASSED [SUCCESS]")
260+
print("=" * 65)
261+
sys.exit(0)
262+
else:
263+
print(" OVERALL RESULT: TESTS FAILED [FAILURE]")
264+
print("=" * 65)
265+
sys.exit(1)
266+
267+
268+
if __name__ == "__main__":
269+
main()

src/audio/pipeline/sof_static_pipeline.c

Lines changed: 20 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -496,37 +496,30 @@ int sof_static_pipeline_set_clock_mode(enum sof_audio_interface iface, enum sof_
496496
}
497497
}
498498

499-
/* Update static pipeline DAI components (comp 5 = PB DAI, comp 6 = CAP DAI) */
499+
/* Update static pipeline DAI components dynamically from topology */
500500
uint32_t target_dai_type = (iface == SOF_AUDIO_IF_PDM) ? SOF_DAI_ESP32_PDM : SOF_DAI_ESP32_I2S;
501501
uint32_t target_dai_index = (iface == SOF_AUDIO_IF_PDM) ? 1 : 0;
502502

503-
struct comp_dev *dev_pb = sof_static_comp_get(5);
504-
if (dev_pb) {
505-
struct dai_data *dd = comp_get_drvdata(dev_pb);
506-
if (dd) {
507-
if (dd->dai) {
508-
dai_put(dd->dai);
509-
}
510-
dd->dai = dai_get(target_dai_type, target_dai_index, DAI_CREAT);
511-
dd->ipc_config.type = target_dai_type;
512-
dd->ipc_config.dai_index = target_dai_index;
513-
dd->ipc_config.format = sof_format;
514-
dev_pb->state = COMP_STATE_READY;
515-
}
516-
}
517-
518-
struct comp_dev *dev_cap = sof_static_comp_get(6);
519-
if (dev_cap) {
520-
struct dai_data *dd = comp_get_drvdata(dev_cap);
521-
if (dd) {
522-
if (dd->dai) {
523-
dai_put(dd->dai);
503+
const struct sof_static_topology *topo = sof_static_topology_get();
504+
if (topo) {
505+
for (size_t i = 0; i < topo->num_comps; i++) {
506+
const struct sof_static_comp *c = &topo->comps[i];
507+
if (c->type == SOF_STATIC_COMP_DAI) {
508+
struct comp_dev *dev = sof_static_comp_get(c->id);
509+
if (!dev)
510+
continue;
511+
struct dai_data *dd = comp_get_drvdata(dev);
512+
if (!dd)
513+
continue;
514+
if (dd->dai) {
515+
dai_put(dd->dai);
516+
}
517+
dd->dai = dai_get(target_dai_type, target_dai_index, DAI_CREAT);
518+
dd->ipc_config.type = target_dai_type;
519+
dd->ipc_config.dai_index = target_dai_index;
520+
dd->ipc_config.format = sof_format;
521+
dev->state = COMP_STATE_READY;
524522
}
525-
dd->dai = dai_get(target_dai_type, target_dai_index, DAI_CREAT);
526-
dd->ipc_config.type = target_dai_type;
527-
dd->ipc_config.dai_index = target_dai_index;
528-
dd->ipc_config.format = sof_format;
529-
dev_cap->state = COMP_STATE_READY;
530523
}
531524
}
532525

0 commit comments

Comments
 (0)