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 .chunks_exact(2)
208 .map(|pair| i16::from_le_bytes([pair[0], pair[1]]))
209 .collect();
210 let mut data = Vec::with_capacity(samples.len() / 2 + BLOCK);
211 let mut index = 0_i32;
212 for block in samples.chunks(BLOCK_SAMPLES) {
213 let mut predicted = i32::from(block[0]);
214 data.extend_from_slice(&block[0].to_le_bytes());
215 data.extend_from_slice(&[index as u8, 0]);
216 // A short last block is padded with its last sample; `fact` gives the true length.
217 let last = *block.last().unwrap();
218 let rest = block[1..]
219 .iter()
220 .copied()
221 .chain(std::iter::repeat(last))
222 .take(BLOCK_SAMPLES - 1)
223 .collect::<Vec<_>>();
224 for pair in rest.chunks_exact(2) {
225 let mut byte = 0;
226 for (shift, sample) in [0, 4].into_iter().zip(pair) {
227 let code = encode(i32::from(*sample), &mut predicted, &mut index);
228 byte |= code << shift;
229 }
230 data.push(byte);
231 }
232 }
233 let count = u32::try_from(samples.len()).ok()?;
234 let bytes_per_second = wave.rate * BLOCK as u32 / BLOCK_SAMPLES as u32;
235 let mut out = Vec::with_capacity(data.len() + 60);
236 let fmt: Vec<u8> = [
237 &0x11_u16.to_le_bytes()[..],
238 &1_u16.to_le_bytes(),
239 &wave.rate.to_le_bytes(),
240 &bytes_per_second.to_le_bytes(),
241 &(BLOCK as u16).to_le_bytes(),
242 &4_u16.to_le_bytes(),
243 &2_u16.to_le_bytes(),
244 &(BLOCK_SAMPLES as u16).to_le_bytes(),
245 ]
246 .concat();
247 let riff = 4 + (8 + fmt.len()) + (8 + 4) + (8 + data.len());
248 out.extend_from_slice(b"RIFF");
249 out.extend_from_slice(&u32::try_from(riff).ok()?.to_le_bytes());
250 out.extend_from_slice(b"WAVEfmt ");
251 out.extend_from_slice(&(fmt.len() as u32).to_le_bytes());
252 out.extend_from_slice(&fmt);
253 out.extend_from_slice(b"fact");
254 out.extend_from_slice(&4_u32.to_le_bytes());
255 out.extend_from_slice(&count.to_le_bytes());
256 out.extend_from_slice(b"data");
257 out.extend_from_slice(&u32::try_from(data.len()).ok()?.to_le_bytes());
258 out.extend_from_slice(&data);
259 let duration = u32::try_from(u64::from(count) * 1000 / u64::from(wave.rate.max(1))).ok()?;
260 Some((out, duration))
261}
262
263/// The 4-bit code nearest `sample` from `predicted`, which it and `index` then follow as a
264/// decoder does.
265fn encode(sample: i32, predicted: &mut i32, index: &mut i32) -> u8 {
266 let step = STEPS[*index as usize];
267 let mut difference = sample - *predicted;
268 let mut code = 0;
269 if difference < 0 {
270 code = 8;
271 difference = -difference;
272 }
273 let mut delta = step >> 3;
274 for (bit, part) in [(4, step), (2, step >> 1), (1, step >> 2)] {
275 if difference >= part {
276 code |= bit;
277 difference -= part;
278 delta += part;
279 }
280 }
281 *predicted = if code & 8 != 0 {
282 *predicted - delta
283 } else {
284 *predicted + delta
285 }
286 .clamp(-32768, 32767);
287 *index = (*index + INDEX_STEPS[usize::from(code & 7)]).clamp(0, 88);
288 code
289}
290
291/// A mono IMA ADPCM WAV file as 16-bit PCM, which every platform's player decodes; none
292/// for other files.
293pub fn decompress(wav: &[u8]) -> Option<Vec<u8>> {
294 let wave = Wave::parse(wav).filter(|wave| wave.format == 0x11 && wave.channels == 1)?;
295 Some(self::wave(wave.rate, &decode(&wave)))
296}
297
298/// Each block's header sample and index, then its codes.
299fn decode(wave: &Wave<'_>) -> Vec<i16> {
300 let mut out = Vec::new();
301 for block in wave.data.chunks(usize::from(wave.block.max(4))) {
302 let Some(header) = block.get(..4) else {
303 break;
304 };
305 let mut predicted = i32::from(i16::from_le_bytes([header[0], header[1]]));
306 let mut index = i32::from(header[2]).clamp(0, 88);
307 out.push(predicted as i16);
308 for byte in &block[4..] {
309 for code in [byte & 15, byte >> 4] {
310 let step = STEPS[index as usize];
311 let mut delta = step >> 3;
312 for (bit, part) in [(4, step), (2, step >> 1), (1, step >> 2)] {
313 if code & bit != 0 {
314 delta += part;
315 }
316 }
317 predicted = if code & 8 != 0 {
318 predicted - delta
319 } else {
320 predicted + delta
321 }
322 .clamp(-32768, 32767);
323 index = (index + INDEX_STEPS[usize::from(code & 7)]).clamp(0, 88);
324 out.push(predicted as i16);
325 }
326 }
327 }
328 if let Some(samples) = wave.samples {
329 out.truncate(samples as usize);
330 }
331 out
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337
338 /// Two seconds of a spoken-pitch chord.
339 fn tone() -> Vec<i16> {
340 (0..2 * RATE)
341 .map(|n| {
342 let t = n as f32 / RATE as f32;
343 let wave = (t * 220.0 * std::f32::consts::TAU).sin() * 0.5
344 + (t * 330.0 * std::f32::consts::TAU).sin() * 0.3;
345 (wave * 12_000.0) as i16
346 })
347 .collect()
348 }
349
350 #[test]
351 fn a_recording_compresses_to_ima_adpcm_that_decodes_close_to_what_was_heard() {
352 let samples = tone();
353 let (bytes, duration) = compress(&wave(RATE, &samples)).unwrap();
354 assert_eq!(duration, 2000);
355 let wave = Wave::parse(&bytes).unwrap();
356 assert_eq!(
357 (wave.format, wave.channels, wave.rate, wave.bits),
358 (0x11, 1, RATE, 4)
359 );
360 assert_eq!(wave.data.len() % BLOCK, 0);
361 assert_eq!(wave.duration_ms(), 2000);
362 assert!(bytes.len() * 3 < samples.len() * 2);
363 let decoded = decode(&wave);
364 assert_eq!(
365 Wave::parse(&decompress(&bytes).unwrap())
366 .unwrap()
367 .data
368 .len(),
369 decoded.len() * 2
370 );
371 assert_eq!(decoded.len(), samples.len());
372 let noise: f64 = samples
373 .iter()
374 .zip(&decoded)
375 .map(|(a, b)| (f64::from(*a) - f64::from(*b)).powi(2))
376 .sum();
377 let signal: f64 = samples.iter().map(|a| f64::from(*a).powi(2)).sum();
378 let snr = 10.0 * (signal / noise).log10();
379 assert!(snr > 20.0, "{snr} dB");
380 }
381
382 #[test]
383 fn only_mono_16_bit_pcm_compresses_and_attached_wav_files_know_their_length() {
384 let stereo = {
385 let mut bytes = wave(RATE, &[0; 64]);
386 bytes[22] = 2;
387 bytes
388 };
389 assert!(compress(&stereo).is_none());
390 assert!(compress(b"not a wave").is_none());
391 let silence = include_bytes!(
392 "../../../corpus/m6/native-features-01/notebook/fixture-data/silence.wav"
393 );
394 // OneNote 2010 stored 1000 ms for this file when attached (`corpus/recording`).
395 let recording = attached("silence.WAV", silence).unwrap();
396 assert_eq!((recording.kind, recording.duration_ms), (1, Some(1000)));
397 assert_eq!(attached("clip.wmv", b"").unwrap().kind, 2);
398 assert!(attached("notes.txt", b"").is_none());
399 }
400
401 #[test]
402 fn a_recording_is_stored_named_after_its_page_and_plays_as_pcm() {
403 let id = [7; 16];
404 let audio = file(id, "Lecture 3/4", false, wave(RATE, &tone())).unwrap();
405 assert_eq!(audio.filename, "Lecture 3_4.wav");
406 let recording = audio.recording.unwrap();
407 assert_eq!(
408 (recording.id, recording.kind, recording.duration_ms),
409 (id, 1, Some(2000))
410 );
411 let stored = audio.bytes.unwrap();
412 assert_eq!(Wave::parse(&stored).unwrap().format, 0x11);
413 let played = sound(&stored).unwrap();
414 assert_eq!(Wave::parse(&played).unwrap().format, 1);
415 assert_eq!(
416 file(id, " ", true, Vec::new()).unwrap().filename,
417 "Video recording.avi"
418 );
419 assert_eq!(
420 label(false, "Tuesday, September 29, 2026", "5:18 PM"),
421 "Audio recording started: 5:18 PM Tuesday, September 29, 2026"
422 );
423 }
424}