Skip to main content

sim_lib_midi_core/
frame_digest.rs

1//! A deterministic offline MIDI artifact for the cookbook (COOKBOOK_8 Category C).
2//!
3//! `midi/chord-digest` encodes a fixed C-major triad to its canonical MIDI wire
4//! bytes and returns the [`sim_cookbook::frame_digest`] of those bytes. It is a
5//! Category C recipe made deterministic by the digest convention: the render is
6//! offline (no device, no clock, no entropy), the bytes are fixed by the MIDI
7//! spec, and the digest is integer-only -- so two runs reproduce byte-for-byte,
8//! which the cookbook twice-run guard asserts.
9
10use std::sync::Arc;
11
12use sim_cookbook::frame_digest;
13use sim_kernel::{
14    AbiVersion, Args, Callable, ClassRef, Cx, Error, Export, Expr, Lib, LibManifest, LibTarget,
15    Linker, LoadCx, Object, ObjectCompat, Result, Symbol, Value, Version,
16};
17
18use crate::wire::encode_channel;
19use crate::{Channel, ChannelMessage, U7};
20
21/// The `midi/digest` lib id.
22pub fn manifest_name() -> Symbol {
23    Symbol::qualified("midi", "digest")
24}
25
26/// The `midi/chord-digest` op symbol.
27fn chord_digest_symbol() -> Symbol {
28    Symbol::qualified("midi", "chord-digest")
29}
30
31/// Encode a major triad rooted at `root` (note-ons on channel 0 at velocity 100)
32/// to canonical MIDI wire bytes and return their frame digest. Pure and
33/// deterministic. A root that would push a note past the 7-bit MIDI range wraps
34/// via `U7`'s low 7 bits, keeping the encode total and thus the digest defined.
35fn chord_digest(root: u8) -> String {
36    let mut bytes = Vec::new();
37    for key in [root, root.wrapping_add(4), root.wrapping_add(7)] {
38        let (status, data) = encode_channel(&ChannelMessage::NoteOn {
39            ch: Channel(0),
40            key: U7(key & 0x7f),
41            vel: U7(100),
42        });
43        bytes.push(status);
44        bytes.extend(data);
45    }
46    frame_digest(&bytes)
47}
48
49/// Parse a `midi/chord-digest` root-note argument (a decimal string).
50fn root_arg(cx: &mut Cx, value: &Value) -> Result<u8> {
51    let text = match value.object().as_expr(cx)? {
52        Expr::String(text) => text,
53        _ => {
54            return Err(Error::Eval(
55                "midi/chord-digest expects a string root".to_owned(),
56            ));
57        }
58    };
59    text.trim().parse::<u8>().map_err(|_| {
60        Error::Eval(format!(
61            "midi/chord-digest root must be 0-255, got {text:?}"
62        ))
63    })
64}
65
66/// Callable runtime object exposing `midi/chord-digest`.
67struct ChordDigestOp;
68
69impl Object for ChordDigestOp {
70    fn display(&self, _cx: &mut Cx) -> Result<String> {
71        Ok("#<function midi/chord-digest>".to_owned())
72    }
73
74    fn as_any(&self) -> &dyn std::any::Any {
75        self
76    }
77}
78
79impl ObjectCompat for ChordDigestOp {
80    fn class(&self, cx: &mut Cx) -> Result<ClassRef> {
81        cx.resolve_class(&Symbol::qualified("core", "Function"))
82    }
83
84    fn as_callable(&self) -> Option<&dyn Callable> {
85        Some(self)
86    }
87}
88
89impl Callable for ChordDigestOp {
90    fn call(&self, cx: &mut Cx, args: Args) -> Result<Value> {
91        let args = args.into_vec();
92        let [root] = args.as_slice() else {
93            return Err(Error::Eval(format!(
94                "midi/chord-digest expects one root-note argument, got {}",
95                args.len()
96            )));
97        };
98        let root = root_arg(cx, root)?;
99        cx.factory().string(chord_digest(root))
100    }
101}
102
103/// The MIDI digest lib: registers `midi/chord-digest` as a callable.
104pub struct MidiDigestLib;
105
106impl Lib for MidiDigestLib {
107    fn manifest(&self) -> LibManifest {
108        LibManifest {
109            id: manifest_name(),
110            version: Version(env!("CARGO_PKG_VERSION").to_owned()),
111            abi: AbiVersion { major: 0, minor: 1 },
112            target: LibTarget::HostRegistered,
113            requires: Vec::new(),
114            capabilities: Vec::new(),
115            exports: vec![Export::Function {
116                symbol: chord_digest_symbol(),
117                function_id: None,
118            }],
119        }
120    }
121
122    fn load(&self, cx: &mut LoadCx, linker: &mut Linker<'_>) -> Result<()> {
123        linker.function_value(
124            chord_digest_symbol(),
125            cx.factory().opaque(Arc::new(ChordDigestOp))?,
126        )?;
127        Ok(())
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn chord_digest_is_a_deterministic_frame() {
137        // Three note-ons -> 9 wire bytes (status + 2 data each).
138        let digest = chord_digest(60);
139        assert!(digest.starts_with("(frame (bytes 9) (hash "));
140        assert_eq!(digest, chord_digest(60));
141    }
142}