tawasuyu

tawasuyu/takiy

> takiy (Quechua: to sing). Music in Rust — synthesis, sequencing, playback — on Llimphi.

Rust 116Markdown 41TOML 192075 Definitions

⎇ 5802bf9ee1f162194bcbfdc797293f2d20d829ae 02_ruway/takiy/takiy-acordes/src/render.rs Rust · 394 lines · 14.5 KB History Who touched what raw

chore: refresco desde el monorepo (pin tawasuyu.git v0.3.0) 5802bf9ee · Sergio ·

1//! De la progresión a un [`Score`] — el momento en que la idea suena.
2//!
3//! Determinista: la misma progresión con los mismos patrones y ajustes da
4//! exactamente las mismas notas. Lo que sale es un `Score` normal y
5//! corriente, así que sigue de largo por lo que ya existe: se reproduce
6//! (`takiy-playback`), se edita nota por nota en el piano roll, se exporta
7//! a MIDI (`takiy-midi`) o a WAV.
8
9use serde::{Deserialize, Serialize};
10use takiy_core::{Pitch, Score, ScoreNote, Track};
11use takiy_genesis::rng::Rng;
12use takiy_genesis::{Brief, Style};
13
14use crate::banco::Banco;
15use crate::patron::{Direccion, Golpe, Patron};
16use crate::progresion::{Progresion, Ranura};
17
18/// Cómo se baja la progresión a notas.
19#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
20pub struct Ajustes {
21    pub bpm: f32,
22    /// Tiempos por compás — 4 para 4/4, 3 para un vals.
23    pub beats_por_compas: f32,
24    /// El patrón que usan las ranuras que no eligieron uno propio.
25    pub patron: usize,
26    /// Pista de bajo con la fundamental del acorde.
27    pub bajo: bool,
28    pub octava_bajo: i32,
29    /// Pista de batería. Sólo se agrega en compás de 4 tiempos — el groove
30    /// que reusamos de `takiy-genesis` está escrito en 4/4.
31    pub bateria: bool,
32    /// Estilo del groove de batería.
33    pub estilo: Style,
34    /// Semilla del jitter de la batería (el resto es determinista sin ella).
35    pub semilla: u64,
36    /// Swing `0.0..0.5`: cuánto se atrasa cada paso impar, en fracción de
37    /// paso. `0.0` = recto.
38    pub swing: f32,
39}
40
41impl Default for Ajustes {
42    fn default() -> Self {
43        Self {
44            bpm: 100.0,
45            beats_por_compas: 4.0,
46            patron: 2, // «Rasgueo pop»
47            bajo: true,
48            octava_bajo: 2,
49            bateria: false,
50            estilo: Style::Pop,
51            semilla: 1,
52            swing: 0.0,
53        }
54    }
55}
56
57/// Las alturas concretas de una ranura: el acorde en su octava, con la
58/// inversión aplicada (cada inversión sube la voz más grave una octava).
59pub fn voces_de(ranura: &Ranura) -> Vec<Pitch> {
60    let mut voces = ranura.acorde.voicing(ranura.octava);
61    if voces.is_empty() {
62        return voces;
63    }
64    for _ in 0..ranura.inversion.min(4) {
65        let grave = voces.remove(0);
66        match grave.transpose(12) {
67            Some(p) => voces.push(p),
68            None => {
69                voces.insert(0, grave);
70                break;
71            }
72        }
73    }
74    voces.sort_by_key(|p| p.midi());
75    voces
76}
77
78/// La progresión hecha `Score`.
79pub fn render(prog: &Progresion, banco: &Banco, aj: &Ajustes) -> Score {
80    let mut score = Score::new(aj.bpm);
81    let bpc = aj.beats_por_compas.max(1.0);
82
83    let mut acordes = Track::new("Acordes");
84    let mut bajo = Track::new("Bajo");
85
86    for (ranura, inicio_compas) in prog.ranuras().iter().zip(prog.inicios()) {
87        let inicio = inicio_compas * bpc;
88        let largo = ranura.compases.max(0.05) * bpc;
89        let patron = banco.get_o_primero(ranura.patron.unwrap_or(aj.patron));
90        tocar_ranura(&mut acordes, ranura, patron, inicio, largo, bpc, aj.swing);
91        if aj.bajo {
92            tocar_bajo(&mut bajo, ranura, inicio, largo, bpc);
93        }
94    }
95
96    score.add_track(acordes);
97    if aj.bajo && !bajo.is_empty() {
98        score.add_track(bajo);
99    }
100
101    // Batería: se reusa el groove de `takiy-genesis` (perfil por estilo,
102    // patrones euclidianos). Está escrito en 4/4, así que fuera de 4/4 no
103    // se agrega — antes que sonar corrida, no suena.
104    if aj.bateria && (bpc - 4.0).abs() < f32::EPSILON {
105        let compases = prog.compases().ceil().max(1.0) as usize;
106        let brief = Brief { bars: compases, style: aj.estilo, tempo_bpm: aj.bpm, ..Brief::default() };
107        let mut rng = Rng::new(aj.semilla);
108        let bateria = takiy_genesis::rhythm::drum_section(
109            &brief, compases, 0, 1.0, false, false, false, &mut rng,
110        );
111        if !bateria.is_empty() {
112            score.add_track(bateria);
113        }
114    }
115
116    score
117}
118
119/// Un acorde tocado con su patrón, desde `inicio` y por `largo` beats.
120fn tocar_ranura(
121    track: &mut Track,
122    ranura: &Ranura,
123    patron: &Patron,
124    inicio: f32,
125    largo: f32,
126    bpc: f32,
127    swing: f32,
128) {
129    let voces = voces_de(ranura);
130    if voces.is_empty() {
131        return;
132    }
133    let n = patron.pasos_por_compas();
134    let paso_beats = bpc / n as f32;
135    // Cuántos pasos entran en la ranura (una ranura de medio compás toca
136    // la primera mitad del patrón, no el patrón entero apretado).
137    let total = (largo / paso_beats).round().max(1.0) as usize;
138    let fin = inicio + largo;
139
140    for j in 0..total {
141        let Some(paso) = patron.paso(j % n) else { continue };
142        if !paso.golpe.suena() {
143            continue;
144        }
145        let atraso = if j % 2 == 1 { swing.clamp(0.0, 0.49) * paso_beats } else { 0.0 };
146        let t = inicio + j as f32 * paso_beats + atraso;
147        if t >= fin {
148            break;
149        }
150        // Nada suena después del cambio de acorde: la ligadura se corta.
151        let dur = (paso.largo * paso_beats).min(fin - t).max(0.05);
152
153        match paso.golpe {
154            Golpe::Silencio => {}
155            Golpe::Fundamental => {
156                track.add(ScoreNote::new(voces[0], t, dur, paso.velocidad));
157            }
158            Golpe::Voz(i) => {
159                let v = voces[(i as usize) % voces.len()];
160                track.add(ScoreNote::new(v, t, dur, paso.velocidad));
161            }
162            Golpe::Rasgueo(dir) => {
163                let sep = patron.ancho_rasgueo.max(0.0) * paso_beats;
164                for (k, &v) in voces.iter().enumerate() {
165                    // Hacia abajo la púa entra por la voz grave; hacia
166                    // arriba, por la aguda.
167                    let orden = match dir {
168                        Direccion::Abajo => k,
169                        Direccion::Arriba => voces.len() - 1 - k,
170                    };
171                    let t_v = t + orden as f32 * sep;
172                    if t_v >= fin {
173                        continue;
174                    }
175                    // Las voces que entran después pierden un poco de
176                    // fuerza: es lo que hace que un rasgueo no suene a
177                    // acorde de máquina.
178                    let vel = (paso.velocidad as i32 - 3 * orden as i32).clamp(1, 127) as u8;
179                    let dur_v = (dur - (t_v - t)).max(0.05);
180                    track.add(ScoreNote::new(v, t_v, dur_v, vel));
181                }
182            }
183        }
184    }
185}
186
187/// Bajo: la fundamental en el 1 y la quinta a mitad de compás cuando la
188/// ranura dura un compás entero o más.
189fn tocar_bajo(track: &mut Track, ranura: &Ranura, inicio: f32, largo: f32, bpc: f32) {
190    let voces = voces_de(ranura);
191    let Some(root) = Pitch::from_class_octave(ranura.acorde.root, ranura.octava - 2)
192        .or_else(|| voces.first().copied())
193    else {
194        return;
195    };
196    if largo >= bpc {
197        let mitad = largo / 2.0;
198        track.add(ScoreNote::new(root, inicio, mitad.min(bpc / 2.0), 96));
199        let quinta = root.transpose(7).unwrap_or(root);
200        track.add(ScoreNote::new(quinta, inicio + mitad, largo - mitad, 84));
201    } else {
202        track.add(ScoreNote::new(root, inicio, largo, 96));
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use crate::paleta::{Paleta, TipoTonalidad};
210    use crate::patron::{Direccion, Golpe, Paso};
211    use takiy_core::{Chord, ChordQuality, PitchClass};
212
213    fn do_mayor() -> Paleta {
214        Paleta::nueva(PitchClass::C, TipoTonalidad::Mayor)
215    }
216
217    fn ajustes() -> Ajustes {
218        Ajustes { bajo: false, bateria: false, ..Ajustes::default() }
219    }
220
221    #[test]
222    fn el_render_es_determinista() {
223        let p = Progresion::cadencia(&do_mayor(), 0);
224        let b = Banco::nuevo();
225        let a = Ajustes { bajo: true, bateria: true, ..Ajustes::default() };
226        let uno = render(&p, &b, &a);
227        let dos = render(&p, &b, &a);
228        assert_eq!(uno.tracks().len(), dos.tracks().len());
229        for (t1, t2) in uno.tracks().iter().zip(dos.tracks()) {
230            assert_eq!(t1.notes(), t2.notes(), "pista «{}»", t1.name);
231        }
232    }
233
234    #[test]
235    fn cada_acorde_cae_en_su_compas() {
236        let p = Progresion::cadencia(&do_mayor(), 0); // C G Am F
237        let mut b = Banco::nuevo();
238        // Patrón de un solo golpe al 1 para que la cuenta sea limpia.
239        let solo = b.agregar(Patron::desde(
240            "uno",
241            0.0,
242            vec![Paso::nuevo(Golpe::Fundamental, 100, 4.0)],
243        ));
244        let score = render(&p, &b, &Ajustes { patron: solo, ..ajustes() });
245        let notas = score.track(0).unwrap().notes();
246        assert_eq!(notas.len(), 4);
247        let inicios: Vec<f32> = notas.iter().map(|n| n.start).collect();
248        assert_eq!(inicios, vec![0.0, 4.0, 8.0, 12.0]);
249        let clases: Vec<_> = notas.iter().map(|n| n.pitch.class()).collect();
250        assert_eq!(
251            clases,
252            vec![PitchClass::C, PitchClass::G, PitchClass::A, PitchClass::F]
253        );
254    }
255
256    #[test]
257    fn el_rasgueo_abre_las_voces_en_el_tiempo() {
258        let mut prog = Progresion::nueva();
259        prog.agregar(Chord::new(PitchClass::C, ChordQuality::Major));
260        let mut b = Banco::nuevo();
261        let i = b.agregar(Patron::desde(
262            "rasgueo",
263            0.25,
264            vec![Paso::nuevo(Golpe::Rasgueo(Direccion::Abajo), 100, 4.0)],
265        ));
266        let score = render(&prog, &b, &Ajustes { patron: i, ..ajustes() });
267        let notas = score.track(0).unwrap().notes();
268        assert_eq!(notas.len(), 3, "las tres voces de la tríada");
269        // Grave primero, y separadas: es un rasgueo, no un bloque.
270        assert!(notas[0].start < notas[1].start && notas[1].start < notas[2].start);
271        assert!(notas[0].pitch.midi() < notas[1].pitch.midi());
272        // Y hacia arriba entra primero la aguda.
273        let j = b.agregar(Patron::desde(
274            "arriba",
275            0.25,
276            vec![Paso::nuevo(Golpe::Rasgueo(Direccion::Arriba), 100, 4.0)],
277        ));
278        let score = render(&prog, &b, &Ajustes { patron: j, ..ajustes() });
279        let notas = score.track(0).unwrap().notes();
280        let primera = notas.iter().min_by(|a, b| a.start.total_cmp(&b.start)).unwrap();
281        let aguda = notas.iter().max_by_key(|n| n.pitch.midi()).unwrap();
282        assert_eq!(primera.pitch, aguda.pitch);
283    }
284
285    #[test]
286    fn ninguna_nota_pisa_el_acorde_siguiente() {
287        let p = Progresion::cadencia(&do_mayor(), 0);
288        let b = Banco::nuevo();
289        // «Redonda» deja sonar el compás entero: el corte tiene que ser exacto.
290        let score = render(&p, &b, &Ajustes { patron: 0, ..ajustes() });
291        for n in score.track(0).unwrap().notes() {
292            let compas = (n.start / 4.0).floor();
293            assert!(
294                n.end() <= (compas + 1.0) * 4.0 + 1e-3,
295                "una nota de {} se estiró hasta {}",
296                n.start,
297                n.end()
298            );
299        }
300    }
301
302    #[test]
303    fn una_ranura_de_medio_compas_toca_media_grilla() {
304        let mut prog = Progresion::nueva();
305        prog.agregar(Chord::new(PitchClass::C, ChordQuality::Major));
306        prog.ranura_mut(0).unwrap().compases = 0.5;
307        let mut b = Banco::nuevo();
308        // Ocho pasos de una nota cada uno: en medio compás tienen que
309        // sonar los primeros cuatro, no los ocho apretados.
310        let i = b.agregar(Patron::desde(
311            "ocho",
312            0.0,
313            (0..8).map(|_| Paso::nuevo(Golpe::Fundamental, 90, 1.0)).collect(),
314        ));
315        let score = render(&prog, &b, &Ajustes { patron: i, ..ajustes() });
316        let inicios: Vec<f32> = score
317            .track(0)
318            .unwrap()
319            .notes()
320            .iter()
321            .map(|n| n.start)
322            .collect();
323        assert_eq!(inicios, vec![0.0, 0.5, 1.0, 1.5]);
324    }
325
326    #[test]
327    fn la_inversion_sube_la_voz_grave_una_octava() {
328        let mut r = Ranura::nueva(Chord::new(PitchClass::C, ChordQuality::Major));
329        let fundamental = voces_de(&r);
330        r.inversion = 1;
331        let primera = voces_de(&r);
332        assert_eq!(fundamental.len(), primera.len());
333        assert_eq!(primera[0].class(), PitchClass::E, "ahora abajo está la tercera");
334        assert_eq!(
335            primera.last().unwrap().midi(),
336            fundamental[0].midi() + 12,
337            "la fundamental subió una octava"
338        );
339    }
340
341    #[test]
342    fn el_swing_atrasa_solo_los_pasos_impares() {
343        let mut prog = Progresion::nueva();
344        prog.agregar(Chord::new(PitchClass::C, ChordQuality::Major));
345        let mut b = Banco::nuevo();
346        let i = b.agregar(Patron::desde(
347            "ocho",
348            0.0,
349            (0..8).map(|_| Paso::nuevo(Golpe::Fundamental, 90, 1.0)).collect(),
350        ));
351        let recto = render(&prog, &b, &Ajustes { patron: i, ..ajustes() });
352        let swing = render(&prog, &b, &Ajustes { patron: i, swing: 0.3, ..ajustes() });
353        let a: Vec<f32> = recto.track(0).unwrap().notes().iter().map(|n| n.start).collect();
354        let s: Vec<f32> = swing.track(0).unwrap().notes().iter().map(|n| n.start).collect();
355        assert_eq!(a.len(), s.len());
356        for (k, (x, y)) in a.iter().zip(&s).enumerate() {
357            if k % 2 == 0 {
358                assert_eq!(x, y, "el paso {k} es a tiempo");
359            } else {
360                assert!(y > x, "el paso {k} tiene que atrasarse");
361            }
362        }
363    }
364
365    #[test]
366    fn el_bajo_y_la_bateria_entran_como_pistas_aparte() {
367        let p = Progresion::cadencia(&do_mayor(), 0);
368        let b = Banco::nuevo();
369        let score = render(&p, &b, &Ajustes { bajo: true, bateria: true, ..Ajustes::default() });
370        let nombres: Vec<&str> = score.tracks().iter().map(|t| t.name.as_str()).collect();
371        assert_eq!(nombres, vec!["Acordes", "Bajo", "Batería"]);
372        assert!(!score.track(1).unwrap().is_empty());
373        assert!(!score.track(2).unwrap().is_empty());
374    }
375
376    #[test]
377    fn en_vals_no_hay_bateria_de_cuatro_cuartos() {
378        let p = Progresion::cadencia(&do_mayor(), 0);
379        let b = Banco::nuevo();
380        let score = render(
381            &p,
382            &b,
383            &Ajustes { beats_por_compas: 3.0, bateria: true, bajo: false, ..Ajustes::default() },
384        );
385        let nombres: Vec<&str> = score.tracks().iter().map(|t| t.name.as_str()).collect();
386        assert_eq!(nombres, vec!["Acordes"]);
387    }
388
389    #[test]
390    fn una_progresion_vacia_da_un_score_sin_notas() {
391        let score = render(&Progresion::nueva(), &Banco::nuevo(), &Ajustes::default());
392        assert!(score.tracks().iter().all(|t| t.is_empty()));
393    }
394}