openentropy_core/sources/sensor/
audio.rs1use crate::source::{EntropySource, Platform, Requirement, SourceCategory, SourceInfo};
8
9use crate::sources::helpers::{command_exists, pack_nibbles, run_command_output_timeout};
10
11const CAPTURE_DURATION: &str = "0.1";
13
14const 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
35pub struct AudioNoiseConfig {
37 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#[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 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, );
93
94 let raw_audio = match result {
95 Some(output) => output.stdout,
96 None => return Vec::new(),
97 };
98
99 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] 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}