use std::{collections::HashMap, error::Error, io::Write, path::Path};
use config::{Config, File};
use midly::live::LiveEvent;
use serde::{Deserialize, Serialize};
use tracing::info;
use super::{
midi::{self, ToMidiEvent},
notification::SongNotificationConfig,
samples::{SampleDefinition, SampleTrigger, SamplesConfig},
track::Track,
};
#[derive(Deserialize, Serialize)]
pub struct Song {
#[serde(default = "default_song_kind")]
kind: super::kind::ConfigKind,
name: String,
midi_event: Option<midi::Event>,
midi_file: Option<String>,
midi_playback: Option<MidiPlayback>,
light_shows: Option<Vec<LightShow>>,
lighting: Option<Vec<LightingShow>>,
tracks: Vec<Track>,
#[serde(default)]
samples: HashMap<String, SampleDefinition>,
#[serde(default)]
sample_triggers: Vec<SampleTrigger>,
#[serde(default)]
loop_playback: bool,
#[serde(default)]
sections: Vec<Section>,
#[serde(default)]
notification_audio: Option<SongNotificationConfig>,
}
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct Section {
pub name: String,
pub start_measure: usize,
pub end_measure: usize,
}
impl Song {
#[allow(clippy::too_many_arguments)]
pub fn new(
name: &str,
midi_event: Option<midi::Event>,
midi_file: Option<String>,
midi_playback: Option<MidiPlayback>,
light_shows: Option<Vec<LightShow>>,
lighting: Option<Vec<LightingShow>>,
tracks: Vec<Track>,
samples: HashMap<String, SampleDefinition>,
sample_triggers: Vec<SampleTrigger>,
) -> Song {
Song {
kind: super::kind::ConfigKind::Song,
name: name.to_string(),
midi_event,
midi_file,
midi_playback,
light_shows,
lighting,
tracks,
samples,
sample_triggers,
loop_playback: false,
sections: Vec::new(),
notification_audio: None,
}
}
pub fn deserialize(path: &Path) -> Result<Song, crate::config::ConfigError> {
Ok(Config::builder()
.add_source(File::from(path))
.build()?
.try_deserialize::<Song>()?)
}
pub fn save(&self, path: &Path) -> Result<(), Box<dyn Error>> {
let serialized = crate::util::to_yaml_string(self)?;
info!(serialized);
let mut file = match std::fs::File::create(path) {
Ok(file) => file,
Err(err) => return Err(Box::new(err)),
};
match file.write_all(serialized.as_bytes()) {
Ok(_result) => Ok(()),
Err(err) => Err(Box::new(err)),
}
}
pub fn validate(&self) -> Result<(), Vec<String>> {
let mut errors = Vec::new();
if self.name.trim().is_empty() {
errors.push("song name must not be empty".to_string());
}
for (i, track) in self.tracks.iter().enumerate() {
let label = if track.name().is_empty() {
format!("track[{}]", i)
} else {
format!("track \"{}\"", track.name())
};
if track.name().trim().is_empty() {
errors.push(format!("{}: name must not be empty", label));
}
if track.file().trim().is_empty() {
errors.push(format!("{}: file must not be empty", label));
}
if let Some(ch) = track.file_channel() {
if ch == 0 {
errors.push(format!(
"{}: file_channel must be 1 or greater (channels are 1-indexed)",
label
));
}
}
}
for (i, section) in self.sections.iter().enumerate() {
let label = if section.name.is_empty() {
format!("section[{}]", i)
} else {
format!("section \"{}\"", section.name)
};
if section.name.trim().is_empty() {
errors.push(format!("{}: name must not be empty", label));
}
if section.start_measure == 0 {
errors.push(format!(
"{}: start_measure must be 1 or greater (measures are 1-indexed)",
label
));
}
if section.end_measure <= section.start_measure {
errors.push(format!(
"{}: end_measure must be greater than start_measure",
label
));
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
pub fn name(&self) -> &str {
&self.name
}
pub fn set_name(&mut self, name: &str) {
self.name = name.to_string();
}
pub fn midi_event(&self) -> Result<Option<LiveEvent<'static>>, Box<dyn Error>> {
Ok(match &self.midi_event {
Some(midi_event) => Some(midi_event.to_midi_event()?),
None => None,
})
}
pub fn midi_playback(&self) -> Option<MidiPlayback> {
if let Some(midi_playback) = &self.midi_playback {
return Some(midi_playback.clone());
} else if let Some(midi_file) = &self.midi_file {
return Some(MidiPlayback {
file: midi_file.clone(),
exclude_midi_channels: None,
});
}
None
}
pub fn light_shows(&self) -> Option<&[LightShow]> {
self.light_shows.as_deref()
}
pub fn lighting(&self) -> Option<&[LightingShow]> {
self.lighting.as_deref()
}
pub fn tracks(&self) -> &[Track] {
&self.tracks
}
pub fn loop_playback(&self) -> bool {
self.loop_playback
}
pub fn sections(&self) -> &[Section] {
&self.sections
}
pub fn notification_audio(&self) -> Option<&SongNotificationConfig> {
self.notification_audio.as_ref()
}
pub fn samples_config(&self) -> SamplesConfig {
SamplesConfig::new(
self.samples.clone(),
self.sample_triggers.clone(),
0, )
}
}
#[derive(Deserialize, Clone, Serialize)]
pub struct MidiPlayback {
file: String,
exclude_midi_channels: Option<Vec<u8>>,
}
impl MidiPlayback {
pub fn file(&self) -> &str {
&self.file
}
pub fn exclude_midi_channels(&self) -> Vec<u8> {
self.exclude_midi_channels
.clone()
.unwrap_or_default()
.iter()
.map(|channel| channel - 1)
.collect()
}
}
#[derive(Deserialize, Clone, Serialize)]
pub struct LightShow {
universe_name: String,
dmx_file: String,
midi_channels: Option<Vec<u8>>,
}
impl LightShow {
pub fn new(universe_name: String, dmx_file: String, midi_channels: Option<Vec<u8>>) -> Self {
Self {
universe_name,
dmx_file,
midi_channels,
}
}
pub fn universe_name(&self) -> &str {
&self.universe_name
}
pub fn dmx_file(&self) -> &str {
&self.dmx_file
}
pub fn midi_channels(&self) -> Vec<u8> {
self.midi_channels
.clone()
.unwrap_or_default()
.iter()
.map(|channel| channel - 1)
.collect()
}
}
#[derive(Deserialize, Clone, Serialize)]
pub struct LightingShow {
file: String,
}
impl LightingShow {
pub fn new(file: String) -> Self {
Self { file }
}
pub fn file(&self) -> &str {
&self.file
}
}
fn default_song_kind() -> super::kind::ConfigKind {
super::kind::ConfigKind::Song
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::track::Track;
fn minimal_song() -> Song {
Song::new(
"Test Song",
None,
None,
None,
None,
None,
vec![Track::new("track1".to_string(), "track1.wav", None)],
HashMap::new(),
vec![],
)
}
#[test]
fn test_lighting_show_creation() {
let show = LightingShow::new("test.light".to_string());
assert_eq!(show.file(), "test.light");
}
#[test]
fn song_name() {
assert_eq!(minimal_song().name(), "Test Song");
}
#[test]
fn song_tracks() {
let song = minimal_song();
assert_eq!(song.tracks().len(), 1);
assert_eq!(song.tracks()[0].name(), "track1");
}
#[test]
fn midi_event_none() {
let song = minimal_song();
assert!(song.midi_event().unwrap().is_none());
}
#[test]
fn midi_event_some() {
let song = Song::new(
"test",
Some(midi::note_on(1, 60, 127)),
None,
None,
None,
None,
vec![Track::new("t".to_string(), "t.wav", None)],
HashMap::new(),
vec![],
);
let event = song.midi_event().unwrap();
assert!(event.is_some());
}
#[test]
fn midi_playback_none() {
let song = minimal_song();
assert!(song.midi_playback().is_none());
}
#[test]
fn midi_playback_from_midi_file_field() {
let song = Song::new(
"test",
None,
Some("song.mid".to_string()),
None,
None,
None,
vec![Track::new("t".to_string(), "t.wav", None)],
HashMap::new(),
vec![],
);
let playback = song.midi_playback().unwrap();
assert_eq!(playback.file(), "song.mid");
assert!(playback.exclude_midi_channels().is_empty());
}
#[test]
fn midi_playback_field_overrides_midi_file() {
let mp = MidiPlayback {
file: "override.mid".to_string(),
exclude_midi_channels: Some(vec![10]),
};
let song = Song::new(
"test",
None,
Some("fallback.mid".to_string()),
Some(mp),
None,
None,
vec![Track::new("t".to_string(), "t.wav", None)],
HashMap::new(),
vec![],
);
let playback = song.midi_playback().unwrap();
assert_eq!(playback.file(), "override.mid");
}
#[test]
fn exclude_midi_channels_subtracts_one() {
let mp = MidiPlayback {
file: "test.mid".to_string(),
exclude_midi_channels: Some(vec![1, 10, 16]),
};
let excluded = mp.exclude_midi_channels();
assert_eq!(excluded, vec![0, 9, 15]);
}
#[test]
fn exclude_midi_channels_empty_default() {
let mp = MidiPlayback {
file: "test.mid".to_string(),
exclude_midi_channels: None,
};
assert!(mp.exclude_midi_channels().is_empty());
}
#[test]
fn light_shows_none() {
let song = minimal_song();
assert!(song.light_shows().is_none());
}
#[test]
fn light_shows_some() {
let song = Song::new(
"test",
None,
None,
None,
Some(vec![LightShow::new(
"universe1".to_string(),
"lights.mid".to_string(),
Some(vec![10]),
)]),
None,
vec![Track::new("t".to_string(), "t.wav", None)],
HashMap::new(),
vec![],
);
let shows = song.light_shows().unwrap();
assert_eq!(shows.len(), 1);
assert_eq!(shows[0].universe_name(), "universe1");
assert_eq!(shows[0].dmx_file(), "lights.mid");
}
#[test]
fn light_show_midi_channels_subtracts_one() {
let ls = LightShow::new("u".to_string(), "f.mid".to_string(), Some(vec![1, 10]));
assert_eq!(ls.midi_channels(), vec![0, 9]);
}
#[test]
fn light_show_midi_channels_empty_default() {
let ls = LightShow::new("u".to_string(), "f.mid".to_string(), None);
assert!(ls.midi_channels().is_empty());
}
#[test]
fn lighting_none() {
let song = minimal_song();
assert!(song.lighting().is_none());
}
#[test]
fn lighting_some() {
let song = Song::new(
"test",
None,
None,
None,
None,
Some(vec![LightingShow::new("show.light".to_string())]),
vec![Track::new("t".to_string(), "t.wav", None)],
HashMap::new(),
vec![],
);
let lighting = song.lighting().unwrap();
assert_eq!(lighting.len(), 1);
assert_eq!(lighting[0].file(), "show.light");
}
#[test]
fn samples_config_empty() {
let song = minimal_song();
let sc = song.samples_config();
assert!(sc.samples().is_empty());
assert!(sc.sample_triggers().is_empty());
}
#[test]
fn serde_deserialize_minimal() {
let yaml = r#"
name: "Minimal Song"
tracks:
- name: track1
file: track1.wav
"#;
let song: Song = config::Config::builder()
.add_source(config::File::from_str(yaml, config::FileFormat::Yaml))
.build()
.unwrap()
.try_deserialize()
.unwrap();
assert_eq!(song.name(), "Minimal Song");
assert_eq!(song.tracks().len(), 1);
assert!(song.midi_playback().is_none());
assert!(song.light_shows().is_none());
}
#[test]
fn serde_deserialize_with_midi_playback() {
let yaml = r#"
name: "MIDI Song"
tracks:
- name: track1
file: track1.wav
midi_playback:
file: song.mid
exclude_midi_channels: [10, 16]
"#;
let song: Song = config::Config::builder()
.add_source(config::File::from_str(yaml, config::FileFormat::Yaml))
.build()
.unwrap()
.try_deserialize()
.unwrap();
let mp = song.midi_playback().unwrap();
assert_eq!(mp.file(), "song.mid");
assert_eq!(mp.exclude_midi_channels(), vec![9, 15]);
}
#[test]
fn save_creates_file() {
let song = minimal_song();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("song.yaml");
song.save(&path).unwrap();
assert!(path.exists());
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("Test Song"));
}
#[test]
fn save_fails_on_invalid_path() {
let song = minimal_song();
let result = song.save(Path::new("/nonexistent/directory/song.yaml"));
assert!(result.is_err());
}
#[test]
fn validate_ok_for_valid_song() {
let song = minimal_song();
assert!(song.validate().is_ok());
}
#[test]
fn validate_rejects_empty_name() {
let song = Song::new(
"",
None,
None,
None,
None,
None,
vec![Track::new("t".to_string(), "t.wav", None)],
HashMap::new(),
vec![],
);
let errors = song.validate().unwrap_err();
assert!(errors.iter().any(|e| e.contains("song name")));
}
#[test]
fn validate_rejects_file_channel_zero() {
let song = Song::new(
"test",
None,
None,
None,
None,
None,
vec![Track::new("t".to_string(), "t.wav", Some(0))],
HashMap::new(),
vec![],
);
let errors = song.validate().unwrap_err();
assert!(errors.iter().any(|e| e.contains("file_channel")));
}
#[test]
fn validate_rejects_empty_track_name() {
let song = Song::new(
"test",
None,
None,
None,
None,
None,
vec![Track::new("".to_string(), "t.wav", None)],
HashMap::new(),
vec![],
);
let errors = song.validate().unwrap_err();
assert!(errors.iter().any(|e| e.contains("name must not be empty")));
}
#[test]
fn validate_rejects_empty_track_file() {
let song = Song::new(
"test",
None,
None,
None,
None,
None,
vec![Track::new("t".to_string(), "", None)],
HashMap::new(),
vec![],
);
let errors = song.validate().unwrap_err();
assert!(errors.iter().any(|e| e.contains("file must not be empty")));
}
#[test]
fn validate_collects_multiple_errors() {
let song = Song::new(
"",
None,
None,
None,
None,
None,
vec![Track::new("".to_string(), "", Some(0))],
HashMap::new(),
vec![],
);
let errors = song.validate().unwrap_err();
assert!(
errors.len() >= 4,
"Expected at least 4 errors, got: {:?}",
errors
);
}
#[test]
fn sections_default_empty() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("song.yaml");
std::fs::write(
&path,
"name: test\ntracks:\n - name: t1\n file: t1.wav\n",
)
.unwrap();
let song = Song::deserialize(&path).unwrap();
assert!(song.sections().is_empty());
}
#[test]
fn sections_deserialize_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("song.yaml");
std::fs::write(
&path,
"name: test\ntracks:\n - name: t1\n file: t1.wav\nsections:\n - name: verse\n start_measure: 1\n end_measure: 8\n - name: chorus\n start_measure: 9\n end_measure: 16\n",
)
.unwrap();
let song = Song::deserialize(&path).unwrap();
assert_eq!(song.sections().len(), 2);
assert_eq!(song.sections()[0].name, "verse");
assert_eq!(song.sections()[0].start_measure, 1);
assert_eq!(song.sections()[0].end_measure, 8);
assert_eq!(song.sections()[1].name, "chorus");
assert_eq!(song.sections()[1].start_measure, 9);
assert_eq!(song.sections()[1].end_measure, 16);
}
#[test]
fn validate_rejects_section_start_measure_zero() {
let mut song = minimal_song();
song.sections = vec![Section {
name: "bad".to_string(),
start_measure: 0,
end_measure: 4,
}];
let errors = song.validate().unwrap_err();
assert!(errors.iter().any(|e| e.contains("start_measure must be 1")));
}
#[test]
fn validate_rejects_section_end_not_greater_than_start() {
let mut song = minimal_song();
song.sections = vec![Section {
name: "bad".to_string(),
start_measure: 5,
end_measure: 5,
}];
let errors = song.validate().unwrap_err();
assert!(errors
.iter()
.any(|e| e.contains("end_measure must be greater")));
}
#[test]
fn validate_rejects_section_end_less_than_start() {
let mut song = minimal_song();
song.sections = vec![Section {
name: "bad".to_string(),
start_measure: 8,
end_measure: 4,
}];
let errors = song.validate().unwrap_err();
assert!(errors
.iter()
.any(|e| e.contains("end_measure must be greater")));
}
#[test]
fn validate_rejects_section_empty_name() {
let mut song = minimal_song();
song.sections = vec![Section {
name: "".to_string(),
start_measure: 1,
end_measure: 4,
}];
let errors = song.validate().unwrap_err();
assert!(errors.iter().any(|e| e.contains("name must not be empty")));
}
#[test]
fn validate_accepts_valid_sections() {
let mut song = minimal_song();
song.sections = vec![
Section {
name: "verse".to_string(),
start_measure: 1,
end_measure: 8,
},
Section {
name: "chorus".to_string(),
start_measure: 9,
end_measure: 16,
},
];
assert!(song.validate().is_ok());
}
#[test]
fn loop_playback_deserializes() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("song.yaml");
std::fs::write(
&path,
"name: test\ntracks:\n - name: t1\n file: t1.wav\nloop_playback: true\n",
)
.unwrap();
let song = Song::deserialize(&path).unwrap();
assert!(song.loop_playback());
}
}