Skip to main content

refrain_adapters/
text.rs

1//! Text adapter: deterministic natural-language template rendering.
2//!
3//! Each Hap is rendered as one line of "human-readable" prose. The
4//! template is intentionally minimal — a heavier markov or n-gram
5//! generator would need a corpus and would not be byte-deterministic
6//! across machines. For Phase 9 we ship a stable template renderer and
7//! a tiny seeded shuffler that produces stylistic variation on the
8//! filler words while keeping the structural slots fixed.
9
10use refrain_core::Refrain;
11
12use crate::schedule::{schedule, Hap};
13use crate::{AdapterCaps, AdapterErr, EmitCtx, ExtractedRefrain, RefrainAdapter};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum TextStyle {
17    Prose,
18    Bullets,
19}
20
21pub struct TextAdapter {
22    pub style: TextStyle,
23    pub seed: u64,
24}
25
26impl TextAdapter {
27    pub fn new(style: TextStyle) -> Self {
28        Self { style, seed: 0 }
29    }
30
31    pub fn with_seed(style: TextStyle, seed: u64) -> Self {
32        Self { style, seed }
33    }
34}
35
36fn collect_haps(refrain: &Refrain) -> Vec<Hap> {
37    let mut all = Vec::new();
38    let mut t = 0.0;
39    for (_kind, p) in refrain.stages() {
40        let (sub, dur) = schedule(p, t);
41        all.extend(sub);
42        t += dur;
43    }
44    all
45}
46
47// A small seeded LCG so the same (refrain, seed) yields the same output.
48fn lcg(state: &mut u64) -> u64 {
49    *state = state
50        .wrapping_mul(6364136223846793005)
51        .wrapping_add(1442695040888963407);
52    *state
53}
54
55const VERBS: &[&str] = &["sings", "chants", "intones", "voices", "speaks"];
56const SUFFIXES: &[&str] = &[".", " in measured time.", " over the refrain.", " quietly."];
57
58fn render_hap(h: &Hap, state: &mut u64, style: TextStyle) -> String {
59    let verb = VERBS[(lcg(state) as usize) % VERBS.len()];
60    let suffix = SUFFIXES[(lcg(state) as usize) % SUFFIXES.len()];
61    match style {
62        TextStyle::Prose => match &h.pitch {
63            Some(p) => format!(
64                "At cycle {:.4}, the voice {} {} for {:.4} cycles{}",
65                h.start,
66                verb,
67                p,
68                h.duration(),
69                suffix
70            ),
71            None => format!(
72                "At cycle {:.4}, a structural mark: {}{}",
73                h.start, h.value, suffix
74            ),
75        },
76        TextStyle::Bullets => match &h.pitch {
77            Some(p) => format!("- t={:.4} {} {} dur={:.4}", h.start, verb, p, h.duration()),
78            None => format!("- t={:.4} mark={}", h.start, h.value),
79        },
80    }
81}
82
83impl RefrainAdapter for TextAdapter {
84    fn name(&self) -> &str {
85        match self.style {
86            TextStyle::Prose => "text.prose",
87            TextStyle::Bullets => "text.bullets",
88        }
89    }
90
91    fn emit(&self, refrain: &ExtractedRefrain, _ctx: &EmitCtx) -> Result<Vec<u8>, AdapterErr> {
92        let haps = collect_haps(refrain.refrain);
93        let mut state = self.seed.wrapping_add(0x9E3779B97F4A7C15); // stable salt
94        let mut out = String::new();
95        out.push_str("# Refrain: ");
96        out.push_str(&refrain.refrain.name);
97        out.push('\n');
98        for h in &haps {
99            out.push_str(&render_hap(h, &mut state, self.style));
100            out.push('\n');
101        }
102        Ok(out.into_bytes())
103    }
104
105    fn capabilities(&self) -> AdapterCaps {
106        AdapterCaps {
107            realtime: false,
108            differentiable: false,
109        }
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use refrain_core::parse;
117
118    #[test]
119    fn prose_contains_pitch_lines() {
120        let r = parse("(refrain a (territorialize (loop 4 (note C4 q))))").unwrap();
121        let a = TextAdapter::new(TextStyle::Prose);
122        let s = String::from_utf8(
123            a.emit(&ExtractedRefrain { refrain: &r }, &EmitCtx::default())
124                .unwrap(),
125        )
126        .unwrap();
127        assert!(s.contains("# Refrain: a"));
128        assert_eq!(s.matches("C4").count(), 4);
129    }
130
131    #[test]
132    fn bullets_uses_dash_prefix() {
133        let r = parse("(refrain b (territorialize (note G4 e)))").unwrap();
134        let a = TextAdapter::new(TextStyle::Bullets);
135        let s = String::from_utf8(
136            a.emit(&ExtractedRefrain { refrain: &r }, &EmitCtx::default())
137                .unwrap(),
138        )
139        .unwrap();
140        assert!(s.contains("- t="));
141        assert!(s.contains("G4"));
142    }
143
144    #[test]
145    fn deterministic_for_same_seed() {
146        let r = parse("(refrain c (territorialize (loop 4 (note C4 q))))").unwrap();
147        let a = TextAdapter::with_seed(TextStyle::Prose, 42);
148        let s1 = a
149            .emit(&ExtractedRefrain { refrain: &r }, &EmitCtx::default())
150            .unwrap();
151        let s2 = a
152            .emit(&ExtractedRefrain { refrain: &r }, &EmitCtx::default())
153            .unwrap();
154        assert_eq!(s1, s2);
155    }
156
157    #[test]
158    fn different_seeds_diverge() {
159        let r = parse("(refrain c (territorialize (loop 4 (note C4 q))))").unwrap();
160        let a = TextAdapter::with_seed(TextStyle::Prose, 1);
161        let b = TextAdapter::with_seed(TextStyle::Prose, 2);
162        let sa = a
163            .emit(&ExtractedRefrain { refrain: &r }, &EmitCtx::default())
164            .unwrap();
165        let sb = b
166            .emit(&ExtractedRefrain { refrain: &r }, &EmitCtx::default())
167            .unwrap();
168        assert_ne!(sa, sb);
169    }
170
171    #[test]
172    fn empty_refrain_emits_just_header() {
173        let r = parse("(refrain e)").unwrap();
174        let a = TextAdapter::new(TextStyle::Prose);
175        let s = String::from_utf8(
176            a.emit(&ExtractedRefrain { refrain: &r }, &EmitCtx::default())
177                .unwrap(),
178        )
179        .unwrap();
180        assert_eq!(s, "# Refrain: e\n");
181    }
182
183    #[test]
184    fn names_distinguish_styles() {
185        assert_eq!(TextAdapter::new(TextStyle::Prose).name(), "text.prose");
186        assert_eq!(TextAdapter::new(TextStyle::Bullets).name(), "text.bullets");
187    }
188}