//! TRACES: FR-DEV-3e //! The camera profile's tables as an operation (D20). //! //! The matrix turns camera RGB into colour; a DNG camera profile adds two //! lookups over hue, saturation and value on top of it — the `HueSatMap`, a //! calibration, and the `LookTable`, a rendering intent. This operation //! applies them. `docs/dev/camera-profiles.md` is the design. //! //! # Where the tables come from //! //! Not from here. They belong to the *source*, like the matrix: `dr-decode` //! resolves them per file and `dr-gpu` uploads them to the storage buffer //! every generated shader declares at `@binding(8)`, laid out by //! [`profile_buffer`]. This operation holds only the photographer's two //! settings — whether to use the profile, and how strongly to apply its look //! — so a render path never has to remember to hand it anything. //! //! # Why it is composed at its defaults //! //! A profile that is on is the rendering, not an edit: an untouched raw //! renders through it and writes no parameters. So [`Operation::composes`] //! answers "is the switch on", not "has anything moved". The fragment then //! branches on the buffer's header, which says whether this source has tables //! at all; a JPEG, or a raw with no profile, reads two zeros and passes //! through. //! //! # The lookup //! //! The DNG SDK's `RefBaselineHueSatMap`, with the two departures §2 of the //! design gives for scene-referred values: value is not clamped on the way //! out, and a colour with a negative ProPhoto component passes through. //! [`apply_reference`] is the same arithmetic on the CPU, and the GPU tests //! hold the shader to it. use std::sync::{Arc, LazyLock}; use dr_types::{HueSatTable, ProfileTables}; use crate::descriptor::{ Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit, }; use crate::operation::{Helper, Operation, Uniform}; pub const ID: OpId = OpId("camera_profile"); pub const APPLY: ParamId = ParamId("apply"); pub const LOOK: ParamId = ParamId("look"); /// The look's strength at which the LookTable is applied as the profile /// states it, in percent. pub const PROFILE_LOOK: f32 = 100.0; /// The look's default strength: off. /// /// Measured, not chosen. Against the photographer's earlier exports with no /// look applied, the default rendering scores the same with the table at 100, /// 50 or 0 (held-out MSE 140, 140, 143), and is 9 % more colourful without /// it: the table desaturates near-neutral tones, which is exactly where the /// default rendering was short of those exports. The table stays one slider /// away for anyone who wants the profile's look. pub const DEFAULT_LOOK: f32 = 0.0; /// Twice the profile's look. pub const MAX_LOOK: f32 = 2.0 * PROFILE_LOOK; /// Entries of the buffer's header, before the entries themselves: one /// `vec4` describing each table — `(hue divisions, saturation divisions, /// value divisions, sRGB-encoded)`, zero hue divisions meaning absent — and /// a third whose `.x` is the tone curve's length (camera-profiles.md §12). pub const HEADER_ENTRIES: usize = 3; static DESCRIPTOR: LazyLock> = LazyLock::new(|| { Arc::new(OpDescriptor { attributes: vec![Attribute::Colour], id: ID, label: LocalizedKey("op.camera_profile"), params: vec![ ParamDescriptor::switch_on("apply", "param.camera_profile.apply"), ParamDescriptor::scalar( "look", "param.camera_profile.look", 0.0, MAX_LOOK, DEFAULT_LOOK, Unit::Percent, Scale::Linear, 0, ), ], }) }); /// Linear sRGB (the working space) to linear ProPhoto, and back, row-major, /// each row scaled to sum to one so that working white is ProPhoto white /// exactly and a neutral reaches the tables with zero saturation. pub(crate) fn working_prophoto() -> &'static ([f32; 9], [f32; 9]) { static M: LazyLock<([f32; 9], [f32; 9])> = LazyLock::new(|| { let to = normalise_rows(dr_types::ColourSpace::ProPhoto.from_linear_srgb()); let back = normalise_rows(invert(&to).expect("ProPhoto's matrix is invertible")); (to, back) }); &M } fn normalise_rows(mut m: [f32; 9]) -> [f32; 9] { for row in m.chunks_exact_mut(3) { let sum: f32 = row.iter().sum(); row.iter_mut().for_each(|v| *v /= sum); } m } fn invert(m: &[f32; 9]) -> Option<[f32; 9]> { let [a, b, c, d, e, f, g, h, i] = m.map(f64::from); let det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g); if det.abs() < 1e-12 { return None; } let inv = [ (e * i - f * h) / det, (c * h - b * i) / det, (b * f - c * e) / det, (f * g - d * i) / det, (a * i - c * g) / det, (c * d - a * f) / det, (d * h - e * g) / det, (b * g - a * h) / det, (a * e - b * d) / det, ]; Some(inv.map(|v| v as f32)) } pub(crate) fn mul(m: &[f32; 9], c: [f32; 3]) -> [f32; 3] { std::array::from_fn(|r| m[r * 3] * c[0] + m[r * 3 + 1] * c[1] + m[r * 3 + 2] * c[2]) } /// A row-major matrix as a WGSL `mat3x3`, whose constructor takes columns. fn wgsl_mat(m: &[f32; 9]) -> String { let col = |j: usize| format!("vec3({:e}, {:e}, {:e})", m[j], m[3 + j], m[6 + j]); format!("mat3x3({}, {}, {})", col(0), col(1), col(2)) } /// The working space to ProPhoto and back, as WGSL constants, and where /// the profile buffer's sections begin. A helper of its own because the /// view transform's DNG reference curve needs it too, and helpers are emitted /// once each, in the order first asked for. pub(crate) static PROPHOTO_HELPER: LazyLock = LazyLock::new(|| { let (to, back) = working_prophoto(); let source = format!( "const PROFILE_FROM_WORKING = {};\nconst PROFILE_TO_WORKING = {};\n{SECTIONS_WGSL}", wgsl_mat(to), wgsl_mat(back) ); Helper { name: "profile_curve_base", source: Box::leak(source.into_boxed_str()), } }); static HELPERS: LazyLock<[Helper; 2]> = LazyLock::new(|| { [ *PROPHOTO_HELPER, Helper { name: "profile_apply", source: LOOKUP_WGSL, }, ] }); /// Where each section of the profile buffer starts, from its header. const SECTIONS_WGSL: &str = " fn profile_entries(dims: vec4) -> u32 { return u32(dims.x * dims.y * dims.z); } fn profile_look_base() -> u32 { return 3u + profile_entries(profile_table[0]); } fn profile_curve_base() -> u32 { return profile_look_base() + profile_entries(profile_table[1]); } "; /// The lookup, in WGSL. Mirrors [`apply_reference`] line for line. const LOOKUP_WGSL: &str = r#" fn profile_srgb_encode(v: f32) -> f32 { if (v <= 0.0031308) { return v * 12.92; } return 1.055 * pow(v, 1.0 / 2.4) - 0.055; } fn profile_srgb_decode(v: f32) -> f32 { if (v <= 0.04045) { return v / 12.92; } return pow((v + 0.055) / 1.055, 2.4); } // The DNG SDK's HSV: hue in [0, 6), saturation (max - min) / max, value max. fn profile_rgb_to_hsv(c: vec3) -> vec3 { let v = max(c.r, max(c.g, c.b)); let gap = v - min(c.r, min(c.g, c.b)); if (gap <= 0.0) { return vec3(0.0, 0.0, v); } var h: f32; if (c.r == v) { h = (c.g - c.b) / gap; if (h < 0.0) { h += 6.0; } } else if (c.g == v) { h = 2.0 + (c.b - c.r) / gap; } else { h = 4.0 + (c.r - c.g) / gap; } return vec3(h, gap / v, v); } fn profile_hsv_to_rgb(hsv: vec3) -> vec3 { let s = hsv.y; let v = hsv.z; if (s <= 0.0) { return vec3(v); } let h = hsv.x - 6.0 * floor(hsv.x / 6.0); let i = min(floor(h), 5.0); let f = h - i; let p = v * (1.0 - s); let q = v * (1.0 - s * f); let t = v * (1.0 - s * (1.0 - f)); switch (i32(i)) { case 0: { return vec3(v, t, p); } case 1: { return vec3(q, v, p); } case 2: { return vec3(p, v, t); } case 3: { return vec3(p, q, v); } case 4: { return vec3(t, p, v); } default: { return vec3(v, p, q); } } } fn profile_entry(base: u32, at: u32) -> vec3 { return profile_table[base + at].xyz; } // (hue shift in degrees, saturation scale, value scale) at `hsv`: bilinear // over hue and saturation, hue wrapping, and linear over value for a 3-D // table. Indices are the SDK's. fn profile_lookup(dims: vec4, base: u32, hsv: vec3) -> vec3 { let hd = u32(dims.x); let sd = u32(dims.y); let vd = u32(dims.z); var h0 = 0u; var h1 = 0u; var hf = 0.0; if (hd > 1u) { let hs = hsv.x * f32(hd) / 6.0; h0 = min(u32(hs), hd - 1u); hf = hs - f32(h0); h1 = h0 + 1u; if (h1 >= hd) { h1 = 0u; } } let ss = hsv.y * f32(sd - 1u); let s0 = min(u32(ss), sd - 2u); let sf = ss - f32(s0); var v0 = 0u; var vf = 0.0; if (vd > 1u) { var ve = clamp(hsv.z, 0.0, 1.0); if (dims.w > 0.5) { ve = profile_srgb_encode(ve); } let vs = ve * f32(vd - 1u); v0 = min(u32(vs), vd - 2u); vf = vs - f32(v0); } let val_step = hd * sd; let lo = v0 * val_step; var d = mix( mix(profile_entry(base, lo + h0 * sd + s0), profile_entry(base, lo + h1 * sd + s0), hf), mix(profile_entry(base, lo + h0 * sd + s0 + 1u), profile_entry(base, lo + h1 * sd + s0 + 1u), hf), sf); if (vd > 1u) { let hi = lo + val_step; let e = mix( mix(profile_entry(base, hi + h0 * sd + s0), profile_entry(base, hi + h1 * sd + s0), hf), mix(profile_entry(base, hi + h0 * sd + s0 + 1u), profile_entry(base, hi + h1 * sd + s0 + 1u), hf), sf); d = mix(d, e, vf); } return d; } // One table applied to a ProPhoto colour, its deltas scaled by `amount`. fn profile_apply(dims: vec4, base: u32, c: vec3, amount: f32) -> vec3 { let hsv = profile_rgb_to_hsv(c); var d = profile_lookup(dims, base, hsv); d = vec3(d.x * amount, max(1.0 + (d.y - 1.0) * amount, 0.0), max(1.0 + (d.z - 1.0) * amount, 0.0)); let h = hsv.x + d.x * (6.0 / 360.0); let s = min(hsv.y * d.y, 1.0); var v = hsv.z * d.z; if (dims.w > 0.5) { // The scale is defined on the encoded value; applied as the ratio it // makes at min(v, 1), so a value above 1.0 is scaled, not clipped. let vc = min(hsv.z, 1.0); v = hsv.z; if (vc > 0.0) { v = hsv.z * profile_srgb_decode(profile_srgb_encode(vc) * d.z) / vc; } } return profile_hsv_to_rgb(vec3(h, s, v)); } "#; #[derive(Debug, Clone)] pub struct CameraProfile { apply: bool, look: f32, } impl Default for CameraProfile { fn default() -> Self { Self { apply: true, look: DEFAULT_LOOK, } } } impl CameraProfile { pub fn new() -> Self { Self::default() } } impl Operation for CameraProfile { fn descriptor(&self) -> Arc { DESCRIPTOR.clone() } fn set_param(&mut self, id: ParamId, value: f32) { match id { APPLY => self.apply = value != 0.0, LOOK => self.look = value, _ => log::warn!("camera_profile: unknown parameter {id}"), } } fn param(&self, id: ParamId) -> f32 { match id { APPLY => f32::from(u8::from(self.apply)), LOOK => self.look, _ => 0.0, } } fn is_active(&self) -> bool { !self.apply || self.look != DEFAULT_LOOK } fn composes(&self) -> bool { self.apply } fn wgsl_body(&self) -> String { "\ let hue_sat_dims = profile_table[0]; let look_dims = profile_table[1]; if (hue_sat_dims.x > 0.0 || look_dims.x > 0.0) { var p = PROFILE_FROM_WORKING * c; // A colour outside ProPhoto has no HSV the tables were made for; it // passes through rather than being floored, which would clip it (D19). if (min(p.r, min(p.g, p.b)) >= 0.0) { if (hue_sat_dims.x > 0.0) { p = profile_apply(hue_sat_dims, 3u, p, 1.0); } if (look_dims.x > 0.0 && look > 0.0) { p = profile_apply(look_dims, profile_look_base(), p, look); } c = PROFILE_TO_WORKING * p; } }" .into() } fn uniforms(&self) -> Vec { vec![Uniform { name: "look", value: self.look / 100.0, }] } fn helpers(&self) -> &[Helper] { HELPERS.as_slice() } } /// TRACES: FR-DEV-3e | FR-DEV-3j /// The storage buffer a source's profile is uploaded as: the three header /// `vec4`s, the HueSatMap's entries, the LookTable's, each entry /// `(hue shift, saturation scale, value scale, 0)`, then the tone curve's /// samples in `.x`. /// /// The curve is always there: the profile's own where it has one, Camera /// Raw's ACR3 default otherwise — including in the placeholder every source /// without a profile binds, whose tables are absent, so a raw with no /// profile still has the reference tone curve when it is chosen (D21). pub fn profile_buffer(tables: Option<&ProfileTables>) -> Vec<[f32; 4]> { let header = |t: Option<&HueSatTable>| match t { Some(t) => [ t.hue_divisions as f32, t.sat_divisions as f32, t.val_divisions as f32, if t.srgb_encoded { 1.0 } else { 0.0 }, ], None => [0.0; 4], }; let hue_sat = tables.and_then(|t| t.hue_sat.as_ref()); let look = tables.and_then(|t| t.look.as_ref()); let curve: &[f32] = tables .and_then(|t| t.tone_curve.as_deref()) .unwrap_or(&dr_types::tone::ACR3_DEFAULT); let mut out = vec![ header(hue_sat), header(look), [curve.len() as f32, 0.0, 0.0, 0.0], ]; for t in [hue_sat, look].into_iter().flatten() { out.extend(t.entries.iter().map(|e| [e[0], e[1], e[2], 0.0])); } out.extend(curve.iter().map(|&v| [v, 0.0, 0.0, 0.0])); out } /// TRACES: FR-DEV-3e /// The fragment's arithmetic on the CPU: a working-space colour through the /// source's tables, the look at `look` (1.0 = as the profile states it). /// /// The reference the shader is tested against, and the statement of the /// algorithm a reader can step through. pub fn apply_reference(tables: &ProfileTables, c: [f32; 3], look: f32) -> [f32; 3] { let (to, back) = working_prophoto(); let mut p = mul(to, c); if p.iter().any(|v| *v < 0.0) { return c; } if let Some(t) = &tables.hue_sat { p = apply_table(t, p, 1.0); } if let Some(t) = tables.look.as_ref().filter(|_| look > 0.0) { p = apply_table(t, p, look); } mul(back, p) } fn srgb_encode(v: f32) -> f32 { if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 } } fn srgb_decode(v: f32) -> f32 { if v <= 0.040_45 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } } /// The SDK's `DNG_RGBtoHSV`: hue in `[0, 6)`. pub fn rgb_to_hsv([r, g, b]: [f32; 3]) -> [f32; 3] { let v = r.max(g).max(b); let gap = v - r.min(g).min(b); if gap <= 0.0 { return [0.0, 0.0, v]; } let h = if r == v { let h = (g - b) / gap; if h < 0.0 { h + 6.0 } else { h } } else if g == v { 2.0 + (b - r) / gap } else { 4.0 + (r - g) / gap }; [h, gap / v, v] } pub fn hsv_to_rgb([h, s, v]: [f32; 3]) -> [f32; 3] { if s <= 0.0 { return [v; 3]; } let h = h - 6.0 * (h / 6.0).floor(); let i = h.floor().min(5.0); let f = h - i; let p = v * (1.0 - s); let q = v * (1.0 - s * f); let t = v * (1.0 - s * (1.0 - f)); match i as i32 { 0 => [v, t, p], 1 => [q, v, p], 2 => [p, v, t], 3 => [p, q, v], 4 => [t, p, v], _ => [v, p, q], } } fn lookup(t: &HueSatTable, [h, s, v]: [f32; 3]) -> [f32; 3] { let (hd, sd, vd) = (t.hue_divisions, t.sat_divisions, t.val_divisions); let (mut h0, mut h1, mut hf) = (0u32, 0u32, 0.0f32); if hd > 1 { let hs = h * hd as f32 / 6.0; h0 = (hs as u32).min(hd - 1); hf = hs - h0 as f32; h1 = if h0 + 1 >= hd { 0 } else { h0 + 1 }; } let ss = s * (sd - 1) as f32; let s0 = (ss as u32).min(sd - 2); let sf = ss - s0 as f32; let (mut v0, mut vf) = (0u32, 0.0f32); if vd > 1 { let mut ve = v.clamp(0.0, 1.0); if t.srgb_encoded { ve = srgb_encode(ve); } let vs = ve * (vd - 1) as f32; v0 = (vs as u32).min(vd - 2); vf = vs - v0 as f32; } let mix = |a: [f32; 3], b: [f32; 3], w: f32| -> [f32; 3] { std::array::from_fn(|i| a[i] + (b[i] - a[i]) * w) }; let at = |v: u32, h: u32, s: u32| t.entries[t.index(h, s, v)]; let plane = |v: u32| { mix( mix(at(v, h0, s0), at(v, h1, s0), hf), mix(at(v, h0, s0 + 1), at(v, h1, s0 + 1), hf), sf, ) }; let d = plane(v0); if vd > 1 { mix(d, plane(v0 + 1), vf) } else { d } } fn apply_table(t: &HueSatTable, c: [f32; 3], amount: f32) -> [f32; 3] { let hsv = rgb_to_hsv(c); let d = lookup(t, hsv); let d = [ d[0] * amount, (1.0 + (d[1] - 1.0) * amount).max(0.0), (1.0 + (d[2] - 1.0) * amount).max(0.0), ]; let h = hsv[0] + d[0] * (6.0 / 360.0); let s = (hsv[1] * d[1]).min(1.0); let v = if t.srgb_encoded { let vc = hsv[2].min(1.0); if vc > 0.0 { hsv[2] * srgb_decode(srgb_encode(vc) * d[2]) / vc } else { hsv[2] } } else { hsv[2] * d[2] }; hsv_to_rgb([h, s, v]) } #[cfg(test)] mod tests { use super::*; use dr_types::ProfileOrigin; fn uniform(h: u32, s: u32, v: u32, e: [f32; 3]) -> HueSatTable { HueSatTable::new(h, s, v, false, vec![e; (h * s * v) as usize]).unwrap() } fn tables(hue_sat: Option, look: Option) -> ProfileTables { ProfileTables { name: "test".into(), origin: ProfileOrigin::Embedded, hue_sat, look, tone_curve: None, } } fn close(a: [f32; 3], b: [f32; 3], tol: f32) -> bool { a.iter() .zip(b) .all(|(x, y)| (x - y).abs() <= tol * y.abs().max(1.0)) } #[test] fn it_starts_neutral_and_composed() { let op = CameraProfile::new(); assert!(!op.is_active(), "an untouched photograph writes nothing"); assert!(op.composes(), "and still renders through its profile"); let mut off = CameraProfile::new(); off.set_param(APPLY, 0.0); assert!(off.is_active() && !off.composes()); } #[test] fn the_working_space_round_trips_through_prophoto() { let (to, back) = working_prophoto(); for c in [[1.0, 1.0, 1.0], [0.2, 0.5, 0.1], [4.0, 0.3, 0.02]] { assert!(close(mul(back, mul(to, c)), c, 1e-5), "{c:?}"); } let white = mul(to, [1.0; 3]); assert!(white.iter().all(|v| (v - 1.0).abs() < 1e-6), "{white:?}"); } #[test] fn hsv_round_trips() { for c in [ [0.9, 0.2, 0.1], [0.1, 0.7, 0.3], [0.2, 0.3, 0.8], [0.5, 0.5, 0.5], [3.0, 1.0, 2.0], ] { assert!(close(hsv_to_rgb(rgb_to_hsv(c)), c, 1e-6), "{c:?}"); } } #[test] fn grey_passes_through() { let t = tables( Some(uniform(6, 3, 1, [30.0, 1.5, 1.0])), Some(uniform(6, 3, 1, [-20.0, 1.3, 1.0])), ); for v in [0.0, 0.18, 1.0, 8.0] { let out = apply_reference(&t, [v; 3], 1.0); assert!(close(out, [v; 3], 1e-5), "{v}: {out:?}"); } } #[test] fn an_identity_table_changes_nothing() { let t = tables( Some(uniform(90, 30, 1, [0.0, 1.0, 1.0])), Some(uniform(36, 8, 16, [0.0, 1.0, 1.0])), ); for c in [[0.9, 0.2, 0.1], [0.05, 0.4, 0.2], [2.0, 0.5, 0.3]] { assert!(close(apply_reference(&t, c, 1.0), c, 1e-5), "{c:?}"); } } #[test] fn a_saturation_scale_scales_saturation() { let t = tables(Some(uniform(6, 3, 1, [0.0, 1.2, 1.0])), None); let (to, _) = working_prophoto(); let c = [0.6, 0.3, 0.2]; let before = rgb_to_hsv(mul(to, c)); let after = rgb_to_hsv(mul(to, apply_reference(&t, c, 1.0))); assert!( (after[1] - before[1] * 1.2).abs() < 1e-4, "{before:?} {after:?}" ); assert!((after[0] - before[0]).abs() < 1e-4); assert!((after[2] - before[2]).abs() < 1e-4); } #[test] fn hue_interpolation_wraps_from_the_last_column_to_the_first() { // Four hue columns: a shift only in the first. A hue just short of // 6.0 (red, from the magenta side) sits between the last column and // the first, and must take most of the first's shift. let mut e = vec![[0.0, 1.0, 1.0]; 4 * 2]; e[0] = [40.0, 1.0, 1.0]; e[1] = [40.0, 1.0, 1.0]; let t = HueSatTable::new(4, 2, 1, false, e).unwrap(); let d = lookup(&t, [5.9, 0.5, 0.5]); assert!(d[0] > 30.0, "{d:?}"); // Columns sit at hue 0, 1.5, 3 and 4.5; between the third and the // fourth, neither of which shifts, nothing moves. let d = lookup(&t, [3.7, 0.5, 0.5]); assert!(d[0].abs() < 1e-6, "{d:?}"); } #[test] fn a_value_above_one_stays_above_one() { let t = tables(None, Some(uniform(6, 3, 4, [5.0, 1.1, 0.9]))); let out = apply_reference(&t, [6.0, 3.0, 2.0], 1.0); assert!(out.iter().any(|v| *v > 1.0), "{out:?}"); let mut srgb = uniform(6, 3, 4, [0.0, 1.0, 0.9]); srgb.srgb_encoded = true; let out = apply_reference(&tables(None, Some(srgb)), [6.0, 3.0, 2.0], 1.0); assert!(out.iter().all(|v| v.is_finite()) && out[0] > 1.0, "{out:?}"); } #[test] fn the_look_strength_scales_the_look_alone() { let hs = uniform(6, 3, 1, [0.0, 1.1, 1.0]); let look = uniform(6, 3, 1, [0.0, 1.2, 1.0]); let t = tables(Some(hs.clone()), Some(look)); let c = [0.5, 0.3, 0.2]; let none = apply_reference(&t, c, 0.0); assert!(close( none, apply_reference(&tables(Some(hs), None), c, 1.0), 1e-6 )); let (to, _) = working_prophoto(); let s = |x| rgb_to_hsv(mul(to, x))[1]; assert!(s(apply_reference(&t, c, 2.0)) > s(apply_reference(&t, c, 1.0))); } #[test] fn the_buffer_puts_the_header_first_and_the_look_after_the_hue_sat_map() { let bare = profile_buffer(None); assert_eq!(bare[..2], [[0.0; 4]; 2], "no tables"); assert_eq!(bare[2][0], 1025.0, "and the reference default curve"); assert_eq!(bare.len(), HEADER_ENTRIES + 1025); let t = tables( Some(uniform(2, 2, 1, [1.0, 2.0, 3.0])), Some(uniform(3, 2, 2, [4.0, 5.0, 6.0])), ); let b = profile_buffer(Some(&t)); assert_eq!(b[0], [2.0, 2.0, 1.0, 0.0]); assert_eq!(b[1], [3.0, 2.0, 2.0, 0.0]); assert_eq!(b.len(), HEADER_ENTRIES + 4 + 12 + 1025); assert_eq!(b[HEADER_ENTRIES], [1.0, 2.0, 3.0, 0.0]); assert_eq!(b[HEADER_ENTRIES + 4], [4.0, 5.0, 6.0, 0.0]); assert_eq!(b[HEADER_ENTRIES + 16][0], dr_types::tone::ACR3_DEFAULT[0]); } }