Skip to main content

openentropy_core/sources/sensor/
audio.rs

1//! AudioNoiseSource — Microphone ADC noise via ffmpeg.
2//!
3//! Captures a short burst of audio from the default input device using ffmpeg's
4//! avfoundation backend, then extracts the lower 4 bits of each int16 sample.
5//! These LSBs are dominated by Johnson-Nyquist thermal noise.
6
7use crate::source::{EntropySource, Platform, Requirement, SourceCategory, SourceInfo};
8
9use crate::sources::helpers::{command_exists, pack_nibbles, run_command_output_timeout};
10
11/// Duration of audio capture in seconds.
12const CAPTURE_DURATION: &str = "0.1";
13
14/// Sample rate for audio capture.
15const SAMPLE_RATE: &str = "44100";
16
17static AUDIO_NOISE_INFO: SourceInfo = SourceInfo {
18    name: "audio_noise",
19    description: "Microphone ADC thermal noise (Johnson-Nyquist) via ffmpeg",
20    physics: "Records from the microphone ADC with no signal present. The LSBs capture \
21              Johnson-Nyquist noise \u{2014} thermal agitation of electrons in the input \
22              impedance. At audio frequencies (up to ~44 kHz), this noise is entirely \
23              classical: hf \u{226a} kT by a factor of ~10^8 at room temperature. Laptop \
24              audio codecs use CMOS input stages where channel thermal noise and 1/f \
25              flicker noise dominate; shot noise is negligible. \
26              Voltage noise \u{221d} \u{221a}(4kT R \u{0394}f).",
27    category: SourceCategory::Sensor,
28    platform: Platform::MacOS,
29    requirements: &[Requirement::AudioUnit],
30    entropy_rate_estimate: 6.0,
31    composite: false,
32    is_fast: false,
33};
34
35/// Configuration for audio device selection.
36pub struct AudioNoiseConfig {
37    /// AVFoundation audio device index (e.g., 0, 1, 2).
38    /// `None` means use default (`:0`).
39    pub device_index: Option<u32>,
40}
41
42impl Default for AudioNoiseConfig {
43    fn default() -> Self {
44        let device_index = std::env::var("OPENENTROPY_AUDIO_DEVICE")
45            .ok()
46            .and_then(|s| s.parse().ok());
47        Self { device_index }
48    }
49}
50
51/// Entropy source that harvests thermal noise from the microphone ADC.
52#[derive(Default)]
53pub struct AudioNoiseSource {
54    pub config: AudioNoiseConfig,
55}
56
57impl EntropySource for AudioNoiseSource {
58    fn info(&self) -> &SourceInfo {
59        &AUDIO_NOISE_INFO
60    }
61
62    fn is_available(&self) -> bool {
63        cfg!(target_os = "macos") && command_exists("ffmpeg")
64    }
65
66    fn collect(&self, n_samples: usize) -> Vec<u8> {
67        // Capture raw signed 16-bit PCM audio from the default input device.
68        // ffmpeg -f avfoundation -i ":0" -t 0.1 -f s16le -ar 44100 -ac 1 pipe:1
69        let device_input = format!(":{}", self.config.device_index.unwrap_or(0));
70        let result = run_command_output_timeout(
71            "ffmpeg",
72            &[
73                "-hide_banner",
74                "-loglevel",
75                "error",
76                "-nostdin",
77                "-f",
78                "avfoundation",
79                "-i",
80                &device_input,
81                "-t",
82                CAPTURE_DURATION,
83                "-f",
84                "s16le",
85                "-ar",
86                SAMPLE_RATE,
87                "-ac",
88                "1",
89                "pipe:1",
90            ],
91            5000, // 5 second timeout — ffmpeg capture is 0.1s, generous margin
92        );
93
94        let raw_audio = match result {
95            Some(output) => output.stdout,
96            None => return Vec::new(),
97        };
98
99        // Each sample is 2 bytes (signed 16-bit little-endian).
100        // Extract the lower 4 bits of each sample as entropy.
101        let nibbles = raw_audio.chunks_exact(2).map(|chunk| {
102            let sample = i16::from_le_bytes([chunk[0], chunk[1]]);
103            (sample & 0x0F) as u8
104        });
105
106        pack_nibbles(nibbles, n_samples)
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    #[test]
115    fn audio_noise_info() {
116        let src = AudioNoiseSource::default();
117        assert_eq!(src.name(), "audio_noise");
118        assert_eq!(src.info().category, SourceCategory::Sensor);
119        assert_eq!(src.info().entropy_rate_estimate, 6.0);
120        assert!(!src.info().composite);
121    }
122
123    #[test]
124    fn audio_config_default_is_none() {
125        let config = AudioNoiseConfig { device_index: None };
126        assert!(config.device_index.is_none());
127    }
128
129    #[test]
130    fn audio_config_explicit_device() {
131        let src = AudioNoiseSource {
132            config: AudioNoiseConfig {
133                device_index: Some(2),
134            },
135        };
136        assert_eq!(src.config.device_index, Some(2));
137    }
138
139    #[test]
140    #[cfg(target_os = "macos")]
141    #[ignore] // Requires microphone and ffmpeg
142    fn audio_noise_collects_bytes() {
143        let src = AudioNoiseSource::default();
144        if src.is_available() {
145            let data = src.collect(64);
146            assert!(!data.is_empty());
147            assert!(data.len() <= 64);
148        }
149    }
150}