tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
1//! Banda sonora del export: **compone una pieza a la medida de la escena** con el
2//! motor generativo de takiy (`takiy-genesis`) y la sintetiza a WAV (`takiy-synth`)
3//! para muxearla al video.
4//!
5//! El cruce vive **aquí, en la app studio** — nunca en el motor `llimphi-*` ni en el
6//! núcleo agnóstico `llimphi-voxel`. La app conoce takiy; el motor no. Así se
7//! mantiene la independencia del crate llimphi: takiy entra como *frontend* del
8//! export, no como dependencia del gráfico.
9//!
10//! La escena dicta la música (la geografía es la partitura):
11//! · el **bioma** (agua/ríos/montaña/relieve) fija el ánimo y el estilo;
12//! · el **nombre** de la escena y del bioma entran como prosa (`Brief::from_prompt`);
13//! · la **atmósfera** de apertura (bruma + fundido) tira a lo onírico;
14//! · la **duración** manda los compases (música que cubre el plano).
15//! Sobre esa pieza caen **campanas** en los *beats del guion* (cortes de cámara +
16//! gestos) para que la música acompañe la acción. Determinista: misma escena →
17//! mismo WAV (la semilla sale del nombre + ids del mundo, sin reloj).
18
19use llimphi_voxel::{Bioma, SceneSpec};
20use takiy_core::{Patch, Pitch, ReverbParams, ScoreNote, Track, Wave};
21use takiy_genesis::{compose, Brief, Mood, Style};
22use takiy_synth::{write_wav, OscRenderer, Renderer};
23
24/// Compone la banda de `scene` con el motor generativo, deja caer campanas sobre
25/// los `accents` (beats del guion, en segundos) y la sintetiza a `path` (WAV PCM
26/// 16-bit). Devuelve la duración en segundos. `world_seed` entra en la semilla para
27/// que dos mundos distintos con el mismo nombre de escena rindan variantes.
28pub fn render_for_scene(
29 path: &str,
30 scene: &SceneSpec,
31 bioma: &Bioma,
32 world_seed: u32,
33 accents: &[f32],
34) -> f32 {
35 let brief = brief_for_scene(scene, bioma, world_seed);
36 let mut score = compose(&brief);
37
38 // Reverb de master según el ánimo: los mundos de agua/onírico respiran más.
39 score.master_reverb = Some(match brief.mood {
40 Mood::Onirico | Mood::Calmo | Mood::Melancolico => {
41 ReverbParams { room_size: 0.7, damping: 0.4, mix: 0.28 }
42 }
43 _ => ReverbParams { room_size: 0.5, damping: 0.5, mix: 0.15 },
44 });
45
46 // Acentos: una campana aguda (fundamental + octava, corta) en cada beat del
47 // guion. Los `accents` vienen en segundos → a beats con el tempo de la pieza.
48 if !accents.is_empty() {
49 let sec_per_beat = 60.0 / brief.tempo_bpm.max(1.0);
50 let mut hits = Track::new("acentos");
51 hits.volume = 0.9;
52 hits.instrument = Some(bell_patch());
53 for &t in accents {
54 let beat = t / sec_per_beat;
55 for midi in [84, 96] {
56 if let Some(p) = Pitch::from_midi(midi) {
57 hits.add(ScoreNote::new(p, beat, 0.5, 90));
58 }
59 }
60 }
61 score.add_track(hits);
62 }
63
64 let renderer = OscRenderer { sample_rate: 44_100, ..Default::default() };
65 let buf = renderer.render(&score);
66 let secs = buf.duration_seconds();
67 let _ = write_wav(&buf, path);
68 secs
69}
70
71/// Traduce la escena a un [`Brief`] generativo. El nombre (escena + bioma) entra
72/// como prosa; el relieve del bioma refina ánimo/estilo; la duración fija los
73/// compases. Función pura y determinista.
74fn brief_for_scene(scene: &SceneSpec, bioma: &Bioma, world_seed: u32) -> Brief {
75 let seed = scene_seed(scene, world_seed);
76 let mut brief = Brief::from_prompt(&format!("{} {}", scene.name, bioma.name), seed);
77
78 // El relieve del bioma como partitura:
79 // · mucha agua/ríos → onírico y cinemático (con reverb).
80 // · mucha montaña/relieve → enérgico y épico.
81 // · llanura árida y seca → oscuro/esparcido.
82 let agua = bioma.water_level.max(bioma.rivers);
83 let montana = (bioma.relief * bioma.mountains).clamp(0.0, 1.0);
84 if agua > 0.45 {
85 brief.mood = Mood::Onirico;
86 brief.style = Style::Cinematica;
87 } else if montana > 0.35 {
88 brief.mood = Mood::Energico;
89 brief.style = Style::Cinematica;
90 } else if agua < 0.15 && bioma.relief < 0.2 {
91 brief.mood = Mood::Oscuro;
92 }
93
94 // La atmósfera de apertura (bruma + fundido) tira a lo onírico.
95 if scene.atmosfera.is_some() {
96 brief.mood = Mood::Onirico;
97 }
98
99 // La duración manda los compases (4/4): música que cubre la escena.
100 let sec_per_bar = 4.0 * 60.0 / brief.tempo_bpm.max(1.0);
101 brief.bars = ((scene.duration / sec_per_bar).ceil() as usize).clamp(4, 64);
102
103 brief
104}
105
106/// Semilla determinista de la escena: FNV-1a del nombre, mezclado con el id del
107/// mundo y su semilla. Sin reloj → reproducible cuadro a cuadro.
108fn scene_seed(scene: &SceneSpec, world_seed: u32) -> u64 {
109 let mut h: u64 = 0xcbf2_9ce4_8422_2325;
110 for b in scene.name.bytes() {
111 h ^= b as u64;
112 h = h.wrapping_mul(0x0000_0100_0000_01b3);
113 }
114 h ^= scene.mundo.wrapping_mul(0x9e37_79b9_7f4a_7c15);
115 h ^= (world_seed as u64).wrapping_mul(0x2545_f491_4f6c_dd1d);
116 h | 1
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122 use llimphi_voxel::Clip;
123
124 /// Bioma de prueba con relieve controlado (los materiales no importan aquí).
125 fn bioma(name: &str, water_level: f32, rivers: f32, relief: f32, mountains: f32) -> Bioma {
126 Bioma {
127 id: 1,
128 name: name.into(),
129 base: 0.3,
130 dune: 0.05,
131 relief,
132 mountains,
133 water_level,
134 rivers,
135 peak_at: 0.8,
136 ground: 0,
137 cliff: 0,
138 peak: None,
139 objetos: Vec::new(),
140 minerales: Vec::new(),
141 densidad_urbana: 0.4,
142 casas: Vec::new(),
143 fauna: vec![],
144 seres: Vec::new(),
145 }
146 }
147
148 fn escena(name: &str, dur: f32) -> SceneSpec {
149 let mut s = SceneSpec::walk_and_emote(name, 0, 2, Clip::Wave, [96, 24, 96]);
150 s.duration = dur;
151 s
152 }
153
154 #[test]
155 fn determinista_misma_escena_mismo_brief() {
156 let b = bioma("desierto", 0.1, 0.0, 0.1, 0.1);
157 let s = escena("el despertar", 6.0);
158 let a = brief_for_scene(&s, &b, 1337);
159 let c = brief_for_scene(&s, &b, 1337);
160 assert_eq!(a, c);
161 }
162
163 #[test]
164 fn bioma_de_agua_es_onirico() {
165 let b = bioma("el lago", 0.7, 0.5, 0.1, 0.1);
166 let brief = brief_for_scene(&escena("calma", 8.0), &b, 7);
167 assert_eq!(brief.mood, Mood::Onirico);
168 assert_eq!(brief.style, Style::Cinematica);
169 }
170
171 #[test]
172 fn bioma_de_montana_es_energico() {
173 let b = bioma("las cumbres", 0.1, 0.0, 0.8, 0.8);
174 let brief = brief_for_scene(&escena("la cima", 8.0), &b, 7);
175 assert_eq!(brief.mood, Mood::Energico);
176 }
177
178 #[test]
179 fn llanura_arida_es_oscura() {
180 let b = bioma("el yermo", 0.05, 0.0, 0.1, 0.1);
181 let brief = brief_for_scene(&escena("sed", 8.0), &b, 7);
182 assert_eq!(brief.mood, Mood::Oscuro);
183 }
184
185 #[test]
186 fn la_duracion_manda_los_compases() {
187 let b = bioma("pradera", 0.2, 0.1, 0.2, 0.2);
188 // Escena larga → más compases que una corta.
189 let corta = brief_for_scene(&escena("saludo", 4.0), &b, 7);
190 let larga = brief_for_scene(&escena("saludo", 30.0), &b, 7);
191 assert!(larga.bars > corta.bars);
192 // La pieza cubre (o excede) la duración de la escena.
193 let sec_per_bar = 4.0 * 60.0 / larga.tempo_bpm;
194 assert!(larga.bars as f32 * sec_per_bar >= 30.0);
195 }
196}
197
198/// Campana brillante para los acentos: triángulo agudo con decay corto, filtro
199/// abierto. Corta por encima de la pieza sin taparla.
200fn bell_patch() -> Patch {
201 Patch {
202 name: "Campana".into(),
203 wave: Wave::Triangle,
204 attack: 0.005,
205 decay: 0.18,
206 sustain: 0.0,
207 release: 0.25,
208 cutoff_hz: 6000.0,
209 detune_cents: 0.0,
210 gain: 0.7,
211 }
212 .sanitized()
213}