From f92db6fac82c2b15a486a9adb59da5a99a0e4533 Mon Sep 17 00:00:00 2001 From: Keagan McClelland Date: Thu, 12 Aug 2021 10:20:36 -0600 Subject: [PATCH] Feature/sound (#389) * WIP sound lib * basically finishes sound interface, still needs circle of fifths for updates etc. * finishes sound interface, includes light testing * fixes loops to use euclidian remainder * implements locking for the sound interface * stop sleeping on blocks --- appmgr/Cargo.lock | 79 +++++++- appmgr/Cargo.toml | 4 + appmgr/src/error.rs | 2 + appmgr/src/lib.rs | 1 + appmgr/src/sound.rs | 445 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 530 insertions(+), 1 deletion(-) create mode 100644 appmgr/src/sound.rs diff --git a/appmgr/Cargo.lock b/appmgr/Cargo.lock index bcd63766a..05b9e4607 100644 --- a/appmgr/Cargo.lock +++ b/appmgr/Cargo.lock @@ -726,6 +726,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "212d0f5754cb6769937f4501cc0e67f4f4483c8d2c3e1e922ee9edbe4ab4c7c0" +[[package]] +name = "divrem" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69dde51e8fef5e12c1d65e0929b03d66e4c0c18282bc30ed2ca050ad6f44dd82" + [[package]] name = "dotenv" version = "0.15.0" @@ -786,8 +792,10 @@ dependencies = [ "clap", "cookie_store 0.15.0", "digest 0.9.0", + "divrem", "ed25519-dalek", "emver", + "fd-lock-rs", "futures", "git-version", "http", @@ -804,6 +812,8 @@ dependencies = [ "patch-db", "pin-project", "prettytable-rs", + "proptest", + "proptest-derive", "rand 0.7.3", "regex", "reqwest", @@ -1254,7 +1264,7 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df004cfca50ef23c36850aaaa59ad52cc70d0e90243c3c7737a4dd32dc7a3c4f" dependencies = [ - "quick-error", + "quick-error 1.2.3", ] [[package]] @@ -2022,6 +2032,37 @@ dependencies = [ "unicode-xid 0.2.2", ] +[[package]] +name = "proptest" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0d9cc07f18492d879586c92b485def06bc850da3118075cd45d50e9c95b0e5" +dependencies = [ + "bit-set", + "bitflags", + "byteorder", + "lazy_static", + "num-traits", + "quick-error 2.0.1", + "rand 0.8.4", + "rand_chacha 0.3.1", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", +] + +[[package]] +name = "proptest-derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90b46295382dc76166cb7cf2bb4a97952464e4b7ed5a43e6cd34e1fec3349ddc" +dependencies = [ + "proc-macro2 0.4.30", + "quote 0.6.13", + "syn 0.15.44", +] + [[package]] name = "psl-types" version = "2.0.7" @@ -2056,6 +2097,12 @@ version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quote" version = "0.6.13" @@ -2169,6 +2216,15 @@ dependencies = [ "rand_core 0.6.3", ] +[[package]] +name = "rand_xorshift" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25bf25ec5ae4a3f1b92f929810509a2f53d7dca2f50b794ff57e3face536c8f" +dependencies = [ + "rand_core 0.6.3", +] + [[package]] name = "redox_syscall" version = "0.1.57" @@ -2396,6 +2452,18 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61b3909d758bb75c79f23d4736fac9433868679d3ad2ea7a61e3c25cfda9a088" +[[package]] +name = "rusty-fork" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb3dcc6e454c328bb824492db107ab7c0ae8fcffe4ad210136ef014458c1bc4f" +dependencies = [ + "fnv", + "quick-error 1.2.3", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.5" @@ -3448,6 +3516,15 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe" +[[package]] +name = "wait-timeout" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f200f5b12eb75f8c1ed65abd4b2db8a6e1b138a20de009dacee265a2498f3f6" +dependencies = [ + "libc", +] + [[package]] name = "want" version = "0.3.0" diff --git a/appmgr/Cargo.toml b/appmgr/Cargo.toml index 6c317f556..a20d1482e 100644 --- a/appmgr/Cargo.toml +++ b/appmgr/Cargo.toml @@ -55,8 +55,10 @@ chrono = { version = "0.4.19", features = ["serde"] } clap = "2.33" cookie_store = "0.15.0" digest = "0.9.0" +divrem = "1.0.0" ed25519-dalek = { version = "1.0.1", features = ["serde"] } emver = { version = "0.1.2", features = ["serde"] } +fd-lock-rs = "*" futures = "0.3.8" git-version = "0.3.4" http = "0.2.3" @@ -73,6 +75,8 @@ openssl = { version = "0.10.30", features = ["vendored"] } patch-db = { version = "*", path = "../../patch-db/patch-db" } pin-project = "1.0.6" prettytable-rs = "0.8.0" +proptest = "1.0.0" +proptest-derive = "0.3.0" rand = "0.7.3" regex = "1.4.2" reqwest = { version = "0.11.2", features = ["stream", "json"] } diff --git a/appmgr/src/error.rs b/appmgr/src/error.rs index 1b62b3615..85d5081e1 100644 --- a/appmgr/src/error.rs +++ b/appmgr/src/error.rs @@ -48,6 +48,7 @@ pub enum ErrorKind { Uninitialized = 40, ParseNetAddress = 41, ParseSshKey = 42, + SoundError = 43, } impl ErrorKind { pub fn as_str(&self) -> &'static str { @@ -95,6 +96,7 @@ impl ErrorKind { Uninitialized => "Uninitialized", ParseNetAddress => "Net Address Parsing Error", ParseSshKey => "SSH Key Parsing Error", + SoundError => "Sound Interface Error", } } } diff --git a/appmgr/src/lib.rs b/appmgr/src/lib.rs index 9db2b1826..13b90b943 100644 --- a/appmgr/src/lib.rs +++ b/appmgr/src/lib.rs @@ -35,6 +35,7 @@ pub mod migration; pub mod net; pub mod registry; pub mod s9pk; +pub mod sound; pub mod ssh; pub mod status; pub mod util; diff --git a/appmgr/src/sound.rs b/appmgr/src/sound.rs new file mode 100644 index 000000000..6aa857ae0 --- /dev/null +++ b/appmgr/src/sound.rs @@ -0,0 +1,445 @@ +use crate::{Error, ErrorKind, ResultExt}; +use divrem::DivRem; +use proptest_derive::Arbitrary; +use std::{cmp::Ordering, path::Path, time::Duration}; +use tokio::sync::{Mutex, MutexGuard}; + +lazy_static::lazy_static! { + static ref SEMITONE_K: f64 = 2f64.powf(1f64 / 12f64); + static ref A_4: f64 = 440f64; + static ref C_0: f64 = *A_4 / SEMITONE_K.powf(9f64) / 2f64.powf(4f64); + static ref EXPORT_FILE: &'static Path = Path::new("/sys/class/pwm/pwmchip0/pwm0/export"); + static ref UNEXPORT_FILE: &'static Path = Path::new("/sys/class/pwm/pwmchip0/pwm0/unexport"); + static ref PERIOD_FILE: &'static Path = Path::new("/sys/class/pwm/pwmchip0/pwm0/period"); + static ref DUTY_FILE: &'static Path = Path::new("/sys/class/pwm/pwmchip0/pwm0/duty_cycle"); + static ref SWITCH_FILE: &'static Path = Path::new("/sys/class/pwm/pwmchip0/pwm0/enable"); + static ref SOUND_MUTEX: Mutex>> = Mutex::new(None); +} + +pub const SOUND_LOCK_FILE: &'static str = "/TODO/AIDEN/CHANGEME"; + +struct SoundInterface(Option>>>); +impl SoundInterface { + pub async fn lease() -> Result { + tokio::fs::write(&*EXPORT_FILE, "0") + .await + .map_err(|e| Error { + source: e.into(), + kind: ErrorKind::SoundError, + revision: None, + })?; + let mut guard = SOUND_MUTEX.lock().await; + let sound_file = tokio::fs::File::create(SOUND_LOCK_FILE).await?; + *guard = Some( + tokio::task::spawn_blocking(move || { + fd_lock_rs::FdLock::lock(sound_file, fd_lock_rs::LockType::Exclusive, true) + }) + .await + .map_err(|e| { + Error::new( + anyhow::anyhow!("Sound file lock panicked: {}", e), + ErrorKind::SoundError, + ) + })? + .with_kind(ErrorKind::SoundError)?, + ); + Ok(SoundInterface(Some(guard))) + } + pub async fn play(&mut self, note: &Note) -> Result<(), Error> { + { + let curr_period = tokio::fs::read_to_string(&*PERIOD_FILE).await?; + if curr_period == "0\n" { + tokio::fs::write(&*PERIOD_FILE, "1000").await?; + } + let new_period = ((1.0 / note.frequency()) * 1_000_000_000.0).round() as u64; + tokio::fs::write(&*DUTY_FILE, "0").await?; + tokio::fs::write(&*PERIOD_FILE, format!("{}", new_period)).await?; + tokio::fs::write(&*DUTY_FILE, format!("{}", new_period / 2)).await?; + tokio::fs::write(&*SWITCH_FILE, "1").await + } + .map_err(|e| Error { + source: e.into(), + kind: ErrorKind::SoundError, + revision: None, + }) + } + pub async fn play_for_time_slice( + &mut self, + tempo_qpm: u16, + note: &Note, + time_slice: &TimeSlice, + ) -> Result<(), Error> { + { + self.play(note).await?; + tokio::time::sleep(time_slice.to_duration((tempo_qpm as u64 * 19 / 20) as u16)).await; + self.stop().await?; + tokio::time::sleep(time_slice.to_duration(tempo_qpm / 20)).await; + Ok(()) + } + .or_else(|e: Error| { + // we could catch this error and propagate but I'd much prefer the original error bubble up + let _mute = self.stop(); + Err(e) + }) + } + pub async fn stop(&mut self) -> Result<(), Error> { + tokio::fs::write(&*SWITCH_FILE, "0") + .await + .map_err(|e| Error { + source: e.into(), + kind: ErrorKind::SoundError, + revision: None, + }) + } +} + +pub struct Song { + tempo_qpm: u16, + note_sequence: Notes, +} +impl<'a, T: 'a> Song +where + &'a T: IntoIterator, TimeSlice)>, +{ + pub async fn play(&'a self) -> Result<(), Error> { + let mut sound = SoundInterface::lease().await?; + for (note, slice) in &self.note_sequence { + match note { + None => tokio::time::sleep(slice.to_duration(self.tempo_qpm)).await, + Some(n) => sound.play_for_time_slice(self.tempo_qpm, n, slice).await?, + }; + } + Ok(()) + } +} + +impl Drop for SoundInterface { + fn drop(&mut self) { + let guard = self.0.take(); + tokio::spawn(async move { + if let Err(e) = tokio::fs::write(&*UNEXPORT_FILE, "0").await { + log::error!("Failed to Unexport Sound Interface: {}", e) + } + if let Some(mut guard) = guard { + if let Some(lock) = guard.take() { + if let Err(e) = tokio::task::spawn_blocking(|| lock.unlock(true)) + .await + .unwrap() + { + log::error!("Failed to drop Sound Interface File Lock: {}", e.1) + } + } + } + }); + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Arbitrary)] +pub struct Note { + semitone: Semitone, + octave: i8, +} +impl Note { + pub fn frequency(&self) -> f64 { + SEMITONE_K.powf((self.semitone as isize) as f64) * (*C_0) * (2f64.powf(self.octave as f64)) + } +} +impl PartialOrd for Note { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} +impl Ord for Note { + fn cmp(&self, other: &Self) -> Ordering { + if self.octave == other.octave { + self.semitone.cmp(&other.semitone) + } else { + self.octave.cmp(&other.octave) + } + } +} +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Arbitrary)] +pub enum Semitone { + C = 0, + Db = 1, + D = 2, + Eb = 3, + E = 4, + F = 5, + Gb = 6, + G = 7, + Ab = 8, + A = 9, + Bb = 10, + B = 11, +} + +impl Semitone { + pub fn rotate(&self, n: isize) -> Semitone { + let mut temp = (*self as isize) + n; + + match temp.rem_euclid(12) { + 0 => Semitone::C, + 1 => Semitone::Db, + 2 => Semitone::D, + 3 => Semitone::Eb, + 4 => Semitone::E, + 5 => Semitone::F, + 6 => Semitone::Gb, + 7 => Semitone::G, + 8 => Semitone::Ab, + 9 => Semitone::A, + 10 => Semitone::Bb, + 11 => Semitone::B, + _ => panic!("crate::sound::Semitone::rotate: Unreachable"), + } + } +} + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Arbitrary)] +pub struct Interval(isize); + +#[derive(Clone, Copy)] +pub enum TimeSlice { + Sixteenth, + Eighth, + Quarter, + Half, + Whole, + Triplet(&'static TimeSlice), + Dot(&'static TimeSlice), + Tie(&'static TimeSlice, &'static TimeSlice), +} +impl TimeSlice { + pub fn to_duration(&self, tempo_qpm: u16) -> Duration { + let micros_per_quarter = (tempo_qpm as f64) * 1_000_000f64; + match &self { + &Self::Sixteenth => Duration::from_micros((micros_per_quarter / 4.0) as u64), + &Self::Eighth => Duration::from_micros((micros_per_quarter / 2.0) as u64), + &Self::Quarter => Duration::from_micros(micros_per_quarter as u64), + &Self::Half => Duration::from_micros((micros_per_quarter * 2.0) as u64), + &Self::Whole => Duration::from_micros((micros_per_quarter * 4.0) as u64), + &Self::Triplet(ts) => ts.to_duration(tempo_qpm) * 2 / 3, + &Self::Dot(ts) => ts.to_duration(tempo_qpm) * 3 / 2, + &Self::Tie(ts0, ts1) => ts0.to_duration(tempo_qpm) + ts1.to_duration(tempo_qpm), + } + } +} + +pub fn interval(i: &Interval, note: &Note) -> Note { + match (i, note) { + (Interval(n), Note { semitone, octave }) => { + use std::cmp::Ordering::*; + let (o_t, s_t) = n.div_rem(12); + let new_semitone = semitone.rotate(s_t); + let new_octave = match (new_semitone.cmp(semitone), s_t.cmp(&0)) { + (Greater, Less) => octave.clone() as isize + o_t - 1, + (Less, Greater) => octave.clone() as isize + o_t + 1, + _ => octave.clone() as isize + o_t, + }; + Note { + semitone: new_semitone, + octave: new_octave as i8, + } + } + } +} + +pub const MINOR_THIRD: Interval = Interval(3); +pub const MAJOR_THIRD: Interval = Interval(4); +pub const FOURTH: Interval = Interval(5); +pub const FIFTH: Interval = Interval(7); + +fn iterate T>(f: F, init: &T) -> impl Iterator { + let mut temp = init.clone(); + let ff = move || { + let next = f(&temp); + let now = std::mem::replace(&mut temp, next); + now + }; + std::iter::repeat_with(ff) +} + +pub fn circle_of_fifths(note: &Note) -> impl Iterator { + iterate(|n| interval(&FIFTH, n), note) +} + +pub fn circle_of_fourths(note: &Note) -> impl Iterator { + iterate(|n| interval(&FOURTH, n), note) +} + +pub struct CircleOf<'a> { + current: Note, + duration: TimeSlice, + interval: &'a Interval, +} +impl<'a> CircleOf<'a> { + pub const fn new(interval: &'a Interval, start: Note, duration: TimeSlice) -> Self { + CircleOf { + current: start, + duration, + interval, + } + } +} +impl<'a> Iterator for CircleOf<'a> { + type Item = (Option, TimeSlice); + fn next(&mut self) -> Option { + let current = self.current; + let prev = std::mem::replace(&mut self.current, interval(&self.interval, ¤t)); + Some((Some(prev), self.duration.clone())) + } +} + +macro_rules! song { + ($tempo:expr, [$($note:expr;)*]) => { + { + const fn note(semi: Semitone, octave: i8, duration: TimeSlice) -> (Option, TimeSlice) { + ( + Some(Note { + semitone: semi, + octave, + }), + duration, + ) + } + const fn rest(duration: TimeSlice) -> (Option, TimeSlice) { + (None, duration) + } + + use crate::sound::Semitone::*; + use crate::sound::TimeSlice::*; + Song { + tempo_qpm: $tempo as u16, + note_sequence: [ + $( + $note, + )* + ] + } + } + }; +} + +pub const MARIO_DEATH: Song<[(Option, TimeSlice); 12]> = song!(400, [ + note(B, 4, Quarter); + note(F, 5, Quarter); + rest(Quarter); + note(F, 5, Quarter); + note(F, 5, Triplet(&Half)); + note(E, 5, Triplet(&Half)); + note(D, 5, Triplet(&Half)); + note(C, 5, Quarter); + note(E, 5, Quarter); + rest(Quarter); + note(E, 5, Quarter); + note(C, 4, Half); +]); + +pub const MARIO_POWER_UP: Song<[(Option, TimeSlice); 15]> = song!(400, [ + note(G,4,Triplet(&Eighth)); + note(B,4,Triplet(&Eighth)); + note(D,5,Triplet(&Eighth)); + note(G,5,Triplet(&Eighth)); + note(B,5,Triplet(&Eighth)); + note(Ab,4,Triplet(&Eighth)); + note(C,5,Triplet(&Eighth)); + note(Eb,5,Triplet(&Eighth)); + note(Ab,5,Triplet(&Eighth)); + note(C,5,Triplet(&Eighth)); + note(Bb,4,Triplet(&Eighth)); + note(D,5,Triplet(&Eighth)); + note(F,5,Triplet(&Eighth)); + note(Bb,5,Triplet(&Eighth)); + note(D,6,Triplet(&Eighth)); +]); + +pub const MARIO_COIN: Song<[(Option, TimeSlice); 2]> = song!(400, [ + note(B, 5, Eighth); + note(E, 6, Tie(&Dot(&Quarter), &Half)); +]); + +pub const BEETHOVEN: Song<[(Option, TimeSlice); 9]> = song!(216, [ + note(E, 5, Eighth); + note(E, 5, Eighth); + note(E, 5, Eighth); + note(C, 5, Half); + rest(Half); + note(D, 5, Eighth); + note(D, 5, Eighth); + note(D, 5, Eighth); + note(B, 4, Half); +]); + +lazy_static::lazy_static! { + pub static ref CIRCLE_OF_5THS_SHORT: Song>> = Song { + tempo_qpm: 300, + note_sequence: CircleOf::new( + &FIFTH, + Note { + semitone: Semitone::A, + octave: 3, + }, + TimeSlice::Triplet(&TimeSlice::Eighth), + ) + .take(6), + }; + pub static ref CIRCLE_OF_4THS_SHORT: Song>> = Song { + tempo_qpm: 300, + note_sequence: CircleOf::new( + &FOURTH, + Note { + semitone: Semitone::C, + octave: 4, + }, + TimeSlice::Triplet(&TimeSlice::Eighth) + ).take(6) + }; +} + +proptest::prop_compose! { + fn arb_interval() (i in -88isize..88isize) -> Interval { + Interval(i) + } +} +proptest::prop_compose! { + fn arb_note() (o in 0..8i8, s: Semitone) -> Note { + Note { + semitone: s, + octave: o, + } + } +} + +proptest::proptest! { + #[test] + fn positive_interval_greater(a in arb_note(), i in arb_interval()) { + proptest::prop_assume!(i > Interval(0)); + proptest::prop_assert!(interval(&i, &a) > a) + } + + #[test] + fn negative_interval_less(a in arb_note(), i in arb_interval()) { + proptest::prop_assume!(i < Interval(0)); + proptest::prop_assert!(interval(&i, &a) < a) + } + + #[test] + fn zero_interval_equal(a in arb_note()) { + proptest::prop_assert!(interval(&Interval(0), &a) == a) + } + + #[test] + fn positive_negative_cancellation(a in arb_note(), i in arb_interval()) { + let neg_i = match i { + Interval(n) => Interval(0-n) + }; + proptest::prop_assert_eq!(interval(&neg_i, &interval(&i, &a)), a) + } + + #[test] + fn freq_conversion_preserves_ordering(a in arb_note(), b in arb_note()) { + proptest::prop_assert_eq!(Some(a.cmp(&b)), a.frequency().partial_cmp(&b.frequency())) + } + +}