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>
This commit is contained in:
@@ -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 |
|
||||
@@ -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 }
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user