use std::fs;
use std::io::{self, Write};
use std::path::Path;
use tracing::{debug, trace};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Channel {
Left,
Right,
}
pub const WAV_HEADER_BYTES: usize = 44;
#[derive(Debug)]
pub struct I2sCapture {
bclk_pin: u8,
lrclk_pin: u8,
dout_pin: u8,
prev_bclk: bool,
prev_lrclk: bool,
accumulator: u16,
bit_count: u8,
current_channel: Channel,
finalizing_channel: Option<Channel>,
frames: Vec<(i16, i16)>,
pending_left: Option<i16>,
pending_right: Option<i16>,
first_lrclk_cycle: Option<u64>,
last_lrclk_cycle: Option<u64>,
lrclk_edges: u64,
sys_clk_hz: u32,
}
impl I2sCapture {
pub fn new(sys_clk_hz: u32, bclk_pin: u8, lrclk_pin: u8, dout_pin: u8) -> Self {
Self {
bclk_pin,
lrclk_pin,
dout_pin,
prev_bclk: false,
prev_lrclk: false,
accumulator: 0,
bit_count: 0,
current_channel: Channel::Left,
finalizing_channel: None,
frames: Vec::new(),
pending_right: None,
pending_left: None,
first_lrclk_cycle: None,
last_lrclk_cycle: None,
lrclk_edges: 0,
sys_clk_hz,
}
}
pub fn tick(&mut self, pads: u32, now_cycles: u64) {
let bclk = pads & (1u32 << self.bclk_pin) != 0;
let lrclk = pads & (1u32 << self.lrclk_pin) != 0;
let dout = pads & (1u32 << self.dout_pin) != 0;
if lrclk != self.prev_lrclk {
self.on_lrclk_edge(lrclk, now_cycles);
}
if bclk && !self.prev_bclk {
self.on_bclk_rising(dout);
}
self.prev_bclk = bclk;
self.prev_lrclk = lrclk;
}
fn on_lrclk_edge(&mut self, new_lrclk: bool, now_cycles: u64) {
debug!(
target: "picoem_devices::i2s_capture",
now_cycles,
new_lrclk,
channel = ?self.current_channel,
accumulator = format_args!("0x{:04x}", self.accumulator),
bit_count = self.bit_count,
edges_seen = self.lrclk_edges,
"lrclk_edge",
);
if self.bit_count == 15 {
self.finalizing_channel = Some(self.current_channel);
} else {
self.finalizing_channel = None;
self.accumulator = 0;
self.pending_left = None;
self.pending_right = None;
}
self.bit_count = 0;
self.current_channel = if new_lrclk {
Channel::Right
} else {
Channel::Left
};
self.lrclk_edges = self.lrclk_edges.saturating_add(1);
if self.first_lrclk_cycle.is_none() {
self.first_lrclk_cycle = Some(now_cycles);
}
self.last_lrclk_cycle = Some(now_cycles);
}
fn on_bclk_rising(&mut self, dout: bool) {
if let Some(finalize) = self.finalizing_channel.take() {
self.accumulator = (self.accumulator << 1) | (dout as u16);
let sample = self.accumulator as i16;
trace!(
target: "picoem_devices::i2s_capture",
dout,
channel = ?finalize,
committed = format_args!("0x{:04x}", sample as u16),
"philips_lsb_commit",
);
match finalize {
Channel::Left => {
if let Some(right) = self.pending_right.take() {
self.frames.push((sample, right));
} else {
self.pending_left = Some(sample);
}
}
Channel::Right => {
if let Some(left) = self.pending_left.take() {
self.frames.push((left, sample));
} else {
self.pending_right = Some(sample);
}
}
}
self.accumulator = 0;
return;
}
if self.bit_count >= 15 {
return;
}
self.accumulator = (self.accumulator << 1) | (dout as u16);
self.bit_count += 1;
trace!(
target: "picoem_devices::i2s_capture",
dout,
bit_count = self.bit_count,
accumulator = format_args!("0x{:04x}", self.accumulator),
"bclk_rising_latch",
);
}
pub fn set_sys_clk_hz(&mut self, sys_clk_hz: u32) {
self.sys_clk_hz = sys_clk_hz;
}
pub fn sys_clk_hz(&self) -> u32 {
self.sys_clk_hz
}
pub fn frames(&self) -> &[(i16, i16)] {
&self.frames
}
pub fn lrclk_edge_count(&self) -> u64 {
self.lrclk_edges
}
pub fn inferred_sample_rate_hz(&self) -> Option<f64> {
let first = self.first_lrclk_cycle?;
let last = self.last_lrclk_cycle?;
if self.lrclk_edges < 2 || last <= first {
return None;
}
let half_periods = self.lrclk_edges.saturating_sub(1) as f64;
let total_cycles = (last - first) as f64;
let freq = self.sys_clk_hz as f64 * half_periods / (2.0 * total_cycles);
Some(freq)
}
pub fn duration_secs(&self, fallback_rate: u32) -> f64 {
let rate = self
.inferred_sample_rate_hz()
.unwrap_or(fallback_rate as f64);
if rate <= 0.0 {
return 0.0;
}
self.frames.len() as f64 / rate
}
pub fn write_wav(&self, path: &Path, sample_rate_hz: u32) -> io::Result<()> {
write_wav(path, sample_rate_hz, &self.frames)
}
}
pub fn write_wav(path: &Path, sample_rate_hz: u32, frames: &[(i16, i16)]) -> io::Result<()> {
if path.is_dir() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"refusing to write WAV to existing directory: {}",
path.display()
),
));
}
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() && !parent.exists() {
fs::create_dir_all(parent)?;
}
}
let channels: u16 = 2;
let bits_per_sample: u16 = 16;
let bytes_per_sample: u16 = bits_per_sample / 8;
let block_align: u16 = channels * bytes_per_sample;
let byte_rate: u32 = sample_rate_hz * (block_align as u32);
let data_bytes: u32 = (frames.len() as u32).saturating_mul(block_align as u32);
let riff_size: u32 = 36u32.saturating_add(data_bytes);
let mut buf: Vec<u8> = Vec::with_capacity(WAV_HEADER_BYTES + data_bytes as usize);
buf.extend_from_slice(b"RIFF");
buf.extend_from_slice(&riff_size.to_le_bytes());
buf.extend_from_slice(b"WAVE");
buf.extend_from_slice(b"fmt ");
buf.extend_from_slice(&16u32.to_le_bytes()); buf.extend_from_slice(&1u16.to_le_bytes()); buf.extend_from_slice(&channels.to_le_bytes());
buf.extend_from_slice(&sample_rate_hz.to_le_bytes());
buf.extend_from_slice(&byte_rate.to_le_bytes());
buf.extend_from_slice(&block_align.to_le_bytes());
buf.extend_from_slice(&bits_per_sample.to_le_bytes());
buf.extend_from_slice(b"data");
buf.extend_from_slice(&data_bytes.to_le_bytes());
debug_assert_eq!(buf.len(), WAV_HEADER_BYTES);
for (l, r) in frames {
buf.extend_from_slice(&l.to_le_bytes());
buf.extend_from_slice(&r.to_le_bytes());
}
let mut f = fs::File::create(path)?;
f.write_all(&buf)?;
f.sync_all()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
const BCLK: u8 = 17;
const LRCLK: u8 = 18;
const DOUT: u8 = 16;
fn pads(bclk: bool, lrclk: bool, dout: bool) -> u32 {
let mut p = 0u32;
if bclk {
p |= 1u32 << BCLK;
}
if lrclk {
p |= 1u32 << LRCLK;
}
if dout {
p |= 1u32 << DOUT;
}
p
}
fn clock_philips_frame(cap: &mut I2sCapture, cycle: &mut u64, left: u16, right: u16) {
for i in (1..=15).rev() {
let bit = (right >> i) & 1 != 0;
cap.tick(pads(false, true, bit), *cycle);
*cycle += 1;
cap.tick(pads(true, true, bit), *cycle);
*cycle += 1;
}
let bit = right & 1 != 0;
cap.tick(pads(false, false, bit), *cycle);
*cycle += 1;
cap.tick(pads(true, false, bit), *cycle);
*cycle += 1;
for i in (1..=15).rev() {
let bit = (left >> i) & 1 != 0;
cap.tick(pads(false, false, bit), *cycle);
*cycle += 1;
cap.tick(pads(true, false, bit), *cycle);
*cycle += 1;
}
let bit = left & 1 != 0;
cap.tick(pads(false, true, bit), *cycle);
*cycle += 1;
cap.tick(pads(true, true, bit), *cycle);
*cycle += 1;
}
#[test]
fn decodes_known_square_wave() {
let mut cap = I2sCapture::new(125_000_000, BCLK, LRCLK, DOUT);
let mut cycle: u64 = 0;
clock_philips_frame(&mut cap, &mut cycle, 0x1234, 0x5678);
clock_philips_frame(&mut cap, &mut cycle, 0x0001, 0x0002);
clock_philips_frame(
&mut cap, &mut cycle, 0xC000,
0x7FFF,
);
assert_eq!(
cap.frames(),
&[
(0x1234i16, 0x5678i16),
(0x0001, 0x0002),
(-0x4000i16, 0x7FFFi16),
],
"Philips timing decode mismatch: frames = {:?}",
cap.frames()
);
}
#[test]
fn stub1_constant_0xff_decodes_correctly() {
let mut cap = I2sCapture::new(125_000_000, BCLK, LRCLK, DOUT);
let mut cycle: u64 = 0;
for _ in 0..4 {
clock_philips_frame(&mut cap, &mut cycle, 0x00FF, 0x0000);
}
let last = *cap.frames().last().expect("expected frames emitted");
assert_eq!(
last,
(0x00FFi16, 0x0000i16),
"Philips decoder must recover L=0x00FF, R=0x0000 from PIO \
output of (right<<16)|left = 0x000000FF; got {:?}",
last,
);
}
#[test]
fn wav_file_roundtrip() {
let tmp = tmp_dir().join("i2s_roundtrip.wav");
let frames: Vec<(i16, i16)> = (0..100)
.map(|i| (i as i16 * 10, -(i as i16) * 10))
.collect();
write_wav(&tmp, 48_000, &frames).expect("write wav");
let bytes = fs::read(&tmp).expect("read wav");
assert!(
bytes.len() >= WAV_HEADER_BYTES,
"file smaller than header: {} bytes",
bytes.len()
);
assert_eq!(&bytes[0..4], b"RIFF");
assert_eq!(&bytes[8..12], b"WAVE");
assert_eq!(&bytes[12..16], b"fmt ");
assert_eq!(&bytes[36..40], b"data");
let audio_fmt = u16::from_le_bytes([bytes[20], bytes[21]]);
let channels = u16::from_le_bytes([bytes[22], bytes[23]]);
let sample_rate = u32::from_le_bytes([bytes[24], bytes[25], bytes[26], bytes[27]]);
let byte_rate = u32::from_le_bytes([bytes[28], bytes[29], bytes[30], bytes[31]]);
let block_align = u16::from_le_bytes([bytes[32], bytes[33]]);
let bits = u16::from_le_bytes([bytes[34], bytes[35]]);
assert_eq!(audio_fmt, 1, "audio format != PCM");
assert_eq!(channels, 2);
assert_eq!(sample_rate, 48_000);
assert_eq!(bits, 16);
assert_eq!(block_align, 4);
assert_eq!(byte_rate, 48_000 * 4);
let data_size = u32::from_le_bytes([bytes[40], bytes[41], bytes[42], bytes[43]]);
assert_eq!(data_size, 100 * 4);
assert_eq!(bytes.len(), WAV_HEADER_BYTES + data_size as usize);
for (i, (l, r)) in frames.iter().enumerate() {
let off = WAV_HEADER_BYTES + i * 4;
let read_l = i16::from_le_bytes([bytes[off], bytes[off + 1]]);
let read_r = i16::from_le_bytes([bytes[off + 2], bytes[off + 3]]);
assert_eq!(read_l, *l, "left sample {i} mismatch");
assert_eq!(read_r, *r, "right sample {i} mismatch");
}
let _ = fs::remove_file(&tmp);
}
#[test]
fn no_activity_produces_empty_wav() {
let mut cap = I2sCapture::new(125_000_000, BCLK, LRCLK, DOUT);
for i in 0..10_000u64 {
cap.tick(0, i);
}
assert_eq!(cap.frames().len(), 0);
assert_eq!(cap.lrclk_edge_count(), 0);
assert!(cap.inferred_sample_rate_hz().is_none());
let tmp = tmp_dir().join("i2s_empty.wav");
cap.write_wav(&tmp, 44_100).expect("write empty wav");
let bytes = fs::read(&tmp).expect("read empty wav");
assert_eq!(
bytes.len(),
WAV_HEADER_BYTES,
"empty WAV must be exactly the 44-byte header"
);
let data_size = u32::from_le_bytes([bytes[40], bytes[41], bytes[42], bytes[43]]);
assert_eq!(data_size, 0);
let riff_size = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
assert_eq!(riff_size, 36);
let _ = fs::remove_file(&tmp);
}
#[test]
fn sample_rate_inferred_from_lrclk() {
let sys_clk = 32_000u32;
let mut cap = I2sCapture::new(sys_clk, BCLK, LRCLK, DOUT);
let half_period_cycles: u32 = 100;
let target_rate = sys_clk as f64 / (2.0 * half_period_cycles as f64);
let mut lrclk = false;
let mut cycle: u64 = 0;
for _frame in 0..50 {
for _ in 0..half_period_cycles {
cap.tick(pads(false, lrclk, false), cycle);
cycle += 1;
}
lrclk = !lrclk;
}
let inferred = cap
.inferred_sample_rate_hz()
.expect("should have edges after 50 frames");
let relative_err = (inferred - target_rate).abs() / target_rate;
assert!(
relative_err < 0.05,
"inferred {inferred:.3} Hz vs target {target_rate:.3} Hz (err {relative_err:.3})"
);
}
#[test]
fn pad_mask_respects_pin_mapping() {
const WRONG_BCLK: u8 = 7;
const WRONG_LRCLK: u8 = 8;
const WRONG_DOUT: u8 = 9;
let mut cap = I2sCapture::new(125_000_000, BCLK, LRCLK, DOUT);
let mut lrclk = false;
let mut cycle: u64 = 0;
for _ in 0..200 {
for i in 0..16 {
let bit = (i & 1) != 0;
let mut pads_val = 0u32;
if bit {
pads_val |= 1u32 << WRONG_DOUT;
}
if lrclk {
pads_val |= 1u32 << WRONG_LRCLK;
}
cap.tick(pads_val, cycle);
cycle += 1;
cap.tick(pads_val | (1u32 << WRONG_BCLK), cycle);
cycle += 1;
}
lrclk = !lrclk;
}
assert_eq!(
cap.frames().len(),
0,
"wrong-pin activity must not produce frames"
);
assert_eq!(cap.lrclk_edge_count(), 0);
}
#[test]
fn write_wav_rejects_directory_path() {
let dir = tmp_dir();
let _ = fs::create_dir_all(&dir);
let err = write_wav(&dir, 44_100, &[]).expect_err("writing to dir must fail");
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
}
#[test]
fn write_wav_creates_missing_parent_dirs() {
let root = tmp_dir();
let nested = root.join("i2s_nested").join("a").join("b").join("out.wav");
let _ = fs::remove_dir_all(root.join("i2s_nested"));
write_wav(&nested, 44_100, &[(1, 2), (3, 4)]).expect("nested write");
assert!(nested.exists(), "nested WAV not created");
let bytes = fs::read(&nested).expect("read");
assert_eq!(bytes.len(), WAV_HEADER_BYTES + 8);
let _ = fs::remove_dir_all(root.join("i2s_nested"));
}
fn tmp_dir() -> PathBuf {
let base = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(|p| p.parent())
.map(|p| p.join("target").join("i2s_capture_tests"))
.unwrap_or_else(|| PathBuf::from("target/i2s_capture_tests"));
let _ = fs::create_dir_all(&base);
base
}
#[test]
fn short_window_drops_state_then_resyncs() {
let mut cap = I2sCapture::new(125_000_000, BCLK, LRCLK, DOUT);
let mut cycle: u64 = 0;
clock_philips_frame(&mut cap, &mut cycle, 0x0AAA, 0x0BBB);
assert_eq!(cap.frames().len(), 1);
for _ in 0..3 {
cap.tick(pads(false, true, false), cycle);
cycle += 1;
cap.tick(pads(true, true, false), cycle);
cycle += 1;
}
cap.tick(pads(false, false, false), cycle); cycle += 1;
clock_philips_frame(&mut cap, &mut cycle, 0x0123, 0x4567);
clock_philips_frame(&mut cap, &mut cycle, 0x00FF, 0x7F00);
let frames = cap.frames();
assert_eq!(frames.len(), 3, "got {} frames, expected 3", frames.len());
assert_eq!(frames[0], (0x0AAAi16, 0x0BBBi16));
assert_eq!(frames[1], (0x0123i16, 0x4567i16));
assert_eq!(frames[2], (0x00FFi16, 0x7F00i16));
}
#[test]
fn extra_bclks_within_window_are_ignored() {
let mut cap = I2sCapture::new(125_000_000, BCLK, LRCLK, DOUT);
let mut cycle: u64 = 0;
clock_philips_frame(&mut cap, &mut cycle, 0, 0);
for _ in 0..25 {
cap.tick(pads(false, true, true), cycle);
cycle += 1;
cap.tick(pads(true, true, true), cycle);
cycle += 1;
}
cap.tick(pads(false, false, true), cycle); cycle += 1;
cap.tick(pads(true, false, true), cycle); cycle += 1;
for _ in 0..15 {
cap.tick(pads(false, false, false), cycle);
cycle += 1;
cap.tick(pads(true, false, false), cycle);
cycle += 1;
}
cap.tick(pads(false, true, false), cycle); cycle += 1;
cap.tick(pads(true, true, false), cycle);
let last = *cap.frames().last().expect("frame emitted");
assert_eq!(
last,
(0x0000i16, -1i16),
"over-long window must still decode to (0x0000, 0xFFFF=-1), got {:?}",
last,
);
}
#[test]
fn duration_secs_zero_fallback_returns_zero() {
let mut cap = I2sCapture::new(125_000_000, BCLK, LRCLK, DOUT);
cap.tick(pads(false, false, false), 0);
cap.tick(pads(false, true, false), 1);
assert_eq!(cap.lrclk_edge_count(), 1);
assert!(cap.inferred_sample_rate_hz().is_none());
assert_eq!(cap.duration_secs(0), 0.0);
}
#[test]
fn inferred_rate_zero_elapsed_returns_none() {
let mut cap = I2sCapture::new(125_000_000, BCLK, LRCLK, DOUT);
cap.tick(pads(false, false, false), 0);
cap.tick(pads(false, true, false), 0);
cap.tick(pads(false, false, false), 0);
cap.tick(pads(false, true, false), 0);
assert!(cap.lrclk_edge_count() >= 2);
assert!(cap.inferred_sample_rate_hz().is_none());
}
#[test]
fn write_wav_bare_filename_no_parent_creation() {
let dir = tmp_dir().join("bare_name");
let _ = fs::create_dir_all(&dir);
let prev = std::env::current_dir().expect("cwd");
std::env::set_current_dir(&dir).expect("chdir into tmp");
let bare = Path::new("i2s_bare.wav");
let res = write_wav(bare, 22_050, &[(1, 2)]);
std::env::set_current_dir(&prev).expect("restore cwd");
res.expect("bare filename should succeed");
let bytes = fs::read(dir.join("i2s_bare.wav")).expect("read");
assert_eq!(bytes.len(), WAV_HEADER_BYTES + 4);
let _ = fs::remove_file(dir.join("i2s_bare.wav"));
}
#[test]
fn set_sys_clk_hz_and_method_write_wav() {
let mut cap = I2sCapture::new(100_000_000, BCLK, LRCLK, DOUT);
assert_eq!(cap.sys_clk_hz(), 100_000_000);
cap.set_sys_clk_hz(125_000_000);
assert_eq!(cap.sys_clk_hz(), 125_000_000);
let tmp = tmp_dir().join("i2s_method.wav");
cap.write_wav(&tmp, 48_000).expect("method write");
let bytes = fs::read(&tmp).expect("read");
assert_eq!(bytes.len(), WAV_HEADER_BYTES);
let _ = fs::remove_file(&tmp);
}
#[test]
fn duration_secs_uses_inferred_rate_when_available() {
let mut cap = I2sCapture::new(125_000_000, BCLK, LRCLK, DOUT);
let mut cycle: u64 = 0;
clock_philips_frame(&mut cap, &mut cycle, 0x0001, 0x0002);
clock_philips_frame(&mut cap, &mut cycle, 0x0003, 0x0004);
clock_philips_frame(&mut cap, &mut cycle, 0x0005, 0x0006);
let inferred = cap
.inferred_sample_rate_hz()
.expect("at least 2 LRCLK edges expected");
assert!(inferred > 0.0, "inferred rate must be positive");
let frames = cap.frames().len() as f64;
let expected = frames / inferred;
let dur = cap.duration_secs(0);
assert!(
(dur - expected).abs() < 1e-9,
"duration {dur} should match {expected} (frames/inferred)",
);
assert!(dur > 0.0, "duration must be positive when frames > 0");
}
#[test]
fn very_low_bclk_inferred_rate_within_tolerance() {
let sys_clk = 125_000_000u32;
let mut cap = I2sCapture::new(sys_clk, BCLK, LRCLK, DOUT);
let half_period: u64 = 10_000;
let mut lrclk = false;
let mut cycle: u64 = 0;
for _frame in 0..8 {
for _ in 0..half_period {
cap.tick(pads(false, lrclk, false), cycle);
cycle += 1;
}
lrclk = !lrclk;
}
let target = sys_clk as f64 / (2.0 * half_period as f64);
let inferred = cap.inferred_sample_rate_hz().expect("rate");
let rel = (inferred - target).abs() / target;
assert!(
rel < 0.05,
"very-low-BCLK: inferred {inferred:.3} Hz vs target {target:.3} Hz (rel {rel:.3})",
);
}
#[test]
fn very_high_bclk_inferred_rate_within_tolerance() {
let sys_clk = 125_000_000u32;
let mut cap = I2sCapture::new(sys_clk, BCLK, LRCLK, DOUT);
let half_period: u64 = 1;
let mut lrclk = false;
let mut cycle: u64 = 0;
for _ in 0..200 {
for _ in 0..half_period {
cap.tick(pads(false, lrclk, false), cycle);
cycle += 1;
}
lrclk = !lrclk;
}
let target = sys_clk as f64 / (2.0 * half_period as f64);
let inferred = cap.inferred_sample_rate_hz().expect("rate");
let rel = (inferred - target).abs() / target;
assert!(
rel < 0.05,
"very-high-BCLK: inferred {inferred:.3} Hz vs target {target:.3} Hz (rel {rel:.3})",
);
}
#[test]
fn mono_left_only_stream_produces_no_frames() {
let mut cap = I2sCapture::new(125_000_000, BCLK, LRCLK, DOUT);
let mut cycle: u64 = 0;
for _ in 0..15 {
cap.tick(pads(false, true, false), cycle);
cycle += 1;
cap.tick(pads(true, true, false), cycle);
cycle += 1;
}
cap.tick(pads(false, false, false), cycle);
cycle += 1;
cap.tick(pads(true, false, false), cycle); cycle += 1;
for _ in 0..100 {
cap.tick(pads(false, false, true), cycle);
cycle += 1;
cap.tick(pads(true, false, true), cycle);
cycle += 1;
}
assert_eq!(
cap.frames().len(),
0,
"mono-left-only stream must not emit stereo frames",
);
}
#[test]
fn write_wav_existing_parent_skips_create_dir_all() {
let root = tmp_dir();
let dir = root.join("i2s_existing_parent");
let _ = fs::create_dir_all(&dir);
let path = dir.join("first.wav");
write_wav(&path, 22_050, &[(10, 20)]).expect("first write");
assert!(path.exists());
let path2 = dir.join("second.wav");
write_wav(&path2, 22_050, &[(30, 40)]).expect("second write");
assert!(path2.exists());
let _ = fs::remove_dir_all(&dir);
}
}