gioser

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 614Markdown 274TOML 149RON 310606 Definiciones

⎇ main llimphi-voxel-studio/src/soundtrack.rs Rust · 213 líneas · 7.8 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo — 27 crates nuevos (anim, lottie, svg, mesh, image, layer, test, wire-view, widgets rag-sidebar/router/lazy-list/rive-button) y sync de los 95 existentes 93253cc22 · Sergio ·

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}