tawasuyu

tawasuyu/brahman

Rust 572TOML 217Markdown 68JSON 198326 Sutichaykuna

feat(takiy): takiy-core — teoría musical + modelo de partitura

Pitch MIDI (clase/octava/frecuencia ET A4=440), Scale (raíz + patrón
de semitonos: mayor, menor natural, pentatónica), Chord (7 cualidades,
voicing, nombres) y un Score multipista con tempo: ScoreNote en
pulsos, Track con inserción ordenada y transposición atómica.

24 tests. Agnóstico de síntesis y UI, #![forbid(unsafe_code)].

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

639381fd945282fff45ce27de529c1473d7500ef · ruwaq sergio · (5 killa ñawpaq) · tayta ea079a0b2 · kay commitpi sach'ata qhaway

Semántico sayk'uy · 89

crates/modules/takiy/takiy-core/src/chord.rs

crates/modules/takiy/takiy-core/src/lib.rs

crates/modules/takiy/takiy-core/src/pitch.rs

crates/modules/takiy/takiy-core/src/scale.rs

crates/modules/takiy/takiy-core/src/score.rs

9 qillqakuna · +706 −0

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<Pitch> {
+        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<Pitch> {
+        (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<Pitch> {
+        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<Pitch> {
+        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<u8>,
+}
+
+impl Scale {
+    /// Escala arbitraria desde su patrón de intervalos.
+    pub fn new(root: PitchClass, intervals: Vec<u8>) -> 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<Pitch> {
+        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<ScoreNote>,
+}
+
+impl Track {
+    pub fn new(name: impl Into<String>) -> 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<Track>,
+}
+
+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<f32> = 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);
+    }
+}