Skip to main content

treble_lang/session/
mod.rs

1//! Live session engine.
2//!
3//! The [`Session`] holds the live state: active patterns, tempo, time
4//! signature, etc.  The TUI calls [`Session::evaluate`] on save, which
5//! parses the source, diffs against the previous state, and queues
6//! changes for the next loop boundary.
7
8use std::collections::HashMap;
9
10use crate::ast::{GroupDef, InstrumentDef, PatternDef, PitchRoot, Program, ScaleMode, SourceLine};
11use crate::error::CompileError;
12use crate::parser::parse_program;
13use crate::piece::{self, Piece};
14
15/// A change that will be applied at the next loop boundary.
16#[derive(Debug, Clone, PartialEq)]
17pub enum Delta {
18    /// A new pattern was added.
19    Add(String),
20    /// An existing pattern was modified.
21    Modify(String),
22    /// A pattern was removed.
23    Remove(String),
24    /// A pattern was muted.
25    Mute(String),
26    /// A pattern was unmuted.
27    Unmute(String),
28    /// A `def` instrument was added.
29    AddInstrument(String),
30    /// A `def` instrument's definition changed.
31    ModifyInstrument(String),
32    /// A `def` instrument was removed from the buffer.
33    RemoveInstrument(String),
34    /// A group was added.
35    AddGroup(String),
36    /// A group's members, filters, or mute flag changed.
37    ModifyGroup(String),
38    /// A group was removed.
39    RemoveGroup(String),
40    /// A `load "<path>"` needs resolving: the path is new to the buffer, or the
41    /// order of the `load` lines changed and with it which file wins a name.
42    ///
43    /// This crate does no I/O, so it cannot see a loaded file change on disk; a
44    /// consumer watching the file raises this same delta for that case (§9.2).
45    Load(String),
46    /// A `load` line left the buffer, so its definitions go with it.
47    Unload(String),
48}
49
50/// Result returned by [`Session::evaluate`].
51#[derive(Debug, Clone)]
52pub struct EvalResult {
53    /// Parse errors (per-line, non-fatal).
54    pub errors: Vec<CompileError>,
55    /// Changes detected vs. previous state.
56    pub deltas: Vec<Delta>,
57    /// Summary counts.
58    pub patterns_active: usize,
59    pub patterns_muted: usize,
60    /// The resolved timeline when the buffer is a piece (§8), `None` when it
61    /// is a live buffer.
62    ///
63    /// A piece is not diffed: it is re-resolved from the top, so `deltas` is
64    /// empty for everything but the instrument definitions and `load` paths a
65    /// consumer still has to act on.
66    pub piece: Option<Piece>,
67}
68
69fn group_error(line: usize, message: String) -> CompileError {
70    CompileError {
71        kind: crate::error::CompileErrorKind::ParseError,
72        location: crate::error::SourceLocation {
73            line,
74            column: 1,
75            file: None,
76        },
77        message,
78        suggestion: None,
79    }
80}
81
82/// Live session state.
83#[derive(Clone)]
84pub struct Session {
85    /// Current BPM.
86    pub bpm: u32,
87    /// Current time signature (numerator, denominator).
88    pub sig: (u8, u8),
89    /// How many cycles a phrase spans. Changes are quantised to it, so `1` is
90    /// "apply at the next cycle" and `16` is "apply at the top of the phrase".
91    pub phrase: u32,
92    /// `seed` (§8.8) — salts every generative construct in the buffer.
93    pub seed: u64,
94    /// Default scale for degree patterns (`scale` directive).
95    pub scale: Option<(PitchRoot, ScaleMode)>,
96    /// Active patterns by name.
97    patterns: HashMap<String, PatternDef>,
98    /// Instruments defined in the buffer by `def`, by name.
99    definitions: HashMap<String, InstrumentDef>,
100    /// Instrument groups by name.
101    groups: HashMap<String, GroupDef>,
102    /// `load` paths in buffer order — the order decides which of two files wins
103    /// a name (§2.5), so it is kept rather than reduced to a set.
104    loads: Vec<String>,
105    /// Pending deltas (queued for next loop boundary).
106    pending: Vec<Delta>,
107    /// Last successfully parsed program (for diffing).
108    last_program: Option<Program>,
109    /// The resolved piece, when the buffer has sections (§8).
110    piece: Option<Piece>,
111}
112
113impl Session {
114    pub fn new() -> Self {
115        Self {
116            bpm: 120,
117            sig: (4, 4),
118            phrase: 1,
119            seed: 0,
120            scale: None,
121            patterns: HashMap::new(),
122            definitions: HashMap::new(),
123            groups: HashMap::new(),
124            loads: Vec::new(),
125            pending: Vec::new(),
126            last_program: None,
127            piece: None,
128        }
129    }
130
131    /// The resolved piece, when the buffer has sections (§8).
132    ///
133    /// This is the authoritative structure in piece mode: [`Session::patterns`]
134    /// still holds the union of every section's lines, but only so a consumer
135    /// can see which instruments the buffer needs — which line sounds when is
136    /// the piece's business, since the same name legitimately appears in every
137    /// section.
138    pub fn piece(&self) -> Option<&Piece> {
139        self.piece.as_ref()
140    }
141
142    /// Whether the buffer is a piece rather than a live session (§8.1).
143    pub fn is_piece(&self) -> bool {
144        self.piece.is_some()
145    }
146
147    /// Evaluate a source string, parse it, diff against previous state,
148    /// and return the result.
149    pub fn evaluate(&mut self, source: &str) -> EvalResult {
150        let (program, errors) = parse_program(source);
151
152        // Evaluation is transactional: a partially parsed buffer must never
153        // poison the state used to diff the next stage-safe edit.
154        if !errors.is_empty() {
155            return EvalResult {
156                errors,
157                deltas: Vec::new(),
158                patterns_active: self.patterns.values().filter(|p| !p.muted).count(),
159                patterns_muted: self.patterns.values().filter(|p| p.muted).count(),
160                piece: self.piece.clone(),
161            };
162        }
163
164        // A buffer with sections is a piece (§8.1). The two modes share every
165        // line kind but differ in what a line means, so the mode is settled
166        // once here and the rest of the walk consults it.
167        let is_piece = piece::is_piece(&program);
168
169        // Extract state from the new program
170        let mut new_bpm = self.bpm;
171        let mut new_sig = self.sig;
172        let mut new_phrase = self.phrase;
173        let mut new_seed = self.seed;
174        let mut new_scale = self.scale;
175        let mut new_patterns: HashMap<String, PatternDef> = HashMap::new();
176        let mut new_definitions: HashMap<String, InstrumentDef> = HashMap::new();
177        let mut new_groups: HashMap<String, GroupDef> = HashMap::new();
178        let mut new_loads: Vec<String> = Vec::new();
179        // The open group's name and mute flag while walking the lines; the
180        // parser has already verified pairing, so this only pairs the header
181        // with its `}` transforms.
182        let mut open_group: Option<(String, bool)> = None;
183        let mut group_errors: Vec<CompileError> = Vec::new();
184
185        for (line_idx, line) in program.lines.iter().enumerate() {
186            match line {
187                SourceLine::Bpm(val) => new_bpm = *val,
188                SourceLine::Sig(num, den) => new_sig = (*num, *den),
189                SourceLine::Phrase(cycles) => new_phrase = *cycles,
190                SourceLine::Scale(root, mode) => new_scale = Some((*root, *mode)),
191                SourceLine::Load(path) => new_loads.push(path.clone()),
192                SourceLine::Seed(value) => new_seed = *value,
193                SourceLine::Meta(_, _) => {}
194                directive @ (SourceLine::Arrange(_) | SourceLine::Tail(_)) if !is_piece => {
195                    group_errors.push(group_error(
196                        line_idx + 1,
197                        format!(
198                            "{}: a live buffer has no sections to arrange or tail — add a \
199                             'section' block to make this a piece (§8.1)",
200                            match directive {
201                                SourceLine::Arrange(_) => "arrange",
202                                _ => "tail",
203                            }
204                        ),
205                    ));
206                }
207                SourceLine::Pattern(def) => {
208                    new_patterns.insert(def.name.clone(), def.clone());
209                }
210                SourceLine::Def(definition) => {
211                    new_definitions.insert(definition.name.clone(), (**definition).clone());
212                }
213                SourceLine::GroupStart { muted, name } => {
214                    // In a piece the same bus is declared in every section that
215                    // feeds it, which is not a redeclaration; that the chains
216                    // agree is checked when the piece is resolved (§8.2).
217                    if !is_piece && new_groups.contains_key(name) {
218                        group_errors.push(group_error(
219                            line_idx + 1,
220                            format!("group '{name}' is declared twice"),
221                        ));
222                    }
223                    open_group = Some((name.clone(), *muted));
224                }
225                SourceLine::GroupEnd(transforms) => {
226                    if let Some((name, muted)) = open_group.take() {
227                        new_groups.insert(
228                            name.clone(),
229                            GroupDef {
230                                muted,
231                                name,
232                                transforms: transforms.clone(),
233                            },
234                        );
235                    }
236                }
237                _ => {}
238            }
239        }
240        // A group and a pattern sharing a name would collide in the mixer. In a
241        // piece the namespaces are per-section, so the piece resolver checks it
242        // there rather than against the flattened union here.
243        for name in new_groups.keys().filter(|_| !is_piece) {
244            if new_patterns.contains_key(name) {
245                group_errors.push(group_error(
246                    1,
247                    format!("'{name}' names both a group and a pattern; pick two names"),
248                ));
249            }
250        }
251        if !group_errors.is_empty() {
252            return EvalResult {
253                errors: group_errors,
254                deltas: Vec::new(),
255                patterns_active: self.patterns.values().filter(|p| !p.muted).count(),
256                patterns_muted: self.patterns.values().filter(|p| p.muted).count(),
257                piece: self.piece.clone(),
258            };
259        }
260
261        // Compute deltas
262        let mut deltas = self.diff(&new_patterns);
263        deltas.extend(self.diff_definitions(&new_definitions));
264        deltas.extend(self.diff_groups(&new_groups));
265        deltas.extend(self.diff_loads(&new_loads));
266
267        // Resolve the arrangement into a timeline. A piece is not diffed — it
268        // is re-resolved from the top (§8.9) — so this replaces the pattern
269        // deltas rather than adding to them.
270        let mut piece = None;
271        if is_piece {
272            let (resolved, piece_errors) = piece::resolve(&program, (new_bpm, new_sig, new_scale));
273            if !piece_errors.is_empty() {
274                return EvalResult {
275                    errors: piece_errors,
276                    deltas: Vec::new(),
277                    patterns_active: self.patterns.values().filter(|p| !p.muted).count(),
278                    patterns_muted: self.patterns.values().filter(|p| p.muted).count(),
279                    piece: self.piece.clone(),
280                };
281            }
282            piece = Some(resolved);
283        }
284
285        // Apply immediate directives
286        self.bpm = new_bpm;
287        self.sig = new_sig;
288        self.phrase = new_phrase;
289        self.seed = new_seed;
290        self.scale = new_scale;
291
292        // Update pattern state
293        self.pending = deltas.clone();
294        self.patterns = new_patterns;
295        self.definitions = new_definitions;
296        self.groups = new_groups;
297        self.loads = new_loads;
298        self.last_program = Some(program);
299        self.piece = piece.clone();
300
301        let patterns_active = self.patterns.values().filter(|p| !p.muted).count();
302        let patterns_muted = self.patterns.values().filter(|p| p.muted).count();
303
304        EvalResult {
305            errors,
306            deltas,
307            patterns_active,
308            patterns_muted,
309            piece,
310        }
311    }
312
313    /// Diff new patterns against current state.
314    fn diff(&self, new_patterns: &HashMap<String, PatternDef>) -> Vec<Delta> {
315        let mut deltas = Vec::new();
316
317        // Check for added or modified patterns
318        for (name, new_def) in new_patterns {
319            match self.patterns.get(name) {
320                None => deltas.push(Delta::Add(name.clone())),
321                Some(old_def) => {
322                    if old_def.muted && !new_def.muted {
323                        deltas.push(Delta::Unmute(name.clone()));
324                    } else if !old_def.muted && new_def.muted {
325                        deltas.push(Delta::Mute(name.clone()));
326                    } else if old_def != new_def {
327                        deltas.push(Delta::Modify(name.clone()));
328                    }
329                }
330            }
331        }
332
333        // Check for removed patterns
334        for name in self.patterns.keys() {
335            if !new_patterns.contains_key(name) {
336                deltas.push(Delta::Remove(name.clone()));
337            }
338        }
339
340        deltas
341    }
342
343    /// Diff `def` blocks against current state.
344    fn diff_definitions(&self, new_definitions: &HashMap<String, InstrumentDef>) -> Vec<Delta> {
345        let mut deltas = Vec::new();
346        for (name, definition) in new_definitions {
347            match self.definitions.get(name) {
348                None => deltas.push(Delta::AddInstrument(name.clone())),
349                Some(previous) if previous != definition => {
350                    deltas.push(Delta::ModifyInstrument(name.clone()));
351                }
352                Some(_) => {}
353            }
354        }
355        for name in self.definitions.keys() {
356            if !new_definitions.contains_key(name) {
357                deltas.push(Delta::RemoveInstrument(name.clone()));
358            }
359        }
360        deltas
361    }
362
363    fn diff_groups(&self, new_groups: &HashMap<String, GroupDef>) -> Vec<Delta> {
364        let mut deltas = Vec::new();
365        for (name, group) in new_groups {
366            match self.groups.get(name) {
367                None => deltas.push(Delta::AddGroup(name.clone())),
368                Some(previous) if previous != group => {
369                    deltas.push(Delta::ModifyGroup(name.clone()));
370                }
371                Some(_) => {}
372            }
373        }
374        for name in self.groups.keys() {
375            if !new_groups.contains_key(name) {
376                deltas.push(Delta::RemoveGroup(name.clone()));
377            }
378        }
379        deltas
380    }
381
382    /// Diff `load` paths against current state.
383    ///
384    /// A path is compared by the text the performer wrote, not by a resolved
385    /// filename: resolution is the consumer's (§2.5), and it is the only side
386    /// that can tell two spellings of one file apart.
387    fn diff_loads(&self, new_loads: &[String]) -> Vec<Delta> {
388        let mut deltas = Vec::new();
389        for path in new_loads {
390            if !self.loads.contains(path) {
391                deltas.push(Delta::Load(path.clone()));
392            }
393        }
394        for path in self.loads.iter() {
395            if !new_loads.contains(path) {
396                deltas.push(Delta::Unload(path.clone()));
397            }
398        }
399        // A pure reorder changes which file wins a name, so the whole set has
400        // to be resolved again even though nothing was added or removed.
401        if deltas.is_empty() && self.loads != new_loads {
402            deltas.extend(new_loads.iter().cloned().map(Delta::Load));
403        }
404        deltas
405    }
406
407    /// `load` paths in the buffer, in the order they were written.
408    pub fn loads(&self) -> &[String] {
409        &self.loads
410    }
411
412    /// Instruments defined in the buffer, by name.
413    pub fn definitions(&self) -> &HashMap<String, InstrumentDef> {
414        &self.definitions
415    }
416
417    /// Instrument groups in the buffer, by name.
418    pub fn groups(&self) -> &HashMap<String, GroupDef> {
419        &self.groups
420    }
421
422    /// Get the currently pending deltas.
423    pub fn pending_deltas(&self) -> &[Delta] {
424        &self.pending
425    }
426
427    /// Apply pending deltas (called by the TUI at loop boundary).
428    pub fn apply_pending(&mut self) {
429        self.pending.clear();
430    }
431
432    /// Get the current active (non-muted) pattern definitions.
433    pub fn active_patterns(&self) -> Vec<&PatternDef> {
434        self.patterns.values().filter(|p| !p.muted).collect()
435    }
436
437    /// Get all pattern definitions (including muted).
438    pub fn all_patterns(&self) -> &HashMap<String, PatternDef> {
439        &self.patterns
440    }
441}
442
443impl Default for Session {
444    fn default() -> Self {
445        Self::new()
446    }
447}
448
449// ============================================================
450// Tests
451// ============================================================
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456    use crate::ast::{Accidental, NoteLetter, PitchRoot, ScaleMode};
457
458    #[test]
459    fn test_groups_are_tracked_and_diffed() {
460        let mut session = Session::new();
461        let source = "group drums {\nkick kick \"x ~\"\n} | lpf 800";
462        let result = session.evaluate(source);
463        assert!(result.errors.is_empty(), "{:?}", result.errors);
464        assert!(result.deltas.contains(&Delta::AddGroup("drums".into())));
465        let group = &session.groups()["drums"];
466        assert!(!group.muted);
467        assert_eq!(group.transforms.len(), 1);
468
469        // Changing the shared chain is a modify; dropping the block a remove.
470        let result = session.evaluate("group drums {\nkick kick \"x ~\"\n} | lpf 400");
471        assert!(result.deltas.contains(&Delta::ModifyGroup("drums".into())));
472        let result = session.evaluate("kick kick \"x ~\"");
473        assert!(result.deltas.contains(&Delta::RemoveGroup("drums".into())));
474        assert!(session.groups().is_empty());
475    }
476
477    #[test]
478    fn test_group_name_collisions_are_rejected() {
479        let mut session = Session::new();
480        let twice = "group a {\nkick kick \"x\"\n}\ngroup a {\nsn snare \"x\"\n}";
481        assert!(!session.evaluate(twice).errors.is_empty());
482        let shadowing = "group kick {\nkick kick \"x\"\n}";
483        assert!(!session.evaluate(shadowing).errors.is_empty());
484        // A failed evaluation must not poison the session.
485        assert!(session.groups().is_empty());
486    }
487
488    #[test]
489    fn test_loads_are_tracked_and_diffed() {
490        let mut session = Session::new();
491        let result = session.evaluate("load \"pads.trbl\"\nkick kick \"x ~\"");
492        assert!(result.errors.is_empty(), "{:?}", result.errors);
493        assert!(result.deltas.contains(&Delta::Load("pads.trbl".into())));
494        assert_eq!(session.loads(), ["pads.trbl"]);
495
496        // Unchanged buffer, nothing to resolve again.
497        let result = session.evaluate("load \"pads.trbl\"\nkick kick \"x ~\"");
498        assert!(result.deltas.is_empty(), "{:?}", result.deltas);
499
500        // A second file is one new load, not a re-resolve of both.
501        let result = session.evaluate("load \"pads.trbl\"\nload \"keys.trbl\"\nkick kick \"x ~\"");
502        assert_eq!(result.deltas, vec![Delta::Load("keys.trbl".into())]);
503
504        // Swapping the order changes which file wins a name, so both reload.
505        let result = session.evaluate("load \"keys.trbl\"\nload \"pads.trbl\"\nkick kick \"x ~\"");
506        assert_eq!(
507            result.deltas,
508            vec![
509                Delta::Load("keys.trbl".into()),
510                Delta::Load("pads.trbl".into())
511            ]
512        );
513
514        let result = session.evaluate("kick kick \"x ~\"");
515        assert!(result.deltas.contains(&Delta::Unload("pads.trbl".into())));
516        assert!(result.deltas.contains(&Delta::Unload("keys.trbl".into())));
517        assert!(session.loads().is_empty());
518    }
519
520    #[test]
521    fn test_session_initial_state() {
522        let session = Session::new();
523        assert_eq!(session.bpm, 120);
524        assert_eq!(session.sig, (4, 4));
525        assert!(session.patterns.is_empty());
526    }
527
528    #[test]
529    fn test_evaluate_directives() {
530        let mut session = Session::new();
531        let result = session.evaluate("bpm 140\nsig 3/4");
532        assert!(result.errors.is_empty());
533        assert_eq!(session.bpm, 140);
534        assert_eq!(session.sig, (3, 4));
535    }
536
537    #[test]
538    fn test_evaluate_adds_patterns() {
539        let mut session = Session::new();
540        let result = session.evaluate("kick kick \"x ~ x ~\"\nbass sine \"c2 eb2\"");
541        assert!(result.errors.is_empty());
542        assert_eq!(result.deltas.len(), 2);
543        assert!(result.deltas.iter().all(|d| matches!(d, Delta::Add(_))));
544        assert_eq!(result.patterns_active, 2);
545    }
546
547    #[test]
548    fn test_evaluate_detects_removal() {
549        let mut session = Session::new();
550        session.evaluate("kick kick \"x ~ x ~\"\nbass sine \"c2 eb2\"");
551
552        let result = session.evaluate("kick kick \"x ~ x ~\"");
553        assert!(
554            result
555                .deltas
556                .iter()
557                .any(|d| *d == Delta::Remove("bass".into()))
558        );
559    }
560
561    #[test]
562    fn test_evaluate_detects_modify() {
563        let mut session = Session::new();
564        session.evaluate("kick kick \"x ~ x ~\"");
565
566        let result = session.evaluate("kick kick \"x x x x\"");
567        assert!(
568            result
569                .deltas
570                .iter()
571                .any(|d| *d == Delta::Modify("kick".into()))
572        );
573    }
574
575    #[test]
576    fn test_evaluate_detects_mute() {
577        let mut session = Session::new();
578        session.evaluate("kick kick \"x ~ x ~\"");
579
580        let result = session.evaluate("; kick kick \"x ~ x ~\"");
581        assert!(
582            result
583                .deltas
584                .iter()
585                .any(|d| *d == Delta::Mute("kick".into()))
586        );
587        assert_eq!(result.patterns_muted, 1);
588        assert_eq!(result.patterns_active, 0);
589    }
590
591    #[test]
592    fn test_evaluate_detects_unmute() {
593        let mut session = Session::new();
594        session.evaluate("; kick kick \"x ~ x ~\"");
595
596        let result = session.evaluate("kick kick \"x ~ x ~\"");
597        assert!(
598            result
599                .deltas
600                .iter()
601                .any(|d| *d == Delta::Unmute("kick".into()))
602        );
603    }
604
605    #[test]
606    fn test_unchanged_no_delta() {
607        let mut session = Session::new();
608        session.evaluate("kick kick \"x ~ x ~\"");
609
610        let result = session.evaluate("kick kick \"x ~ x ~\"");
611        assert!(result.deltas.is_empty());
612    }
613
614    #[test]
615    fn test_error_recovery_preserves_valid() {
616        let mut session = Session::new();
617        session.evaluate("bpm 130\nkick kick \"x ~ x ~\"");
618        let result = session.evaluate("bpm 140\nthis is broken ???\nsnare snare \"x ~ x ~\"");
619        assert_eq!(result.errors.len(), 1);
620        assert_eq!(session.bpm, 130);
621        assert_eq!(result.patterns_active, 1);
622        assert!(session.all_patterns().contains_key("kick"));
623        assert!(!session.all_patterns().contains_key("snare"));
624    }
625
626    #[test]
627    fn test_evaluate_scale_directive() {
628        let mut session = Session::new();
629        let result = session.evaluate("scale C minor\nkick kick \"x ~ x ~\"");
630        assert!(result.errors.is_empty());
631        assert_eq!(
632            session.scale,
633            Some((
634                PitchRoot {
635                    name: NoteLetter::C,
636                    accidental: Accidental::Natural,
637                },
638                ScaleMode::Minor,
639            ))
640        );
641    }
642
643    #[test]
644    fn test_full_example() {
645        let mut session = Session::new();
646        let source = "\
647-- techno loop
648bpm 128
649sig 4/4
650
651kick kick \"x ~ x ~\"
652snare snare \"~ x ~ x\"
653hats hihat \"x*8\"
654bass saw \"c2 _ eb2 _ g1 _ f2 _\"
655lead piano \"c4 eb4 g4 bb4\" | slow 2
656; pad pad \"[c3,eb3,g3] ~ [f3,ab3,c4] ~\"";
657
658        let result = session.evaluate(source);
659        assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
660        assert_eq!(session.bpm, 128);
661        assert_eq!(session.sig, (4, 4));
662        assert_eq!(result.patterns_active, 5);
663        assert_eq!(result.patterns_muted, 1);
664        assert_eq!(result.deltas.len(), 6); // all new = 6 adds
665    }
666}