| 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 | |
| 4 | pub mod video; |
| 5 | |
| 6 | use onestore::page::{Attachment, Recording}; |
| 7 | |
| 8 | /// The rate audio is recorded at: wideband speech, as OneNote's own 8 kHz WMA is narrowband. |
| 9 | pub 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`. |
| 13 | pub 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. |
| 20 | pub 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. |
| 78 | pub 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. |
| 89 | fn 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. |
| 94 | pub 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`. |
| 116 | pub 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. |
| 125 | struct 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 | |
| 137 | impl<'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. |
| 184 | const 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 | ]; |
| 191 | const 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. |
| 194 | const BLOCK: usize = 512; |
| 195 | /// Samples per block: the header's, then two per remaining byte. |
| 196 | const 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. |
| 200 | pub 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. |
| 265 | fn 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. |
| 293 | pub 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. |
| 299 | fn 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)] |
| 335 | mod 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 | } |