1//! Recordings as Snowbound stores them, which OneNote 2010 plays (`corpus/recording`):
2//! audio as 16 kHz IMA ADPCM WAV, video as the Motion JPEG AVI files of `video`.
3
4pub mod video;
5
6use onestore::page::{Attachment, Recording};
7
8/// The rate audio is recorded at: wideband speech, as OneNote's own 8 kHz WMA is narrowband.
9pub const RATE: u32 = 16_000;
10
11/// The line saying when a recording started, as OneNote writes it from the long `date` and
12/// short `time`.
13pub fn label(video: bool, date: &str, time: &str) -> String {
14 let kind = if video { "Video" } else { "Audio" };
15 format!("{kind} recording started: {time} {date}")
16}
17
18/// The file recording `id` on page `title` is stored as, from what was recorded: a mono
19/// 16-bit PCM WAV file, which is compressed, or a `video` AVI file. It has no icon yet.
20pub fn file(
21 id: [u8; 16],
22 title: &str,
23 video: bool,
24 recorded: Vec<u8>,
25) -> Result<Attachment, onestore::page::text::EditError> {
26 let (bytes, duration_ms) = if video {
27 let duration = video::Movie::parse(&recorded).map(|movie| movie.duration_ms());
28 (recorded, duration)
29 } else {
30 match compress(&recorded) {
31 Some((bytes, duration)) => (bytes, Some(duration)),
32 None => {
33 let duration = Wave::parse(&recorded).map(|wave| wave.duration_ms());
34 (recorded, duration)
35 }
36 }
37 };
38 let name: String = title
39 .chars()
40 .map(|c| {
41 if matches!(c, '/' | '\\' | '\0') {
42 '_'
43 } else {
44 c
45 }
46 })
47 .collect();
48 let name = if name.trim().is_empty() {
49 if video {
50 "Video recording"
51 } else {
52 "Audio recording"
53 }
54 .to_owned()
55 } else {
56 name
57 };
58 let extension = if video { "avi" } else { "wav" };
59 Ok(Attachment {
60 id: onestore::page::text::new_id()?,
61 filename: format!("{name}.{extension}"),
62 source_path: None,
63 size: Some(crate::gpu::page::ICON_SIZE),
64 layout: Default::default(),
65 bytes: Some(bytes.into()),
66 preview: None,
67 recording: Some(Recording {
68 id,
69 kind: if video { 2 } else { 1 },
70 duration_ms,
71 }),
72 tags: Vec::new(),
73 })
74}
75
76/// A recording's sound as a 16-bit PCM WAV file every platform's player decodes: IMA ADPCM
77/// decoded, a video's sound taken out of it. None for other files, which play as they are.
78pub fn sound(bytes: &[u8]) -> Option<Vec<u8>> {
79 let sound = match video::Movie::parse(bytes) {
80 Some(movie) => movie
81 .wave(bytes)
82 .unwrap_or_else(|| silence(movie.duration_ms())),
83 None => return decompress(bytes),
84 };
85 Some(decompress(&sound).unwrap_or(sound))
86}
87
88/// Silence as long as `ms`, the clock of a video without sound.
89fn silence(ms: u32) -> Vec<u8> {
90 wave(8_000, &vec![0; ms as usize * 8])
91}
92
93/// A mono 16-bit PCM WAV file of `samples` at `rate`, as the platform recorders write.
94pub fn wave(rate: u32, samples: &[i16]) -> Vec<u8> {
95 let data: Vec<u8> = samples.iter().flat_map(|s| s.to_le_bytes()).collect();
96 [
97 &b"RIFF"[..],
98 &(36 + data.len() as u32).to_le_bytes(),
99 b"WAVEfmt ",
100 &16_u32.to_le_bytes(),
101 &1_u16.to_le_bytes(),
102 &1_u16.to_le_bytes(),
103 &rate.to_le_bytes(),
104 &(rate * 2).to_le_bytes(),
105 &2_u16.to_le_bytes(),
106 &16_u16.to_le_bytes(),
107 b"data",
108 &(data.len() as u32).to_le_bytes(),
109 &data,
110 ]
111 .concat()
112}
113
114/// A recording for a file attached under `filename`, as OneNote 2010 makes one of every
115/// audio and video file it attaches, with the length of a WAV file's `bytes`.
116pub fn attached(filename: &str, bytes: &[u8]) -> Option<Recording> {
117 Some(Recording {
118 id: onestore::page::text::new_guid().ok()?,
119 kind: Recording::kind_of(filename)?,
120 duration_ms: Wave::parse(bytes).map(|wave| wave.duration_ms()),
121 })
122}
123
124/// The chunks of a RIFF WAVE file this module reads.
125struct Wave<'a> {
126 format: u16,
127 channels: u16,
128 rate: u32,
129 bytes_per_second: u32,
130 block: u16,
131 bits: u16,
132 /// The sample count a compressed file's `fact` chunk states.
133 samples: Option<u32>,
134 data: &'a [u8],
135}
136
137impl<'a> Wave<'a> {
138 fn parse(bytes: &'a [u8]) -> Option<Self> {
139 if bytes.get(..4)? != b"RIFF" || bytes.get(8..12)? != b"WAVE" {
140 return None;
141 }
142 let u16_at = |data: &[u8], at: usize| {
143 Some(u16::from_le_bytes(data.get(at..at + 2)?.try_into().ok()?))
144 };
145 let u32_at = |data: &[u8], at: usize| {
146 Some(u32::from_le_bytes(data.get(at..at + 4)?.try_into().ok()?))
147 };
148 let (mut fmt, mut samples, mut data) = (None, None, None);
149 let mut at = 12;
150 while at + 8 <= bytes.len() {
151 let size = u32_at(bytes, at + 4)? as usize;
152 let body = &bytes[at + 8..(at + 8).saturating_add(size).min(bytes.len())];
153 match &bytes[at..at + 4] {
154 b"fmt " => fmt = Some(body),
155 b"fact" => samples = u32_at(body, 0),
156 b"data" => data = Some(body),
157 _ => {}
158 }
159 at = at + 8 + size + size % 2;
160 }
161 let fmt = fmt?;
162 Some(Self {
163 format: u16_at(fmt, 0)?,
164 channels: u16_at(fmt, 2)?,
165 rate: u32_at(fmt, 4)?,
166 bytes_per_second: u32_at(fmt, 8)?,
167 block: u16_at(fmt, 12)?,
168 bits: u16_at(fmt, 14)?,
169 samples,
170 data: data?,
171 })
172 }
173
174 fn duration_ms(&self) -> u32 {
175 let ms = match self.samples.filter(|_| self.format != 1) {
176 Some(samples) => u64::from(samples) * 1000 / u64::from(self.rate.max(1)),
177 None => self.data.len() as u64 * 1000 / u64::from(self.bytes_per_second.max(1)),
178 };
179 u32::try_from(ms).unwrap_or(u32::MAX)
180 }
181}
182
183/// IMA ADPCM's step sizes and how each code moves along them.
184const STEPS: [i32; 89] = [
185 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 19, 21, 23, 25, 28, 31, 34, 37, 41, 45, 50, 55, 60, 66,
186 73, 80, 88, 97, 107, 118, 130, 143, 157, 173, 190, 209, 230, 253, 279, 307, 337, 371, 408, 449,
187 494, 544, 598, 658, 724, 796, 876, 963, 1060, 1166, 1282, 1411, 1552, 1707, 1878, 2066, 2272,
188 2499, 2749, 3024, 3327, 3660, 4026, 4428, 4871, 5358, 5894, 6484, 7132, 7845, 8630, 9493,
189 10442, 11487, 12635, 13899, 15289, 16818, 18500, 20350, 22385, 24623, 27086, 29794, 32767,
190];
191const INDEX_STEPS: [i32; 8] = [-1, -1, -1, -1, 2, 4, 6, 8];
192
193/// Bytes per block: Windows' IMA ADPCM codec's choice for 16 kHz.
194const BLOCK: usize = 512;
195/// Samples per block: the header's, then two per remaining byte.
196const BLOCK_SAMPLES: usize = (BLOCK - 4) * 2 + 1;
197
198/// A mono 16-bit PCM WAV file as 4-bit IMA ADPCM (WAVE_FORMAT_DVI_ADPCM), which Windows
199/// and macOS decode, a quarter of the size; and its length. None for other files.
200pub fn compress(wav: &[u8]) -> Option<(Vec<u8>, u32)> {
201 let wave = Wave::parse(wav)?;
202 if wave.format != 1 || wave.channels != 1 || wave.bits != 16 {
203 return None;
204 }
205 let samples: Vec<i16> = wave
206 .data
207 .as_chunks::<2>()
208 .0
209 .iter()
210 .map(|pair| i16::from_le_bytes([pair[0], pair[1]]))
211 .collect();
212 let mut data = Vec::with_capacity(samples.len() / 2 + BLOCK);
213 let mut index = 0_i32;
214 for block in samples.chunks(BLOCK_SAMPLES) {
215 let mut predicted = i32::from(block[0]);
216 data.extend_from_slice(&block[0].to_le_bytes());
217 data.extend_from_slice(&[index as u8, 0]);
218 // A short last block is padded with its last sample; `fact` gives the true length.
219 let last = *block.last().unwrap();
220 let rest = block[1..]
221 .iter()
222 .copied()
223 .chain(std::iter::repeat(last))
224 .take(BLOCK_SAMPLES - 1)
225 .collect::<Vec<_>>();
226 for pair in rest.as_chunks::<2>().0 {
227 let mut byte = 0;
228 for (shift, sample) in [0, 4].into_iter().zip(pair) {
229 let code = encode(i32::from(*sample), &mut predicted, &mut index);
230 byte |= code << shift;
231 }
232 data.push(byte);
233 }
234 }
235 let count = u32::try_from(samples.len()).ok()?;
236 let bytes_per_second = wave.rate * BLOCK as u32 / BLOCK_SAMPLES as u32;
237 let mut out = Vec::with_capacity(data.len() + 60);
238 let fmt: Vec<u8> = [
239 &0x11_u16.to_le_bytes()[..],
240 &1_u16.to_le_bytes(),
241 &wave.rate.to_le_bytes(),
242 &bytes_per_second.to_le_bytes(),
243 &(BLOCK as u16).to_le_bytes(),
244 &4_u16.to_le_bytes(),
245 &2_u16.to_le_bytes(),
246 &(BLOCK_SAMPLES as u16).to_le_bytes(),
247 ]
248 .concat();
249 let riff = 4 + (8 + fmt.len()) + (8 + 4) + (8 + data.len());
250 out.extend_from_slice(b"RIFF");
251 out.extend_from_slice(&u32::try_from(riff).ok()?.to_le_bytes());
252 out.extend_from_slice(b"WAVEfmt ");
253 out.extend_from_slice(&(fmt.len() as u32).to_le_bytes());
254 out.extend_from_slice(&fmt);
255 out.extend_from_slice(b"fact");
256 out.extend_from_slice(&4_u32.to_le_bytes());
257 out.extend_from_slice(&count.to_le_bytes());
258 out.extend_from_slice(b"data");
259 out.extend_from_slice(&u32::try_from(data.len()).ok()?.to_le_bytes());
260 out.extend_from_slice(&data);
261 let duration = u32::try_from(u64::from(count) * 1000 / u64::from(wave.rate.max(1))).ok()?;
262 Some((out, duration))
263}
264
265/// The 4-bit code nearest `sample` from `predicted`, which it and `index` then follow as a
266/// decoder does.
267fn encode(sample: i32, predicted: &mut i32, index: &mut i32) -> u8 {
268 let step = STEPS[*index as usize];
269 let mut difference = sample - *predicted;
270 let mut code = 0;
271 if difference < 0 {
272 code = 8;
273 difference = -difference;
274 }
275 let mut delta = step >> 3;
276 for (bit, part) in [(4, step), (2, step >> 1), (1, step >> 2)] {
277 if difference >= part {
278 code |= bit;
279 difference -= part;
280 delta += part;
281 }
282 }
283 *predicted = if code & 8 != 0 {
284 *predicted - delta
285 } else {
286 *predicted + delta
287 }
288 .clamp(-32768, 32767);
289 *index = (*index + INDEX_STEPS[usize::from(code & 7)]).clamp(0, 88);
290 code
291}
292
293/// A mono IMA ADPCM WAV file as 16-bit PCM, which every platform's player decodes; none
294/// for other files.
295pub fn decompress(wav: &[u8]) -> Option<Vec<u8>> {
296 let wave = Wave::parse(wav).filter(|wave| wave.format == 0x11 && wave.channels == 1)?;
297 Some(self::wave(wave.rate, &decode(&wave)))
298}
299
300/// Each block's header sample and index, then its codes.
301fn decode(wave: &Wave<'_>) -> Vec<i16> {
302 let mut out = Vec::new();
303 for block in wave.data.chunks(usize::from(wave.block.max(4))) {
304 let Some(header) = block.get(..4) else {
305 break;
306 };
307 let mut predicted = i32::from(i16::from_le_bytes([header[0], header[1]]));
308 let mut index = i32::from(header[2]).clamp(0, 88);
309 out.push(predicted as i16);
310 for byte in &block[4..] {
311 for code in [byte & 15, byte >> 4] {
312 let step = STEPS[index as usize];
313 let mut delta = step >> 3;
314 for (bit, part) in [(4, step), (2, step >> 1), (1, step >> 2)] {
315 if code & bit != 0 {
316 delta += part;
317 }
318 }
319 predicted = if code & 8 != 0 {
320 predicted - delta
321 } else {
322 predicted + delta
323 }
324 .clamp(-32768, 32767);
325 index = (index + INDEX_STEPS[usize::from(code & 7)]).clamp(0, 88);
326 out.push(predicted as i16);
327 }
328 }
329 }
330 if let Some(samples) = wave.samples {
331 out.truncate(samples as usize);
332 }
333 out
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339
340 /// Two seconds of a spoken-pitch chord.
341 fn tone() -> Vec<i16> {
342 (0..2 * RATE)
343 .map(|n| {
344 let t = n as f32 / RATE as f32;
345 let wave = (t * 220.0 * std::f32::consts::TAU).sin() * 0.5
346 + (t * 330.0 * std::f32::consts::TAU).sin() * 0.3;
347 (wave * 12_000.0) as i16
348 })
349 .collect()
350 }
351
352 #[test]
353 fn a_recording_compresses_to_ima_adpcm_that_decodes_close_to_what_was_heard() {
354 let samples = tone();
355 let (bytes, duration) = compress(&wave(RATE, &samples)).unwrap();
356 assert_eq!(duration, 2000);
357 let wave = Wave::parse(&bytes).unwrap();
358 assert_eq!(
359 (wave.format, wave.channels, wave.rate, wave.bits),
360 (0x11, 1, RATE, 4)
361 );
362 assert_eq!(wave.data.len() % BLOCK, 0);
363 assert_eq!(wave.duration_ms(), 2000);
364 assert!(bytes.len() * 3 < samples.len() * 2);
365 let decoded = decode(&wave);
366 assert_eq!(
367 Wave::parse(&decompress(&bytes).unwrap())
368 .unwrap()
369 .data
370 .len(),
371 decoded.len() * 2
372 );
373 assert_eq!(decoded.len(), samples.len());
374 let noise: f64 = samples
375 .iter()
376 .zip(&decoded)
377 .map(|(a, b)| (f64::from(*a) - f64::from(*b)).powi(2))
378 .sum();
379 let signal: f64 = samples.iter().map(|a| f64::from(*a).powi(2)).sum();
380 let snr = 10.0 * (signal / noise).log10();
381 assert!(snr > 20.0, "{snr} dB");
382 }
383
384 #[test]
385 fn only_mono_16_bit_pcm_compresses_and_attached_wav_files_know_their_length() {
386 let stereo = {
387 let mut bytes = wave(RATE, &[0; 64]);
388 bytes[22] = 2;
389 bytes
390 };
391 assert!(compress(&stereo).is_none());
392 assert!(compress(b"not a wave").is_none());
393 let silence = include_bytes!(
394 "../../../corpus/m6/native-features-01/notebook/fixture-data/silence.wav"
395 );
396 // OneNote 2010 stored 1000 ms for this file when attached (`corpus/recording`).
397 let recording = attached("silence.WAV", silence).unwrap();
398 assert_eq!((recording.kind, recording.duration_ms), (1, Some(1000)));
399 assert_eq!(attached("clip.wmv", b"").unwrap().kind, 2);
400 assert!(attached("notes.txt", b"").is_none());
401 }
402
403 #[test]
404 fn a_recording_is_stored_named_after_its_page_and_plays_as_pcm() {
405 let id = [7; 16];
406 let audio = file(id, "Lecture 3/4", false, wave(RATE, &tone())).unwrap();
407 assert_eq!(audio.filename, "Lecture 3_4.wav");
408 let recording = audio.recording.unwrap();
409 assert_eq!(
410 (recording.id, recording.kind, recording.duration_ms),
411 (id, 1, Some(2000))
412 );
413 let stored = audio.bytes.unwrap();
414 assert_eq!(Wave::parse(&stored).unwrap().format, 0x11);
415 let played = sound(&stored).unwrap();
416 assert_eq!(Wave::parse(&played).unwrap().format, 1);
417 assert_eq!(
418 file(id, " ", true, Vec::new()).unwrap().filename,
419 "Video recording.avi"
420 );
421 assert_eq!(
422 label(false, "Tuesday, September 29, 2026", "5:18 PM"),
423 "Audio recording started: 5:18 PM Tuesday, September 29, 2026"
424 );
425 }
426}