//! Banda sonora del export: **compone una pieza a la medida de la escena** con el //! motor generativo de takiy (`takiy-genesis`) y la sintetiza a WAV (`takiy-synth`) //! para muxearla al video. //! //! El cruce vive **aquí, en la app studio** — nunca en el motor `llimphi-*` ni en el //! núcleo agnóstico `llimphi-voxel`. La app conoce takiy; el motor no. Así se //! mantiene la independencia del crate llimphi: takiy entra como *frontend* del //! export, no como dependencia del gráfico. //! //! La escena dicta la música (la geografía es la partitura): //! · el **bioma** (agua/ríos/montaña/relieve) fija el ánimo y el estilo; //! · el **nombre** de la escena y del bioma entran como prosa (`Brief::from_prompt`); //! · la **atmósfera** de apertura (bruma + fundido) tira a lo onírico; //! · la **duración** manda los compases (música que cubre el plano). //! Sobre esa pieza caen **campanas** en los *beats del guion* (cortes de cámara + //! gestos) para que la música acompañe la acción. Determinista: misma escena → //! mismo WAV (la semilla sale del nombre + ids del mundo, sin reloj). use llimphi_voxel::{Bioma, SceneSpec}; use takiy_core::{Patch, Pitch, ReverbParams, ScoreNote, Track, Wave}; use takiy_genesis::{compose, Brief, Mood, Style}; use takiy_synth::{write_wav, OscRenderer, Renderer}; /// Compone la banda de `scene` con el motor generativo, deja caer campanas sobre /// los `accents` (beats del guion, en segundos) y la sintetiza a `path` (WAV PCM /// 16-bit). Devuelve la duración en segundos. `world_seed` entra en la semilla para /// que dos mundos distintos con el mismo nombre de escena rindan variantes. pub fn render_for_scene( path: &str, scene: &SceneSpec, bioma: &Bioma, world_seed: u32, accents: &[f32], ) -> f32 { let brief = brief_for_scene(scene, bioma, world_seed); let mut score = compose(&brief); // Reverb de master según el ánimo: los mundos de agua/onírico respiran más. score.master_reverb = Some(match brief.mood { Mood::Onirico | Mood::Calmo | Mood::Melancolico => { ReverbParams { room_size: 0.7, damping: 0.4, mix: 0.28 } } _ => ReverbParams { room_size: 0.5, damping: 0.5, mix: 0.15 }, }); // Acentos: una campana aguda (fundamental + octava, corta) en cada beat del // guion. Los `accents` vienen en segundos → a beats con el tempo de la pieza. if !accents.is_empty() { let sec_per_beat = 60.0 / brief.tempo_bpm.max(1.0); let mut hits = Track::new("acentos"); hits.volume = 0.9; hits.instrument = Some(bell_patch()); for &t in accents { let beat = t / sec_per_beat; for midi in [84, 96] { if let Some(p) = Pitch::from_midi(midi) { hits.add(ScoreNote::new(p, beat, 0.5, 90)); } } } score.add_track(hits); } let renderer = OscRenderer { sample_rate: 44_100, ..Default::default() }; let buf = renderer.render(&score); let secs = buf.duration_seconds(); let _ = write_wav(&buf, path); secs } /// Traduce la escena a un [`Brief`] generativo. El nombre (escena + bioma) entra /// como prosa; el relieve del bioma refina ánimo/estilo; la duración fija los /// compases. Función pura y determinista. fn brief_for_scene(scene: &SceneSpec, bioma: &Bioma, world_seed: u32) -> Brief { let seed = scene_seed(scene, world_seed); let mut brief = Brief::from_prompt(&format!("{} {}", scene.name, bioma.name), seed); // El relieve del bioma como partitura: // · mucha agua/ríos → onírico y cinemático (con reverb). // · mucha montaña/relieve → enérgico y épico. // · llanura árida y seca → oscuro/esparcido. let agua = bioma.water_level.max(bioma.rivers); let montana = (bioma.relief * bioma.mountains).clamp(0.0, 1.0); if agua > 0.45 { brief.mood = Mood::Onirico; brief.style = Style::Cinematica; } else if montana > 0.35 { brief.mood = Mood::Energico; brief.style = Style::Cinematica; } else if agua < 0.15 && bioma.relief < 0.2 { brief.mood = Mood::Oscuro; } // La atmósfera de apertura (bruma + fundido) tira a lo onírico. if scene.atmosfera.is_some() { brief.mood = Mood::Onirico; } // La duración manda los compases (4/4): música que cubre la escena. let sec_per_bar = 4.0 * 60.0 / brief.tempo_bpm.max(1.0); brief.bars = ((scene.duration / sec_per_bar).ceil() as usize).clamp(4, 64); brief } /// Semilla determinista de la escena: FNV-1a del nombre, mezclado con el id del /// mundo y su semilla. Sin reloj → reproducible cuadro a cuadro. fn scene_seed(scene: &SceneSpec, world_seed: u32) -> u64 { let mut h: u64 = 0xcbf2_9ce4_8422_2325; for b in scene.name.bytes() { h ^= b as u64; h = h.wrapping_mul(0x0000_0100_0000_01b3); } h ^= scene.mundo.wrapping_mul(0x9e37_79b9_7f4a_7c15); h ^= (world_seed as u64).wrapping_mul(0x2545_f491_4f6c_dd1d); h | 1 } #[cfg(test)] mod tests { use super::*; use llimphi_voxel::Clip; /// Bioma de prueba con relieve controlado (los materiales no importan aquí). fn bioma(name: &str, water_level: f32, rivers: f32, relief: f32, mountains: f32) -> Bioma { Bioma { id: 1, name: name.into(), base: 0.3, dune: 0.05, relief, mountains, water_level, rivers, peak_at: 0.8, ground: 0, cliff: 0, peak: None, objetos: Vec::new(), minerales: Vec::new(), densidad_urbana: 0.4, casas: Vec::new(), fauna: vec![], seres: Vec::new(), } } fn escena(name: &str, dur: f32) -> SceneSpec { let mut s = SceneSpec::walk_and_emote(name, 0, 2, Clip::Wave, [96, 24, 96]); s.duration = dur; s } #[test] fn determinista_misma_escena_mismo_brief() { let b = bioma("desierto", 0.1, 0.0, 0.1, 0.1); let s = escena("el despertar", 6.0); let a = brief_for_scene(&s, &b, 1337); let c = brief_for_scene(&s, &b, 1337); assert_eq!(a, c); } #[test] fn bioma_de_agua_es_onirico() { let b = bioma("el lago", 0.7, 0.5, 0.1, 0.1); let brief = brief_for_scene(&escena("calma", 8.0), &b, 7); assert_eq!(brief.mood, Mood::Onirico); assert_eq!(brief.style, Style::Cinematica); } #[test] fn bioma_de_montana_es_energico() { let b = bioma("las cumbres", 0.1, 0.0, 0.8, 0.8); let brief = brief_for_scene(&escena("la cima", 8.0), &b, 7); assert_eq!(brief.mood, Mood::Energico); } #[test] fn llanura_arida_es_oscura() { let b = bioma("el yermo", 0.05, 0.0, 0.1, 0.1); let brief = brief_for_scene(&escena("sed", 8.0), &b, 7); assert_eq!(brief.mood, Mood::Oscuro); } #[test] fn la_duracion_manda_los_compases() { let b = bioma("pradera", 0.2, 0.1, 0.2, 0.2); // Escena larga → más compases que una corta. let corta = brief_for_scene(&escena("saludo", 4.0), &b, 7); let larga = brief_for_scene(&escena("saludo", 30.0), &b, 7); assert!(larga.bars > corta.bars); // La pieza cubre (o excede) la duración de la escena. let sec_per_bar = 4.0 * 60.0 / larga.tempo_bpm; assert!(larga.bars as f32 * sec_per_bar >= 30.0); } } /// Campana brillante para los acentos: triángulo agudo con decay corto, filtro /// abierto. Corta por encima de la pieza sin taparla. fn bell_patch() -> Patch { Patch { name: "Campana".into(), wave: Wave::Triangle, attack: 0.005, decay: 0.18, sustain: 0.0, release: 0.25, cutoff_hz: 6000.0, detune_cents: 0.0, gain: 0.7, } .sanitized() }