Skip to main content

tokmd_fun/
lib.rs

1//! # tokmd-fun
2//!
3//! **Tier 3 (Novelty)**
4//!
5//! Fun renderers for tokmd analysis outputs. Provides creative visualizations
6//! like 3D code cities and audio representations.
7//!
8//! ## What belongs here
9//! * 3D code city visualization (OBJ format)
10//! * Audio representation (MIDI format)
11//! * Eco-label generation
12//! * Other novelty outputs
13//!
14//! ## What does NOT belong here
15//! * Serious analysis features
16//! * Analysis computation
17//! * Core receipt formatting
18
19use anyhow::Result;
20use midly::{Format, Header, MetaMessage, MidiMessage, Smf, Timing, TrackEvent, TrackEventKind};
21
22#[derive(Debug, Clone)]
23pub struct ObjBuilding {
24    pub name: String,
25    pub x: f32,
26    pub y: f32,
27    pub w: f32,
28    pub d: f32,
29    pub h: f32,
30}
31
32pub fn render_obj(buildings: &[ObjBuilding]) -> String {
33    let mut out = String::new();
34    out.push_str("# tokmd code city\n");
35    let mut vertex_index = 1usize;
36
37    for b in buildings {
38        out.push_str(&format!("o {}\n", sanitize_name(&b.name)));
39        let (x, y, z) = (b.x, b.y, 0.0f32);
40        let (w, d, h) = (b.w, b.d, b.h);
41
42        let v = [
43            (x, y, z),
44            (x + w, y, z),
45            (x + w, y + d, z),
46            (x, y + d, z),
47            (x, y, z + h),
48            (x + w, y, z + h),
49            (x + w, y + d, z + h),
50            (x, y + d, z + h),
51        ];
52        for (vx, vy, vz) in v {
53            out.push_str(&format!("v {} {} {}\n", vx, vy, vz));
54        }
55
56        let faces = [
57            [1, 2, 3, 4],
58            [5, 6, 7, 8],
59            [1, 2, 6, 5],
60            [2, 3, 7, 6],
61            [3, 4, 8, 7],
62            [4, 1, 5, 8],
63        ];
64        for face in faces {
65            out.push_str(&format!(
66                "f {} {} {} {}\n",
67                vertex_index + face[0] - 1,
68                vertex_index + face[1] - 1,
69                vertex_index + face[2] - 1,
70                vertex_index + face[3] - 1,
71            ));
72        }
73
74        vertex_index += 8;
75    }
76
77    out
78}
79
80fn sanitize_name(name: &str) -> String {
81    name.chars()
82        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
83        .collect()
84}
85
86#[derive(Debug, Clone)]
87pub struct MidiNote {
88    pub key: u8,
89    pub velocity: u8,
90    pub start: u32,
91    pub duration: u32,
92    pub channel: u8,
93}
94
95pub fn render_midi(notes: &[MidiNote], tempo_bpm: u16) -> Result<Vec<u8>> {
96    let ticks_per_quarter = 480u16;
97    let mut events: Vec<(u32, TrackEventKind<'static>)> = Vec::new();
98
99    let tempo = 60_000_000u32 / tempo_bpm.max(1) as u32;
100    events.push((0, TrackEventKind::Meta(MetaMessage::Tempo(tempo.into()))));
101
102    for note in notes {
103        let ch = note.channel.min(15).into();
104        events.push((
105            note.start,
106            TrackEventKind::Midi {
107                channel: ch,
108                message: MidiMessage::NoteOn {
109                    key: note.key.into(),
110                    vel: note.velocity.into(),
111                },
112            },
113        ));
114        events.push((
115            note.start + note.duration,
116            TrackEventKind::Midi {
117                channel: ch,
118                message: MidiMessage::NoteOff {
119                    key: note.key.into(),
120                    vel: 0.into(),
121                },
122            },
123        ));
124    }
125
126    events.sort_by(|a, b| {
127        a.0.cmp(&b.0).then_with(|| {
128            let rank = |k: &TrackEventKind| -> (u8, u8, u8) {
129                match k {
130                    TrackEventKind::Meta(_) => (0, 0, 0),
131                    TrackEventKind::Midi {
132                        channel,
133                        message: MidiMessage::NoteOff { key, .. },
134                    } => (1, (*channel).into(), (*key).into()),
135                    TrackEventKind::Midi {
136                        channel,
137                        message: MidiMessage::NoteOn { key, .. },
138                    } => (2, (*channel).into(), (*key).into()),
139                    _ => (3, 0, 0),
140                }
141            };
142            rank(&a.1).cmp(&rank(&b.1))
143        })
144    });
145
146    let mut track: Vec<TrackEvent> = Vec::new();
147    let mut last_time = 0u32;
148    for (time, kind) in events {
149        let delta = time.saturating_sub(last_time);
150        last_time = time;
151        track.push(TrackEvent {
152            delta: delta.into(),
153            kind,
154        });
155    }
156
157    track.push(TrackEvent {
158        delta: 0.into(),
159        kind: TrackEventKind::Meta(MetaMessage::EndOfTrack),
160    });
161
162    let smf = Smf {
163        header: Header::new(
164            Format::SingleTrack,
165            Timing::Metrical(ticks_per_quarter.into()),
166        ),
167        tracks: vec![track],
168    };
169
170    let mut out = Vec::new();
171    smf.write_std(&mut out)?;
172    Ok(out)
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    // ── sanitize_name ─────────────────────────────────────────────────
180    #[test]
181    fn sanitize_name_replaces_non_alphanumeric() {
182        assert_eq!(sanitize_name("hello world"), "hello_world");
183        assert_eq!(sanitize_name("src/main.rs"), "src_main_rs");
184        assert_eq!(sanitize_name("foo-bar_baz"), "foo_bar_baz");
185    }
186
187    #[test]
188    fn sanitize_name_preserves_alphanumeric() {
189        assert_eq!(sanitize_name("abc123"), "abc123");
190    }
191
192    #[test]
193    fn sanitize_name_empty() {
194        assert_eq!(sanitize_name(""), "");
195    }
196
197    // ── render_obj ────────────────────────────────────────────────────
198    #[test]
199    fn render_obj_empty_input() {
200        let result = render_obj(&[]);
201        assert_eq!(result, "# tokmd code city\n");
202    }
203
204    #[test]
205    fn render_obj_single_building() {
206        let buildings = vec![ObjBuilding {
207            name: "main".into(),
208            x: 0.0,
209            y: 0.0,
210            w: 1.0,
211            d: 1.0,
212            h: 2.0,
213        }];
214        let result = render_obj(&buildings);
215        assert!(result.starts_with("# tokmd code city\n"));
216        assert!(result.contains("o main\n"));
217        // 8 vertices per building
218        assert_eq!(result.matches("\nv ").count(), 8);
219        // 6 faces per building
220        assert_eq!(result.matches("\nf ").count(), 6);
221    }
222
223    #[test]
224    fn render_obj_multiple_buildings() {
225        let buildings = vec![
226            ObjBuilding {
227                name: "a".into(),
228                x: 0.0,
229                y: 0.0,
230                w: 1.0,
231                d: 1.0,
232                h: 1.0,
233            },
234            ObjBuilding {
235                name: "b".into(),
236                x: 2.0,
237                y: 0.0,
238                w: 1.0,
239                d: 1.0,
240                h: 3.0,
241            },
242        ];
243        let result = render_obj(&buildings);
244        assert!(result.contains("o a\n"));
245        assert!(result.contains("o b\n"));
246        // 16 vertices total (2 × 8)
247        assert_eq!(result.matches("\nv ").count(), 16);
248        // 12 faces total (2 × 6)
249        assert_eq!(result.matches("\nf ").count(), 12);
250    }
251
252    #[test]
253    fn render_obj_sanitizes_names() {
254        let buildings = vec![ObjBuilding {
255            name: "src/main.rs".into(),
256            x: 0.0,
257            y: 0.0,
258            w: 1.0,
259            d: 1.0,
260            h: 1.0,
261        }];
262        let result = render_obj(&buildings);
263        assert!(result.contains("o src_main_rs\n"));
264        assert!(!result.contains("o src/main.rs\n"));
265    }
266
267    // ── render_midi ───────────────────────────────────────────────────
268    #[test]
269    fn render_midi_deterministic_overlap() {
270        // Creates two notes on different channels that start and end at the exact same tick
271        let notes1 = vec![
272            MidiNote {
273                key: 60,
274                velocity: 100,
275                start: 0,
276                duration: 480,
277                channel: 0,
278            },
279            MidiNote {
280                key: 64,
281                velocity: 100,
282                start: 0,
283                duration: 480,
284                channel: 1,
285            },
286        ];
287        let notes2 = vec![
288            MidiNote {
289                key: 64,
290                velocity: 100,
291                start: 0,
292                duration: 480,
293                channel: 1,
294            },
295            MidiNote {
296                key: 60,
297                velocity: 100,
298                start: 0,
299                duration: 480,
300                channel: 0,
301            },
302        ];
303
304        let result1 = render_midi(&notes1, 120).unwrap();
305        let result2 = render_midi(&notes2, 120).unwrap();
306
307        assert_eq!(
308            result1, result2,
309            "Output must be deterministic regardless of input note order"
310        );
311    }
312
313    #[test]
314    fn render_midi_empty_notes() {
315        let result = render_midi(&[], 120).unwrap();
316        // Should produce valid MIDI even with no notes (header + tempo + end-of-track)
317        assert!(!result.is_empty());
318        // MIDI files start with "MThd"
319        assert_eq!(&result[..4], b"MThd");
320    }
321
322    #[test]
323    fn render_midi_single_note() {
324        let notes = vec![MidiNote {
325            key: 60,
326            velocity: 100,
327            start: 0,
328            duration: 480,
329            channel: 0,
330        }];
331        let result = render_midi(&notes, 120).unwrap();
332        assert_eq!(&result[..4], b"MThd");
333        // Should contain track data
334        assert!(result.len() > 14); // Header is 14 bytes minimum
335    }
336
337    #[test]
338    fn render_midi_multiple_notes() {
339        let notes = vec![
340            MidiNote {
341                key: 60,
342                velocity: 100,
343                start: 0,
344                duration: 480,
345                channel: 0,
346            },
347            MidiNote {
348                key: 64,
349                velocity: 80,
350                start: 480,
351                duration: 480,
352                channel: 0,
353            },
354            MidiNote {
355                key: 67,
356                velocity: 60,
357                start: 960,
358                duration: 480,
359                channel: 1,
360            },
361        ];
362        let result = render_midi(&notes, 120).unwrap();
363        assert_eq!(&result[..4], b"MThd");
364    }
365
366    #[test]
367    fn render_midi_channel_clamped_to_15() {
368        let notes = vec![MidiNote {
369            key: 60,
370            velocity: 100,
371            start: 0,
372            duration: 480,
373            channel: 255, // Should be clamped to 15
374        }];
375        // Should not panic or error
376        let result = render_midi(&notes, 120).unwrap();
377        assert!(!result.is_empty());
378    }
379
380    #[test]
381    fn render_midi_tempo_min_clamped() {
382        let notes = vec![MidiNote {
383            key: 60,
384            velocity: 100,
385            start: 0,
386            duration: 480,
387            channel: 0,
388        }];
389        // tempo_bpm = 0 should be clamped via max(1)
390        let result = render_midi(&notes, 0).unwrap();
391        assert!(!result.is_empty());
392    }
393}