Feat: bank / pattern import / export feature + documentation
This commit is contained in:
@@ -10,3 +10,9 @@ description = "Project data structures for cagire sequencer"
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rmp-serde = "1"
|
||||
brotli = "7"
|
||||
base64 = "0.22"
|
||||
|
||||
[dev-dependencies]
|
||||
flate2 = "1"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
mod file;
|
||||
mod project;
|
||||
pub mod share;
|
||||
|
||||
pub const MAX_BANKS: usize = 32;
|
||||
pub const MAX_PATTERNS: usize = 32;
|
||||
|
||||
209
crates/project/src/share.rs
Normal file
209
crates/project/src/share.rs
Normal file
@@ -0,0 +1,209 @@
|
||||
//! Pattern and project sharing via compact text strings.
|
||||
//!
|
||||
//! Export: data → MessagePack → Brotli → base64 URL-safe → prefix
|
||||
//! Import: strip prefix → base64 decode → Brotli decompress → MessagePack → data
|
||||
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine;
|
||||
|
||||
use crate::{Bank, Pattern};
|
||||
|
||||
const PATTERN_PREFIX: &str = "cgr:";
|
||||
const BANK_PREFIX: &str = "cgrb:";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ShareError {
|
||||
InvalidPrefix,
|
||||
Base64(base64::DecodeError),
|
||||
Decompress(std::io::Error),
|
||||
Deserialize(rmp_serde::decode::Error),
|
||||
Serialize(rmp_serde::encode::Error),
|
||||
Compress(std::io::Error),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ShareError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::InvalidPrefix => write!(f, "missing cgr:/cgrb: prefix"),
|
||||
Self::Base64(e) => write!(f, "base64: {e}"),
|
||||
Self::Decompress(e) => write!(f, "decompress: {e}"),
|
||||
Self::Deserialize(e) => write!(f, "deserialize: {e}"),
|
||||
Self::Serialize(e) => write!(f, "serialize: {e}"),
|
||||
Self::Compress(e) => write!(f, "compress: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn compress(data: &[u8]) -> Result<Vec<u8>, ShareError> {
|
||||
let mut output = Vec::new();
|
||||
let params = brotli::enc::BrotliEncoderParams {
|
||||
quality: 11,
|
||||
lgwin: 22,
|
||||
lgblock: 0,
|
||||
..Default::default()
|
||||
};
|
||||
brotli::BrotliCompress(&mut &data[..], &mut output, ¶ms).map_err(ShareError::Compress)?;
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn decompress(data: &[u8]) -> Result<Vec<u8>, ShareError> {
|
||||
let mut output = Vec::new();
|
||||
brotli::BrotliDecompress(&mut &data[..], &mut output).map_err(ShareError::Decompress)?;
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn encode<T: serde::Serialize>(value: &T, prefix: &str) -> Result<String, ShareError> {
|
||||
let packed = rmp_serde::to_vec_named(value).map_err(ShareError::Serialize)?;
|
||||
let compressed = compress(&packed)?;
|
||||
let encoded = URL_SAFE_NO_PAD.encode(&compressed);
|
||||
Ok(format!("{prefix}{encoded}"))
|
||||
}
|
||||
|
||||
fn decode<T: serde::de::DeserializeOwned>(text: &str, prefix: &str) -> Result<T, ShareError> {
|
||||
let text = text.trim();
|
||||
let payload = text.strip_prefix(prefix).ok_or(ShareError::InvalidPrefix)?;
|
||||
let compressed = URL_SAFE_NO_PAD.decode(payload).map_err(ShareError::Base64)?;
|
||||
let packed = decompress(&compressed)?;
|
||||
rmp_serde::from_slice(&packed).map_err(ShareError::Deserialize)
|
||||
}
|
||||
|
||||
pub fn export(pattern: &Pattern) -> Result<String, ShareError> {
|
||||
encode(pattern, PATTERN_PREFIX)
|
||||
}
|
||||
|
||||
pub fn import(text: &str) -> Result<Pattern, ShareError> {
|
||||
decode(text, PATTERN_PREFIX)
|
||||
}
|
||||
|
||||
pub fn export_bank(bank: &Bank) -> Result<String, ShareError> {
|
||||
encode(bank, BANK_PREFIX)
|
||||
}
|
||||
|
||||
pub fn import_bank(text: &str) -> Result<Bank, ShareError> {
|
||||
decode(text, BANK_PREFIX)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Step;
|
||||
|
||||
#[test]
|
||||
fn roundtrip_empty() {
|
||||
let pattern = Pattern::default();
|
||||
let encoded = export(&pattern).unwrap();
|
||||
assert!(encoded.starts_with("cgr:"));
|
||||
let decoded = import(&encoded).unwrap();
|
||||
assert_eq!(decoded.length, pattern.length);
|
||||
assert_eq!(decoded.steps.len(), pattern.steps.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_with_steps() {
|
||||
let mut pattern = Pattern::default();
|
||||
pattern.steps[0] = Step {
|
||||
active: true,
|
||||
script: "kick 60 note".to_string(),
|
||||
source: None,
|
||||
name: Some("kick".to_string()),
|
||||
};
|
||||
pattern.steps[1] = Step {
|
||||
active: false,
|
||||
script: "snare".to_string(),
|
||||
source: None,
|
||||
name: None,
|
||||
};
|
||||
pattern.steps[3] = Step {
|
||||
active: true,
|
||||
script: String::new(),
|
||||
source: Some(0),
|
||||
name: None,
|
||||
};
|
||||
pattern.length = 8;
|
||||
pattern.name = Some("Test".to_string());
|
||||
|
||||
let encoded = export(&pattern).unwrap();
|
||||
let decoded = import(&encoded).unwrap();
|
||||
|
||||
assert_eq!(decoded.length, 8);
|
||||
assert_eq!(decoded.name.as_deref(), Some("Test"));
|
||||
assert_eq!(decoded.steps[0].script, "kick 60 note");
|
||||
assert_eq!(decoded.steps[0].name.as_deref(), Some("kick"));
|
||||
assert!(!decoded.steps[1].active);
|
||||
assert_eq!(decoded.steps[1].script, "snare");
|
||||
assert_eq!(decoded.steps[3].source, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_prefix() {
|
||||
assert!(matches!(import("xxx:abc"), Err(ShareError::InvalidPrefix)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_base64() {
|
||||
assert!(matches!(import("cgr:!!!"), Err(ShareError::Base64(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitespace_trimming() {
|
||||
let pattern = Pattern::default();
|
||||
let encoded = export(&pattern).unwrap();
|
||||
let padded = format!(" {encoded} \n");
|
||||
let decoded = import(&padded).unwrap();
|
||||
assert_eq!(decoded.length, pattern.length);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn msgpack_brotli_smaller_than_json_deflate() {
|
||||
let mut pattern = Pattern::default();
|
||||
for i in 0..16 {
|
||||
pattern.steps[i] = Step {
|
||||
active: true,
|
||||
script: format!("kick {i} note 0.5 dur"),
|
||||
source: None,
|
||||
name: Some(format!("step_{i}")),
|
||||
};
|
||||
}
|
||||
pattern.length = 16;
|
||||
|
||||
// Current (msgpack+brotli)
|
||||
let new_encoded = export(&pattern).unwrap();
|
||||
|
||||
// Old pipeline (json+deflate) for comparison
|
||||
use std::io::Write;
|
||||
let json = serde_json::to_vec(&pattern).unwrap();
|
||||
let mut encoder =
|
||||
flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::best());
|
||||
encoder.write_all(&json).unwrap();
|
||||
let old_compressed = encoder.finish().unwrap();
|
||||
let old_encoded = format!("cgr:{}", URL_SAFE_NO_PAD.encode(&old_compressed));
|
||||
|
||||
assert!(
|
||||
new_encoded.len() < old_encoded.len(),
|
||||
"msgpack+brotli ({}) should be smaller than json+deflate ({})",
|
||||
new_encoded.len(),
|
||||
old_encoded.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_bank() {
|
||||
let mut bank = Bank::default();
|
||||
bank.patterns[0].steps[0] = Step {
|
||||
active: true,
|
||||
script: "kick 60 note".to_string(),
|
||||
source: None,
|
||||
name: Some("kick".to_string()),
|
||||
};
|
||||
bank.patterns[0].length = 8;
|
||||
bank.name = Some("Drums".to_string());
|
||||
|
||||
let encoded = export_bank(&bank).unwrap();
|
||||
assert!(encoded.starts_with("cgrb:"));
|
||||
let decoded = import_bank(&encoded).unwrap();
|
||||
|
||||
assert_eq!(decoded.name.as_deref(), Some("Drums"));
|
||||
assert_eq!(decoded.patterns[0].length, 8);
|
||||
assert_eq!(decoded.patterns[0].steps[0].script, "kick 60 note");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user