diff --git a/Cargo.lock b/Cargo.lock index a6493f5..159c4ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12250,6 +12250,13 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8bdb6fa0dfa67b38c1e66b7041ba9dcf23b99d8121907cd31c807a332f7a0bbb" +[[package]] +name = "takiy-core" +version = "0.1.0" +dependencies = [ + "serde", +] + [[package]] name = "tao-core-video-sys" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index fa203a9..1fa99a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -131,6 +131,11 @@ members = [ "crates/modules/badu/badu-core", "crates/modules/badu/badu-gravity", + # ============================================================ + # modules/takiy/ — Composición musical asistida + # ============================================================ + "crates/modules/takiy/takiy-core", + # ============================================================ # modules/nakui/ — ERP matemático (categórico) # ============================================================ diff --git a/crates/modules/takiy/SDD.md b/crates/modules/takiy/SDD.md new file mode 100644 index 0000000..8064a48 --- /dev/null +++ b/crates/modules/takiy/SDD.md @@ -0,0 +1,43 @@ +# modules/takiy/ — Composición musical asistida + +**Propósito.** Herramienta de composición: un modelo de partitura sobre +teoría musical, con asistencia por IA y síntesis de audio. El núcleo es +agnóstico — el tiempo se mide en pulsos, no en segundos, y no conoce ni +audio ni UI. + +## Crates + +| crate | tipo | rol | +| ------------- | ---- | ------------------------------------------------------------ | +| `takiy-core` | lib | `Pitch`/`PitchClass`, `Scale`, `Chord`/`ChordQuality`, `Score`/`Track`/`ScoreNote` | + +## Modelo + +```text + PitchClass + octava ──► Pitch (MIDI, frecuencia ET A4=440) + │ + Scale (raíz + patrón) Chord (raíz + cualidad) + │ + ScoreNote (pulso, duración, velocidad) ──► Track ──► Score (tempo) +``` + +- **Altura**: número MIDI interno; clase, octava y frecuencia se derivan. +- **Tiempo en pulsos**: una partitura es independiente del tempo hasta + reproducirla (`Score::duration_seconds` aplica el bpm). +- **Transposición atómica**: si una nota se saldría del rango MIDI, la + pista entera no se altera. + +## Dependencias + +- `takiy-core` ← sólo `serde`. `#![forbid(unsafe_code)]`, determinista. + +## Estado + +`takiy-core` implementado y verde (24 tests). **Pendiente** (requieren +infra pesada, no verificables en modo desatendido): + +| crate pendiente | rol | +| --------------- | ---------------------------------------------- | +| `takiy-synth` | síntesis de audio (`fundsp`) | +| `takiy-ai` | asistencia de composición (`ort`, instancia) | +| `takiy-canvas` | piano-roll / partitura visual | diff --git a/crates/modules/takiy/takiy-core/Cargo.toml b/crates/modules/takiy/takiy-core/Cargo.toml new file mode 100644 index 0000000..7cd9d25 --- /dev/null +++ b/crates/modules/takiy/takiy-core/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "takiy-core" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "takiy — teoría musical y modelo de partitura: pitch MIDI, escalas, acordes y un Score multipista con tempo. Agnóstico de síntesis y de UI." + +[dependencies] +serde = { workspace = true } diff --git a/crates/modules/takiy/takiy-core/src/chord.rs b/crates/modules/takiy/takiy-core/src/chord.rs new file mode 100644 index 0000000..2f967bf --- /dev/null +++ b/crates/modules/takiy/takiy-core/src/chord.rs @@ -0,0 +1,127 @@ +//! Acordes — una raíz y una cualidad armónica. + +use serde::{Deserialize, Serialize}; + +use crate::pitch::{Pitch, PitchClass}; + +/// Cualidad de un acorde — define su patrón de intervalos. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ChordQuality { + Major, + Minor, + Diminished, + Augmented, + Major7, + Minor7, + Dominant7, +} + +impl ChordQuality { + /// Intervalos en semitonos desde la raíz. + pub fn intervals(self) -> &'static [u8] { + match self { + ChordQuality::Major => &[0, 4, 7], + ChordQuality::Minor => &[0, 3, 7], + ChordQuality::Diminished => &[0, 3, 6], + ChordQuality::Augmented => &[0, 4, 8], + ChordQuality::Major7 => &[0, 4, 7, 11], + ChordQuality::Minor7 => &[0, 3, 7, 10], + ChordQuality::Dominant7 => &[0, 4, 7, 10], + } + } + + /// Sufijo legible — `""`, `"m"`, `"dim"`, `"maj7"`, … + pub fn suffix(self) -> &'static str { + match self { + ChordQuality::Major => "", + ChordQuality::Minor => "m", + ChordQuality::Diminished => "dim", + ChordQuality::Augmented => "aug", + ChordQuality::Major7 => "maj7", + ChordQuality::Minor7 => "m7", + ChordQuality::Dominant7 => "7", + } + } +} + +/// Un acorde: clase raíz + cualidad. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct Chord { + pub root: PitchClass, + pub quality: ChordQuality, +} + +impl Chord { + pub fn new(root: PitchClass, quality: ChordQuality) -> Self { + Self { root, quality } + } + + /// Cantidad de notas del acorde. + pub fn voice_count(self) -> usize { + self.quality.intervals().len() + } + + /// Las alturas concretas del acorde con la raíz en `root_octave`, + /// en posición fundamental. Las voces que se salgan del rango MIDI + /// se omiten. + pub fn voicing(self, root_octave: i32) -> Vec { + let Some(base) = Pitch::from_class_octave(self.root, root_octave) else { + return Vec::new(); + }; + self.quality + .intervals() + .iter() + .filter_map(|&iv| base.transpose(iv as i32)) + .collect() + } + + /// `true` si la clase de `pitch` es una nota del acorde. + pub fn contains(self, pitch: Pitch) -> bool { + let rel = (pitch.class().semitone() + 12 - self.root.semitone()) % 12; + self.quality.intervals().iter().any(|&iv| iv % 12 == rel) + } + + /// Nombre legible — `"C"`, `"Am"`, `"G7"`, `"Dmaj7"`, … + pub fn name(self) -> String { + format!("{}{}", self.root.name(), self.quality.suffix()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn c_major_triad_is_c_e_g() { + let voices = Chord::new(PitchClass::C, ChordQuality::Major).voicing(4); + let classes: Vec<_> = voices.iter().map(|p| p.class()).collect(); + assert_eq!(classes, vec![PitchClass::C, PitchClass::E, PitchClass::G]); + } + + #[test] + fn a_minor_triad_is_a_c_e() { + let voices = Chord::new(PitchClass::A, ChordQuality::Minor).voicing(3); + let classes: Vec<_> = voices.iter().map(|p| p.class()).collect(); + assert_eq!(classes, vec![PitchClass::A, PitchClass::C, PitchClass::E]); + } + + #[test] + fn seventh_chords_have_four_voices() { + assert_eq!(Chord::new(PitchClass::G, ChordQuality::Dominant7).voice_count(), 4); + } + + #[test] + fn contains_recognizes_chord_tones() { + let g7 = Chord::new(PitchClass::G, ChordQuality::Dominant7); + // G7 = G B D F + assert!(g7.contains(Pitch::from_class_octave(PitchClass::F, 5).unwrap())); + assert!(!g7.contains(Pitch::from_class_octave(PitchClass::E, 5).unwrap())); + } + + #[test] + fn names_render_correctly() { + assert_eq!(Chord::new(PitchClass::C, ChordQuality::Major).name(), "C"); + assert_eq!(Chord::new(PitchClass::A, ChordQuality::Minor).name(), "Am"); + assert_eq!(Chord::new(PitchClass::D, ChordQuality::Major7).name(), "Dmaj7"); + } +} diff --git a/crates/modules/takiy/takiy-core/src/lib.rs b/crates/modules/takiy/takiy-core/src/lib.rs new file mode 100644 index 0000000..f5a20c6 --- /dev/null +++ b/crates/modules/takiy/takiy-core/src/lib.rs @@ -0,0 +1,26 @@ +//! `takiy-core` — teoría musical y modelo de partitura. +//! +//! La base agnóstica de takiy (composición musical asistida): nada de +//! síntesis de audio, nada de IA, nada de UI — sólo los tipos puros que +//! todo lo demás comparte. +//! +//! - [`pitch`] — alturas MIDI, clases de altura, frecuencias. +//! - [`scale`] — escalas como raíz + patrón de semitonos. +//! - [`chord`] — acordes como raíz + cualidad armónica. +//! - [`score`] — `ScoreNote`, `Track` y un `Score` multipista con tempo. +//! +//! El tiempo se mide en pulsos: una partitura es independiente del tempo +//! hasta reproducirla. La síntesis (`takiy-synth`) y la asistencia por +//! IA (`takiy-ai`) se construyen encima sin tocar este crate. + +#![forbid(unsafe_code)] + +pub mod chord; +pub mod pitch; +pub mod scale; +pub mod score; + +pub use chord::{Chord, ChordQuality}; +pub use pitch::{Pitch, PitchClass}; +pub use scale::Scale; +pub use score::{Score, ScoreNote, Track}; diff --git a/crates/modules/takiy/takiy-core/src/pitch.rs b/crates/modules/takiy/takiy-core/src/pitch.rs new file mode 100644 index 0000000..f4e24a8 --- /dev/null +++ b/crates/modules/takiy/takiy-core/src/pitch.rs @@ -0,0 +1,147 @@ +//! Alturas — clases de altura y notas MIDI. +//! +//! La altura interna es un número MIDI (`0..=127`); MIDI 69 es A4 a +//! 440 Hz, MIDI 60 es el do central. Todo lo demás —clase, octava, +//! frecuencia— se deriva de ahí. + +use serde::{Deserialize, Serialize}; + +/// Las doce clases de altura del temperamento igual. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum PitchClass { + C, + Cs, + D, + Ds, + E, + F, + Fs, + G, + Gs, + A, + As, + B, +} + +impl PitchClass { + /// Semitono dentro de la octava (`C = 0 … B = 11`). + pub fn semitone(self) -> u8 { + self as u8 + } + + /// Clase de altura desde un semitono — toma `semitone % 12`. + pub fn from_semitone(semitone: u8) -> PitchClass { + use PitchClass::*; + const ALL: [PitchClass; 12] = + [C, Cs, D, Ds, E, F, Fs, G, Gs, A, As, B]; + ALL[(semitone % 12) as usize] + } + + /// Nombre con sostenidos — `"C"`, `"C#"`, `"D"`, … + pub fn name(self) -> &'static str { + ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"] + [self.semitone() as usize] + } +} + +/// Una altura concreta: número de nota MIDI, `0..=127`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct Pitch(u8); + +impl Pitch { + /// Do central (MIDI 60). + pub const MIDDLE_C: Pitch = Pitch(60); + /// La de afinación, A4 (MIDI 69, 440 Hz). + pub const A4: Pitch = Pitch(69); + + /// Construye desde un número MIDI; `None` si excede `127`. + pub fn from_midi(midi: u8) -> Option { + (midi <= 127).then_some(Pitch(midi)) + } + + /// Construye desde clase + octava (convención científica: la octava + /// del do central es 4). `None` si cae fuera del rango MIDI. + pub fn from_class_octave(class: PitchClass, octave: i32) -> Option { + let midi = (octave + 1) * 12 + class.semitone() as i32; + (0..=127).contains(&midi).then_some(Pitch(midi as u8)) + } + + /// Número de nota MIDI. + pub fn midi(self) -> u8 { + self.0 + } + + /// Clase de altura. + pub fn class(self) -> PitchClass { + PitchClass::from_semitone(self.0 % 12) + } + + /// Octava en convención científica (do central → 4). + pub fn octave(self) -> i32 { + self.0 as i32 / 12 - 1 + } + + /// Transpone por `semitones`; `None` si sale del rango MIDI. + pub fn transpose(self, semitones: i32) -> Option { + let midi = self.0 as i32 + semitones; + (0..=127).contains(&midi).then_some(Pitch(midi as u8)) + } + + /// Frecuencia en Hz bajo temperamento igual con A4 = 440 Hz. + pub fn frequency(self) -> f32 { + 440.0 * 2.0f32.powf((self.0 as f32 - 69.0) / 12.0) + } + + /// Nombre legible — `"C4"`, `"F#5"`, … + pub fn name(self) -> String { + format!("{}{}", self.class().name(), self.octave()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn middle_c_is_c4() { + assert_eq!(Pitch::MIDDLE_C.class(), PitchClass::C); + assert_eq!(Pitch::MIDDLE_C.octave(), 4); + assert_eq!(Pitch::MIDDLE_C.name(), "C4"); + } + + #[test] + fn a4_is_440_hz() { + assert!((Pitch::A4.frequency() - 440.0).abs() < 1e-3); + } + + #[test] + fn octave_up_doubles_frequency() { + let a5 = Pitch::A4.transpose(12).unwrap(); + assert!((a5.frequency() - 880.0).abs() < 1e-2); + } + + #[test] + fn class_octave_roundtrips() { + let p = Pitch::from_class_octave(PitchClass::Fs, 5).unwrap(); + assert_eq!(p.class(), PitchClass::Fs); + assert_eq!(p.octave(), 5); + assert_eq!(p.name(), "F#5"); + } + + #[test] + fn transpose_past_range_fails() { + assert!(Pitch::from_midi(125).unwrap().transpose(10).is_none()); + assert!(Pitch::from_midi(2).unwrap().transpose(-10).is_none()); + } + + #[test] + fn from_midi_rejects_out_of_range() { + assert!(Pitch::from_midi(128).is_none()); + assert!(Pitch::from_midi(127).is_some()); + } + + #[test] + fn semitone_wraps() { + assert_eq!(PitchClass::from_semitone(13), PitchClass::Cs); + } +} diff --git a/crates/modules/takiy/takiy-core/src/scale.rs b/crates/modules/takiy/takiy-core/src/scale.rs new file mode 100644 index 0000000..2fa7975 --- /dev/null +++ b/crates/modules/takiy/takiy-core/src/scale.rs @@ -0,0 +1,119 @@ +//! Escalas — una raíz y un patrón de semitonos. + +use serde::{Deserialize, Serialize}; + +use crate::pitch::{Pitch, PitchClass}; + +/// Una escala: clase raíz + offsets en semitonos desde la raíz. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Scale { + root: PitchClass, + /// Semitonos desde la raíz, ascendentes, dentro de una octava. + intervals: Vec, +} + +impl Scale { + /// Escala arbitraria desde su patrón de intervalos. + pub fn new(root: PitchClass, intervals: Vec) -> Self { + Self { root, intervals } + } + + /// Escala mayor (jónica): `T-T-S-T-T-T-S`. + pub fn major(root: PitchClass) -> Self { + Self::new(root, vec![0, 2, 4, 5, 7, 9, 11]) + } + + /// Escala menor natural (eólica). + pub fn natural_minor(root: PitchClass) -> Self { + Self::new(root, vec![0, 2, 3, 5, 7, 8, 10]) + } + + /// Escala pentatónica mayor. + pub fn pentatonic_major(root: PitchClass) -> Self { + Self::new(root, vec![0, 2, 4, 7, 9]) + } + + /// Clase raíz. + pub fn root(&self) -> PitchClass { + self.root + } + + /// Cantidad de grados de la escala. + pub fn degree_count(&self) -> usize { + self.intervals.len() + } + + /// Clase de altura del grado `degree` (0-indexado, módulo el largo). + pub fn degree(&self, degree: usize) -> PitchClass { + let iv = self.intervals[degree % self.intervals.len()]; + PitchClass::from_semitone(self.root.semitone() + iv) + } + + /// `true` si la clase de `pitch` pertenece a la escala. + pub fn contains(&self, pitch: Pitch) -> bool { + let rel = (pitch.class().semitone() + 12 - self.root.semitone()) % 12; + self.intervals.contains(&rel) + } + + /// Las alturas de la escala en la `octave` dada, un grado por + /// elemento. Las que se salgan del rango MIDI se omiten. + pub fn pitches_in_octave(&self, octave: i32) -> Vec { + self.intervals + .iter() + .filter_map(|&iv| { + Pitch::from_class_octave(self.root, octave) + .and_then(|p| p.transpose(iv as i32)) + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn c_major_has_no_accidentals() { + let s = Scale::major(PitchClass::C); + for pc in [ + PitchClass::C, + PitchClass::D, + PitchClass::E, + PitchClass::F, + PitchClass::G, + PitchClass::A, + PitchClass::B, + ] { + assert!(s.contains(Pitch::from_class_octave(pc, 4).unwrap())); + } + // F# no está en do mayor. + assert!(!s.contains(Pitch::from_class_octave(PitchClass::Fs, 4).unwrap())); + } + + #[test] + fn degrees_of_a_minor() { + let s = Scale::natural_minor(PitchClass::A); + assert_eq!(s.degree(0), PitchClass::A); + assert_eq!(s.degree(2), PitchClass::C); + // El grado envuelve. + assert_eq!(s.degree(7), PitchClass::A); + } + + #[test] + fn pitches_in_octave_count_matches_degrees() { + let s = Scale::major(PitchClass::G); + assert_eq!(s.pitches_in_octave(4).len(), 7); + } + + #[test] + fn pentatonic_has_five_degrees() { + assert_eq!(Scale::pentatonic_major(PitchClass::D).degree_count(), 5); + } + + #[test] + fn contains_is_octave_agnostic() { + let s = Scale::major(PitchClass::C); + assert!(s.contains(Pitch::from_class_octave(PitchClass::E, 2).unwrap())); + assert!(s.contains(Pitch::from_class_octave(PitchClass::E, 7).unwrap())); + } +} diff --git a/crates/modules/takiy/takiy-core/src/score.rs b/crates/modules/takiy/takiy-core/src/score.rs new file mode 100644 index 0000000..262cc59 --- /dev/null +++ b/crates/modules/takiy/takiy-core/src/score.rs @@ -0,0 +1,221 @@ +//! El modelo de partitura — notas, pistas y un `Score` con tempo. +//! +//! El tiempo se mide en *pulsos* (beats), no en segundos: una partitura +//! es independiente del tempo hasta que se la reproduce. La conversión a +//! segundos vive en [`Score::duration_seconds`]. + +use serde::{Deserialize, Serialize}; + +use crate::pitch::Pitch; + +/// Una nota dentro de una pista: altura, inicio y duración en pulsos, +/// y velocidad (intensidad MIDI). +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct ScoreNote { + pub pitch: Pitch, + /// Pulso de inicio. + pub start: f32, + /// Duración en pulsos. + pub duration: f32, + /// Intensidad `0..=127`. + pub velocity: u8, +} + +impl ScoreNote { + /// Crea una nota; la velocidad se acota a `127`. + pub fn new(pitch: Pitch, start: f32, duration: f32, velocity: u8) -> Self { + Self { pitch, start, duration, velocity: velocity.min(127) } + } + + /// Pulso en que la nota termina. + pub fn end(self) -> f32 { + self.start + self.duration + } + + /// `true` si la nota está sonando en el pulso `beat`. + pub fn sounds_at(self, beat: f32) -> bool { + beat >= self.start && beat < self.end() + } +} + +/// Una pista monofónica o polifónica: notas ordenadas por inicio. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct Track { + pub name: String, + notes: Vec, +} + +impl Track { + pub fn new(name: impl Into) -> Self { + Self { name: name.into(), notes: Vec::new() } + } + + /// Inserta una nota manteniendo el orden por pulso de inicio. + pub fn add(&mut self, note: ScoreNote) { + let pos = self + .notes + .partition_point(|n| n.start <= note.start); + self.notes.insert(pos, note); + } + + /// Notas de la pista, ordenadas por inicio. + pub fn notes(&self) -> &[ScoreNote] { + &self.notes + } + + pub fn len(&self) -> usize { + self.notes.len() + } + + pub fn is_empty(&self) -> bool { + self.notes.is_empty() + } + + /// Pulso en que termina la última nota (0 si la pista está vacía). + pub fn duration(&self) -> f32 { + self.notes.iter().map(|n| n.end()).fold(0.0, f32::max) + } + + /// Notas que suenan en el pulso `beat`. + pub fn notes_at(&self, beat: f32) -> Vec<&ScoreNote> { + self.notes.iter().filter(|n| n.sounds_at(beat)).collect() + } + + /// Transpone la pista entera. Es atómico: si alguna nota se saldría + /// del rango MIDI, no se cambia nada y devuelve `false`. + pub fn transpose(&mut self, semitones: i32) -> bool { + if self.notes.iter().any(|n| n.pitch.transpose(semitones).is_none()) { + return false; + } + for n in &mut self.notes { + n.pitch = n.pitch.transpose(semitones).expect("ya verificado"); + } + true + } +} + +/// Una partitura: un tempo y varias pistas. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Score { + /// Pulsos por minuto. + pub tempo_bpm: f32, + tracks: Vec, +} + +impl Score { + /// Partitura vacía con el tempo dado. + pub fn new(tempo_bpm: f32) -> Self { + Self { tempo_bpm, tracks: Vec::new() } + } + + /// Añade una pista y devuelve su índice. + pub fn add_track(&mut self, track: Track) -> usize { + self.tracks.push(track); + self.tracks.len() - 1 + } + + pub fn track(&self, index: usize) -> Option<&Track> { + self.tracks.get(index) + } + + pub fn track_mut(&mut self, index: usize) -> Option<&mut Track> { + self.tracks.get_mut(index) + } + + pub fn tracks(&self) -> &[Track] { + &self.tracks + } + + /// Duración en pulsos — la pista más larga. + pub fn duration_beats(&self) -> f32 { + self.tracks.iter().map(|t| t.duration()).fold(0.0, f32::max) + } + + /// Duración en segundos según el tempo. + pub fn duration_seconds(&self) -> f32 { + if self.tempo_bpm <= 0.0 { + return 0.0; + } + self.duration_beats() * 60.0 / self.tempo_bpm + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pitch::{Pitch, PitchClass}; + + fn note(class: PitchClass, start: f32) -> ScoreNote { + ScoreNote::new(Pitch::from_class_octave(class, 4).unwrap(), start, 1.0, 100) + } + + #[test] + fn add_keeps_notes_sorted_by_start() { + let mut t = Track::new("melodía"); + t.add(note(PitchClass::E, 2.0)); + t.add(note(PitchClass::C, 0.0)); + t.add(note(PitchClass::D, 1.0)); + let starts: Vec = t.notes().iter().map(|n| n.start).collect(); + assert_eq!(starts, vec![0.0, 1.0, 2.0]); + } + + #[test] + fn duration_is_end_of_last_note() { + let mut t = Track::new("x"); + t.add(note(PitchClass::C, 0.0)); + t.add(note(PitchClass::G, 3.0)); // termina en 4.0 + assert_eq!(t.duration(), 4.0); + } + + #[test] + fn notes_at_finds_sounding_notes() { + let mut t = Track::new("x"); + t.add(ScoreNote::new(Pitch::MIDDLE_C, 0.0, 2.0, 80)); + t.add(ScoreNote::new(Pitch::A4, 1.0, 2.0, 80)); + // En el pulso 1.5 ambas suenan; en 2.5 sólo la segunda. + assert_eq!(t.notes_at(1.5).len(), 2); + assert_eq!(t.notes_at(2.5).len(), 1); + assert_eq!(t.notes_at(5.0).len(), 0); + } + + #[test] + fn transpose_is_atomic_on_overflow() { + let mut t = Track::new("x"); + t.add(ScoreNote::new(Pitch::from_midi(120).unwrap(), 0.0, 1.0, 80)); + // +10 sacaría la nota del rango → no cambia nada. + assert!(!t.transpose(10)); + assert_eq!(t.notes()[0].pitch.midi(), 120); + // +5 sí cabe. + assert!(t.transpose(5)); + assert_eq!(t.notes()[0].pitch.midi(), 125); + } + + #[test] + fn velocity_is_clamped() { + let n = ScoreNote::new(Pitch::MIDDLE_C, 0.0, 1.0, 200); + assert_eq!(n.velocity, 127); + } + + #[test] + fn score_duration_in_seconds_follows_tempo() { + let mut s = Score::new(120.0); // 120 bpm → 2 pulsos por segundo + let mut t = Track::new("x"); + t.add(ScoreNote::new(Pitch::MIDDLE_C, 0.0, 8.0, 100)); + s.add_track(t); + assert_eq!(s.duration_beats(), 8.0); + // 8 pulsos a 120 bpm = 4 segundos. + assert!((s.duration_seconds() - 4.0).abs() < 1e-4); + } + + #[test] + fn score_duration_is_the_longest_track() { + let mut s = Score::new(100.0); + let mut a = Track::new("a"); + a.add(ScoreNote::new(Pitch::MIDDLE_C, 0.0, 2.0, 90)); + let mut b = Track::new("b"); + b.add(ScoreNote::new(Pitch::A4, 0.0, 6.0, 90)); + s.add_track(a); + s.add_track(b); + assert_eq!(s.duration_beats(), 6.0); + } +}