tono_core/program.rs
1//! program — the immutable result of compiling a [`Song`](crate::song::Song)
2//! (ADR 0003).
3//!
4//! A [`Program`] is the artifact applications render, ship, and (from
5//! 1.10.0-alpha.3) run: the resolved [`SoundDoc`], the musical metadata a
6//! transport needs, bounded resource estimates, streaming-coverage warnings,
7//! and a canonical content hash — all under three independently evolving
8//! version pins (`SCHEMA_VERSION`, `ENGINE_VERSION`, [`PROGRAM_VERSION`]).
9//! A Program is immutable by convention: it is *validated and resolved*, so
10//! mutating a public field invalidates [`Program::hash`] (a round-trip
11//! through [`Program::from_json`] re-verifies and catches it).
12//!
13//! This API is **stable** — frozen at 1.10.0-rc.1 (docs/api-tiers.md).
14
15use serde::{Deserialize, Serialize};
16
17use crate::diag::Diagnostic;
18use crate::dsl::{SeqWave, SoundDoc};
19use crate::ids::TrackId;
20use crate::render;
21use crate::streaming::StreamGraph;
22
23/// The current Program bundle format revision. A Program records the revision
24/// it was compiled with; a loader rejects a bundle newer than itself. Bumped
25/// when the serialized shape (or its semantics) changes — independently of
26/// the document schema and DSP engine revisions.
27pub const PROGRAM_VERSION: u32 = 2;
28
29/// A compiled song: validated, resolved, hashed. Built by
30/// [`Song::compile`](crate::song::Song::compile); reloaded by
31/// [`Program::from_json`].
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct Program {
34 /// The bundle revision (see [`PROGRAM_VERSION`]).
35 pub program_version: u32,
36 /// The resolved document's effective schema version.
37 pub schema_version: u32,
38 /// The resolved document's effective engine revision.
39 pub engine_version: u32,
40 /// Canonical semantic hash. Version 1 covers the document; version 2
41 /// covers every serialized semantic field except this hash.
42 pub hash: u64,
43 /// The target this program was compiled for (offline or runtime).
44 #[serde(default)]
45 pub target: crate::song::CompileTarget,
46 /// The resolved document — renders through the exact same engine as
47 /// everything else; nothing new in the render path.
48 pub doc: SoundDoc,
49 /// The musical metadata a transport (or a host deciding how far ahead to
50 /// schedule) needs — preserved at compile time, not reconstructed.
51 pub meta: ProgramMeta,
52 /// Bounded resource estimates the runtime preallocates from (ADR 0005).
53 pub estimates: ResourceEstimates,
54 /// Offline compile warnings: streaming blockers re-derived from the
55 /// resolved document on load (a pure function of it), never stored.
56 /// Runtime-target compilation rejects these blockers instead.
57 #[serde(skip)]
58 pub warnings: Vec<Diagnostic>,
59}
60
61/// The musical facts of a compiled song, resolved once at compile time.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct ProgramMeta {
64 /// The song's name.
65 pub name: String,
66 /// Tempo in beats per minute (clamped at compile, like the compiler's
67 /// duration math: degenerate tempos floor at 1).
68 pub tempo_bpm: f32,
69 /// Time-signature numerator (4 = 4/4) — the default meter before any
70 /// `meter_map` points.
71 pub beats_per_bar: u32,
72 /// Grid resolution (4 = sixteenth notes).
73 pub steps_per_beat: u32,
74 /// Tempo changes at exact beat positions (empty = constant `tempo_bpm`).
75 #[serde(default)]
76 pub tempo_map: Vec<crate::dsl::TempoPoint>,
77 /// Time-signature changes by bar (empty = `beats_per_bar`/4 throughout).
78 #[serde(default)]
79 pub meter_map: Vec<crate::song::MeterPoint>,
80 /// The pickup bar's length in beats, if any.
81 #[serde(default)]
82 pub pickup: Option<crate::units::Beat>,
83 /// Named bar ranges, sorted by bar — the runtime's transition targets.
84 #[serde(default)]
85 pub sections: Vec<crate::song::Section>,
86 /// Named beat points, sorted by position.
87 #[serde(default)]
88 pub markers: Vec<crate::song::Marker>,
89 /// The song's length in bars (end of its last placement or direct note).
90 pub length_bars: u32,
91 /// Total duration in seconds, including the release/reverb tail.
92 pub duration_secs: f32,
93 /// Total duration in frames (`duration_secs × sample_rate`, rounded).
94 pub duration_frames: u64,
95 /// The sample rate the program was compiled for.
96 pub sample_rate: u32,
97 /// One entry per track, in declaration order (so `id` is stable).
98 pub tracks: Vec<TrackMeta>,
99}
100
101/// One track's identity and role in a compiled program.
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct TrackMeta {
104 /// The stable identifier: declaration order at compile time, so an
105 /// unchanged song recompiles to identical ids.
106 pub id: TrackId,
107 /// The track name (also the rendered layer id).
108 pub name: String,
109 /// The instrument voice.
110 pub wave: SeqWave,
111 /// How many notes the track plays (direct notes plus placements).
112 pub notes: u32,
113 /// Whether the track is muted in the mix.
114 pub mute: bool,
115 /// Whether the track is a solo track (every non-solo track is muted).
116 pub solo: bool,
117}
118
119/// Bounded estimates of what a Program costs to render or run. Upper bounds
120/// are stated where an exact figure isn't cheap; the runtime preallocates
121/// from these (ADR 0005).
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct ResourceEstimates {
124 /// Total render length in frames (mono frame count).
125 pub frames: u64,
126 /// Total note events across all tracks.
127 pub events: u64,
128 /// An upper bound on simultaneously sounding notes across the mix: the
129 /// per-track maxima summed (tracks start together, so their peaks can
130 /// coincide). Voice pools sized to this never steal.
131 pub peak_voices: u32,
132 /// The dominant render allocation: the stereo f32 output buffers
133 /// (`frames × 2 channels × 4 bytes`). Everything else (per-track bounces)
134 /// is bounded by the same order.
135 pub memory_bytes: u64,
136}
137
138/// Why a serialized [`Program`] failed to load.
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub enum ProgramError {
141 /// The JSON didn't parse or didn't match the bundle shape.
142 Json(String),
143 /// The bundle's `program_version` is newer than this binary supports.
144 TooNew {
145 /// The bundle's revision.
146 found: u32,
147 /// This binary's [`PROGRAM_VERSION`].
148 supported: u32,
149 },
150 /// The stored hash doesn't match the recomputed one — the bundle was
151 /// hand-edited or corrupted (T3002).
152 HashMismatch {
153 /// The hash stored in the bundle.
154 stored: u64,
155 /// The hash recomputed from the versioned bundle content.
156 computed: u64,
157 },
158}
159
160impl std::fmt::Display for ProgramError {
161 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162 match self {
163 ProgramError::Json(e) => write!(f, "program JSON: {e}"),
164 ProgramError::TooNew { found, supported } => write!(
165 f,
166 "T3001: program version {found} is newer than this binary supports ({supported})"
167 ),
168 ProgramError::HashMismatch { stored, computed } => write!(
169 f,
170 "T3002: program hash mismatch (stored {stored:#018x}, computed {computed:#018x}) — \
171 the bundle was edited or corrupted; recompile the song"
172 ),
173 }
174 }
175}
176
177impl std::error::Error for ProgramError {}
178
179/// FNV-1a over bytes — the same primitive the golden corpus uses over sample
180/// bits, here over canonical JSON.
181fn fnv1a(bytes: &[u8]) -> u64 {
182 let mut h: u64 = 0xCBF2_9CE4_8422_2325;
183 for b in bytes {
184 h ^= *b as u64;
185 h = h.wrapping_mul(0x0000_0100_0000_01B3);
186 }
187 h
188}
189
190/// The canonical form of a resolved document (ADR 0003): UTF-8 JSON, object
191/// keys sorted (the serde_json default map), no insignificant whitespace,
192/// floats in shortest-round-trip form. Two equivalent songs — authored in
193/// Rust or Python — serialize to the same bytes.
194fn canonical_json<T: Serialize>(value: &T) -> Vec<u8> {
195 // serde_json's Map is a BTreeMap without the preserve_order feature, so
196 // to_value→to_string is already the canonicalization (sorted keys,
197 // compact separators, ryu float formatting).
198 let value = serde_json::to_value(value).expect("canonical content serializes");
199 serde_json::to_string(&value)
200 .expect("a resolved document serializes")
201 .into_bytes()
202}
203
204/// The canonical content hash of a resolved document: FNV-1a over its
205/// canonical JSON. Independent of the authoring structure and of serialization
206/// formatting — equivalent songs hash equal, from Rust or Python alike.
207pub fn content_hash(doc: &SoundDoc) -> u64 {
208 fnv1a(&canonical_json(doc))
209}
210
211impl Program {
212 /// Recompute this bundle's integrity hash. Version 1 covered only the
213 /// resolved document; version 2 covers every serialized semantic field
214 /// except `hash` itself. Keeping the v1 rule here preserves the shipped
215 /// compatibility fixture while new bundles protect their runtime metadata.
216 pub(crate) fn computed_hash(&self) -> u64 {
217 if self.program_version <= 1 {
218 return content_hash(&self.doc);
219 }
220 let mut value = serde_json::to_value(self).expect("a program serializes");
221 value
222 .as_object_mut()
223 .expect("a program serializes as an object")
224 .remove("hash");
225 fnv1a(&canonical_json(&value))
226 }
227
228 /// Render the full program to mono samples through the standard engine.
229 pub fn render_mono(&self) -> Vec<f32> {
230 render::render(&self.doc)
231 }
232
233 /// Render the full program to a stereo pair. A compiled song always has a
234 /// stereo mix; a defensively duplicated mono pair is returned if it
235 /// somehow doesn't.
236 pub fn render_stereo(&self) -> (Vec<f32>, Vec<f32>) {
237 let product = render::render_product(&self.doc);
238 product.stereo.unwrap_or_else(|| {
239 let m = product.mono;
240 (m.clone(), m)
241 })
242 }
243
244 /// Render a frame range `[start, end)` as a stereo pair — a slice of the
245 /// full render, so a note or tail crossing the range boundary sounds
246 /// exactly as it does in the full mix (this is why ranges render through,
247 /// not from, the boundary). Out-of-range requests clamp.
248 pub fn render_range_frames(&self, start: u64, end: u64) -> (Vec<f32>, Vec<f32>) {
249 let (l, r) = self.render_stereo();
250 let start = (start as usize).min(l.len());
251 let end = (end as usize).min(l.len()).max(start);
252 (l[start..end].to_vec(), r[start..end].to_vec())
253 }
254
255 /// Render a bar range `[start_bar, end_bar)` through the program's meter
256 /// map (see [`Self::render_range_frames`]).
257 pub fn render_range_bars(&self, start_bar: u32, end_bar: u32) -> (Vec<f32>, Vec<f32>) {
258 let transport = crate::runtime::Transport::for_program(&self.meta);
259 self.render_range_frames(
260 transport.frame_at_bar(start_bar),
261 transport.frame_at_bar(end_bar),
262 )
263 }
264
265 /// Render per-track and per-bus stereo stems (pre-master-chain — see
266 /// [`render::Stem`]): every track stem plus every bus stem, in
267 /// declaration order. Muted tracks are silent stems.
268 pub fn render_stems(&self) -> Vec<render::Stem> {
269 render::render_stems(&self.doc).unwrap_or_default()
270 }
271
272 /// Whether the resolved document streams natively (no warnings is the
273 /// same signal — blockers are the only warnings compilation produces).
274 pub fn is_streamable(&self) -> bool {
275 self.warnings.is_empty()
276 }
277
278 /// The machine-readable capability list: what this program can do on a
279 /// host — `"offline-render"` and `"stems"` always; `"streaming"` when the
280 /// resolved document streams natively. Derived from the warnings (a pure
281 /// function of the document), so it's identical from any loader.
282 pub fn capabilities(&self) -> Vec<&'static str> {
283 let mut caps = vec!["offline-render", "stems"];
284 if self.is_streamable() {
285 caps.push("streaming");
286 }
287 caps
288 }
289
290 /// Serialize the bundle (compact JSON; stable field order from the struct).
291 pub fn to_json(&self) -> String {
292 serde_json::to_string(self).expect("a program serializes")
293 }
294
295 /// Load a bundle: parse, reject a newer revision (T3001), re-verify the
296 /// semantic hash (T3002), and re-derive the warnings from the resolved
297 /// document. No musical recomputation — loading never recompiles.
298 pub fn from_json(json: &str) -> Result<Program, ProgramError> {
299 let mut program: Program =
300 serde_json::from_str(json).map_err(|e| ProgramError::Json(e.to_string()))?;
301 if program.program_version > PROGRAM_VERSION {
302 return Err(ProgramError::TooNew {
303 found: program.program_version,
304 supported: PROGRAM_VERSION,
305 });
306 }
307 let computed = program.computed_hash();
308 if computed != program.hash {
309 return Err(ProgramError::HashMismatch {
310 stored: program.hash,
311 computed,
312 });
313 }
314 program.warnings = blocker_warnings(&program.doc);
315 Ok(program)
316 }
317}
318
319/// The streaming blockers of a resolved document, as warnings with per-kind
320/// codes in the T15xx band.
321pub(crate) fn blocker_warnings(doc: &SoundDoc) -> Vec<Diagnostic> {
322 /// The per-kind code; a mixer part reports its cause's code (the message
323 /// already carries the track/bus context).
324 fn code(b: &crate::streaming::StreamBlocker) -> &'static str {
325 use crate::streaming::StreamBlocker as B;
326 match b {
327 B::Normalize => "T1501",
328 B::LoopPlayback => "T1502",
329 B::StereoTreatment => "T1503",
330 B::TracksRoot => "T1504",
331 B::LegacyRng { .. } => "T1505",
332 B::Sampler => "T1506",
333 B::ModulatedFilter => "T1507",
334 B::OfflineEffect { .. } => "T1508",
335 B::TracksPart { cause, .. } => code(cause),
336 }
337 }
338 StreamGraph::blockers(doc)
339 .into_iter()
340 .map(|b| {
341 Diagnostic::warning(code(&b), "doc", b.to_string()).with_remediation(
342 "the offline render is unaffected; live playback uses the buffer-backed Player",
343 )
344 })
345 .collect()
346}
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351 use crate::song::{CompileOptions, Song, note};
352
353 fn two_track_program() -> Program {
354 let mut song = Song::new("prog", 120.0);
355 song.add_track(
356 "bass",
357 crate::dsl::SeqWave::Bass,
358 crate::dsl::Adsr {
359 a: 0.005,
360 d: 0.1,
361 s: 0.8,
362 r: 0.2,
363 punch: 0.0,
364 },
365 );
366 song.add_pattern("riff", 1, vec![note(0, 4, "C2"), note(8, 4, "G2")]);
367 song.arrange("bass", "riff", 0);
368 song.compile(&CompileOptions::default()).expect("compiles")
369 }
370
371 #[test]
372 fn hash_is_canonical_regardless_of_field_order() {
373 let program = two_track_program();
374 let json = serde_json::to_string(&program.doc).unwrap();
375 // Shuffle the top-level key order: parse to a Value, re-emit in a
376 // different order, reparse — the hash must not move.
377 let mut value: serde_json::Value = serde_json::from_str(&json).unwrap();
378 let obj = value.as_object_mut().unwrap();
379 let mut entries: Vec<_> = obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
380 entries.reverse();
381 *obj = entries.into_iter().collect();
382 let reparsed: SoundDoc = serde_json::from_value(value).unwrap();
383 assert_eq!(content_hash(&reparsed), content_hash(&program.doc));
384 assert_eq!(program.computed_hash(), program.hash);
385 }
386
387 #[test]
388 fn program_round_trips_through_json() {
389 let program = two_track_program();
390 let loaded = Program::from_json(&program.to_json()).expect("loads");
391 assert_eq!(loaded.hash, program.hash);
392 assert_eq!(loaded.warnings.len(), program.warnings.len());
393 assert_eq!(loaded.target, program.target);
394 assert_eq!(loaded.render_mono(), program.render_mono());
395 // The capability list is machine-readable and derived on load.
396 assert!(loaded.capabilities().contains(&"streaming"));
397 }
398
399 #[test]
400 fn render_range_is_a_slice_of_the_full_render() {
401 let program = two_track_program();
402 let (l, r) = program.render_stereo();
403 let (sl, sr) = program.render_range_frames(1000, 5000);
404 assert_eq!(sl, l[1000..5000].to_vec());
405 assert_eq!(sr, r[1000..5000].to_vec());
406 // A bar range converts through the meter map.
407 let (bl, _) = program.render_range_bars(0, 1);
408 let transport = crate::runtime::Transport::for_program(&program.meta);
409 assert_eq!(bl.len(), transport.frame_at_bar(1) as usize);
410 // Out-of-range clamps instead of panicking.
411 let (cl, _) = program.render_range_frames(u64::MAX - 1, u64::MAX);
412 assert!(cl.is_empty());
413 }
414
415 #[test]
416 fn from_json_rejects_a_newer_revision() {
417 let program = two_track_program();
418 let mut value: serde_json::Value = serde_json::from_str(&program.to_json()).unwrap();
419 value["program_version"] = serde_json::json!(PROGRAM_VERSION + 1);
420 let err = Program::from_json(&serde_json::to_string(&value).unwrap()).unwrap_err();
421 assert_eq!(
422 err,
423 ProgramError::TooNew {
424 found: PROGRAM_VERSION + 1,
425 supported: PROGRAM_VERSION,
426 }
427 );
428 }
429
430 #[test]
431 fn from_json_catches_a_hand_edited_bundle() {
432 let program = two_track_program();
433 let mut value: serde_json::Value = serde_json::from_str(&program.to_json()).unwrap();
434 value["doc"]["duration"] = serde_json::json!(9.0);
435 let err = Program::from_json(&serde_json::to_string(&value).unwrap()).unwrap_err();
436 assert!(matches!(err, ProgramError::HashMismatch { .. }));
437
438 let mut value: serde_json::Value = serde_json::from_str(&program.to_json()).unwrap();
439 value["meta"]["sample_rate"] = serde_json::json!(48_000);
440 let err = Program::from_json(&serde_json::to_string(&value).unwrap()).unwrap_err();
441 assert!(
442 matches!(err, ProgramError::HashMismatch { .. }),
443 "runtime metadata is part of a v2 bundle's integrity boundary"
444 );
445 }
446
447 #[test]
448 fn estimates_bound_the_render() {
449 let program = two_track_program();
450 assert_eq!(program.estimates.events, 2);
451 assert_eq!(program.estimates.peak_voices, 1);
452 assert_eq!(
453 program.estimates.frames,
454 (program.doc.duration * program.doc.sample_rate as f32).round() as u64
455 );
456 assert_eq!(program.estimates.memory_bytes, program.estimates.frames * 8);
457 }
458}