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");
|
||||
}
|
||||
}
|
||||
19
docs/tutorials/sharing.md
Normal file
19
docs/tutorials/sharing.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Sharing
|
||||
|
||||
Export and import patterns or entire banks via the clipboard. Data is serialized into a compact text string you can paste anywhere -- a chat message, a text file, a note.
|
||||
|
||||
## Export
|
||||
|
||||
Press `g` to copy the current pattern to the clipboard as a share string. The string starts with `cgr:` followed by compressed data.
|
||||
|
||||
On the patterns page, `g` exports a pattern or a bank depending on which column the cursor is in. Bank strings start with `cgrb:`.
|
||||
|
||||
## Import
|
||||
|
||||
Press `G` to import from the clipboard. The share string is decoded and the result replaces the current pattern.
|
||||
|
||||
On the patterns page, importing into the banks column replaces an entire bank. Because this is destructive, a confirmation prompt appears first.
|
||||
|
||||
## Undo
|
||||
|
||||
Both pattern and bank imports are covered by undo. Press `u` to revert an import.
|
||||
@@ -168,6 +168,111 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn share_pattern(&mut self, bank: usize, pattern: usize) {
|
||||
let pattern_data = &self.project_state.project.banks[bank].patterns[pattern];
|
||||
match model::share::export(pattern_data) {
|
||||
Ok(encoded) => {
|
||||
let len = encoded.len();
|
||||
if let Some(clip) = &mut self.clipboard {
|
||||
let _ = clip.set_text(encoded);
|
||||
}
|
||||
if len > 2000 {
|
||||
self.ui.flash(
|
||||
&format!("Shared ({len} chars — too long for Discord)"),
|
||||
200,
|
||||
FlashKind::Error,
|
||||
);
|
||||
} else {
|
||||
self.ui
|
||||
.flash(&format!("Shared ({len} chars)"), 150, FlashKind::Success);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
self.ui
|
||||
.flash(&format!("Share failed: {e}"), 200, FlashKind::Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn share_bank(&mut self, bank: usize) {
|
||||
let bank_data = &self.project_state.project.banks[bank];
|
||||
match model::share::export_bank(bank_data) {
|
||||
Ok(encoded) => {
|
||||
let len = encoded.len();
|
||||
if let Some(clip) = &mut self.clipboard {
|
||||
let _ = clip.set_text(encoded);
|
||||
}
|
||||
if len > 2000 {
|
||||
self.ui.flash(
|
||||
&format!("Bank shared ({len} chars — too long for Discord)"),
|
||||
200,
|
||||
FlashKind::Error,
|
||||
);
|
||||
} else {
|
||||
self.ui
|
||||
.flash(&format!("Bank shared ({len} chars)"), 150, FlashKind::Success);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
self.ui
|
||||
.flash(&format!("Share failed: {e}"), 200, FlashKind::Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn import_bank(&mut self, bank: usize) {
|
||||
let text = match self.clipboard.as_mut().and_then(|c| c.get_text().ok()) {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
self.ui.flash("Clipboard empty", 150, FlashKind::Error);
|
||||
return;
|
||||
}
|
||||
};
|
||||
match model::share::import_bank(&text) {
|
||||
Ok(imported) => {
|
||||
self.project_state.project.banks[bank] = imported;
|
||||
for pattern in 0..model::MAX_PATTERNS {
|
||||
self.project_state.mark_dirty(bank, pattern);
|
||||
}
|
||||
if self.editor_ctx.bank == bank {
|
||||
self.load_step_to_editor();
|
||||
}
|
||||
self.ui
|
||||
.flash("Bank imported", 150, FlashKind::Success);
|
||||
}
|
||||
Err(e) => {
|
||||
self.ui
|
||||
.flash(&format!("Import failed: {e}"), 200, FlashKind::Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn import_pattern(&mut self, bank: usize, pattern: usize) {
|
||||
let text = match self.clipboard.as_mut().and_then(|c| c.get_text().ok()) {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
self.ui
|
||||
.flash("Clipboard empty", 150, FlashKind::Error);
|
||||
return;
|
||||
}
|
||||
};
|
||||
match model::share::import(&text) {
|
||||
Ok(imported) => {
|
||||
self.project_state.project.banks[bank].patterns[pattern] = imported;
|
||||
self.project_state.mark_dirty(bank, pattern);
|
||||
if self.editor_ctx.bank == bank && self.editor_ctx.pattern == pattern {
|
||||
self.load_step_to_editor();
|
||||
}
|
||||
self.ui
|
||||
.flash("Pattern imported", 150, FlashKind::Success);
|
||||
}
|
||||
Err(e) => {
|
||||
self.ui
|
||||
.flash(&format!("Import failed: {e}"), 200, FlashKind::Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn harden_steps(&mut self) {
|
||||
let (bank, pattern) = self.current_bank_pattern();
|
||||
let indices = self.selected_steps();
|
||||
|
||||
@@ -105,6 +105,10 @@ impl App {
|
||||
AppCommand::ResetBank { bank } => self.reset_bank(bank),
|
||||
AppCommand::CopyPattern { bank, pattern } => self.copy_pattern(bank, pattern),
|
||||
AppCommand::PastePattern { bank, pattern } => self.paste_pattern(bank, pattern),
|
||||
AppCommand::SharePattern { bank, pattern } => self.share_pattern(bank, pattern),
|
||||
AppCommand::ImportPattern { bank, pattern } => self.import_pattern(bank, pattern),
|
||||
AppCommand::ShareBank { bank } => self.share_bank(bank),
|
||||
AppCommand::ImportBank { bank } => self.import_bank(bank),
|
||||
AppCommand::CopyPatterns { bank, patterns } => self.copy_patterns(bank, &patterns),
|
||||
AppCommand::PastePatterns { bank, start } => self.paste_patterns(bank, start),
|
||||
AppCommand::CopyBank { bank } => self.copy_bank(bank),
|
||||
|
||||
@@ -28,6 +28,7 @@ impl App {
|
||||
| AppCommand::DeleteSteps { bank, pattern, .. }
|
||||
| AppCommand::ResetPattern { bank, pattern }
|
||||
| AppCommand::PastePattern { bank, pattern }
|
||||
| AppCommand::ImportPattern { bank, pattern }
|
||||
| AppCommand::RenamePattern { bank, pattern, .. } => {
|
||||
let data = self.project_state.project.pattern_at(*bank, *pattern).clone();
|
||||
Some(UndoScope::Pattern { bank: *bank, pattern: *pattern, data })
|
||||
@@ -41,7 +42,9 @@ impl App {
|
||||
let data = self.project_state.project.pattern_at(*bank, *pattern).clone();
|
||||
Some(UndoScope::Pattern { bank: *bank, pattern: *pattern, data })
|
||||
}
|
||||
AppCommand::ResetBank { bank } | AppCommand::PasteBank { bank } => {
|
||||
AppCommand::ResetBank { bank }
|
||||
| AppCommand::PasteBank { bank }
|
||||
| AppCommand::ImportBank { bank } => {
|
||||
let data = self.project_state.project.banks[*bank].clone();
|
||||
Some(UndoScope::Bank { bank: *bank, data })
|
||||
}
|
||||
|
||||
@@ -67,6 +67,20 @@ pub enum AppCommand {
|
||||
bank: usize,
|
||||
pattern: usize,
|
||||
},
|
||||
SharePattern {
|
||||
bank: usize,
|
||||
pattern: usize,
|
||||
},
|
||||
ImportPattern {
|
||||
bank: usize,
|
||||
pattern: usize,
|
||||
},
|
||||
ShareBank {
|
||||
bank: usize,
|
||||
},
|
||||
ImportBank {
|
||||
bank: usize,
|
||||
},
|
||||
CopyPatterns {
|
||||
bank: usize,
|
||||
patterns: Vec<usize>,
|
||||
|
||||
@@ -236,6 +236,14 @@ pub(super) fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool
|
||||
KeyCode::Char('D') => {
|
||||
ctx.dispatch(AppCommand::EvaluatePrelude);
|
||||
}
|
||||
KeyCode::Char('g') => {
|
||||
let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern);
|
||||
ctx.dispatch(AppCommand::SharePattern { bank, pattern });
|
||||
}
|
||||
KeyCode::Char('G') => {
|
||||
let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern);
|
||||
ctx.dispatch(AppCommand::ImportPattern { bank, pattern });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
InputResult::Continue
|
||||
|
||||
@@ -638,6 +638,9 @@ fn execute_confirm(ctx: &mut InputContext, action: &ConfirmAction) -> InputResul
|
||||
ConfirmAction::ResetBanks { banks } => {
|
||||
ctx.dispatch(AppCommand::ResetBanks { banks: banks.clone() });
|
||||
}
|
||||
ConfirmAction::ImportBank { bank } => {
|
||||
ctx.dispatch(AppCommand::ImportBank { bank: *bank });
|
||||
}
|
||||
}
|
||||
ctx.dispatch(AppCommand::CloseModal);
|
||||
InputResult::Continue
|
||||
|
||||
@@ -265,6 +265,33 @@ pub(super) fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> Inp
|
||||
ctx.dispatch(AppCommand::ClearSolos);
|
||||
ctx.app.send_mute_state(ctx.seq_cmd_tx);
|
||||
}
|
||||
KeyCode::Char('g') => {
|
||||
let bank = ctx.app.patterns_nav.bank_cursor;
|
||||
match ctx.app.patterns_nav.column {
|
||||
PatternsColumn::Patterns => {
|
||||
let pattern = ctx.app.patterns_nav.pattern_cursor;
|
||||
ctx.dispatch(AppCommand::SharePattern { bank, pattern });
|
||||
}
|
||||
PatternsColumn::Banks => {
|
||||
ctx.dispatch(AppCommand::ShareBank { bank });
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('G') => {
|
||||
let bank = ctx.app.patterns_nav.bank_cursor;
|
||||
match ctx.app.patterns_nav.column {
|
||||
PatternsColumn::Patterns => {
|
||||
let pattern = ctx.app.patterns_nav.pattern_cursor;
|
||||
ctx.dispatch(AppCommand::ImportPattern { bank, pattern });
|
||||
}
|
||||
PatternsColumn::Banks => {
|
||||
ctx.dispatch(AppCommand::OpenModal(Modal::Confirm {
|
||||
action: ConfirmAction::ImportBank { bank },
|
||||
selected: false,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('s') => super::open_save(ctx),
|
||||
KeyCode::Char('l') => super::open_load(ctx),
|
||||
KeyCode::Char('?') => {
|
||||
|
||||
@@ -127,6 +127,10 @@ pub const DOCS: &[DocEntry] = &[
|
||||
"Soundfonts",
|
||||
include_str!("../../docs/tutorials/soundfont.md"),
|
||||
),
|
||||
Topic(
|
||||
"Sharing",
|
||||
include_str!("../../docs/tutorials/sharing.md"),
|
||||
),
|
||||
];
|
||||
|
||||
pub fn topic_count() -> usize {
|
||||
|
||||
@@ -9,8 +9,8 @@ pub use cagire_forth::{
|
||||
Variables, Word, WordCompile, WORDS,
|
||||
};
|
||||
pub use cagire_project::{
|
||||
load, load_str, save, Bank, FollowUp, LaunchQuantization, Pattern, PatternSpeed, Project,
|
||||
SyncMode, MAX_BANKS, MAX_PATTERNS,
|
||||
load, load_str, save, share, Bank, FollowUp, LaunchQuantization, Pattern, PatternSpeed,
|
||||
Project, SyncMode, MAX_BANKS, MAX_PATTERNS,
|
||||
};
|
||||
pub use script::ScriptEngine;
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ pub enum ConfirmAction {
|
||||
ResetBank { bank: usize },
|
||||
ResetPatterns { bank: usize, patterns: Vec<usize> },
|
||||
ResetBanks { banks: Vec<usize> },
|
||||
ImportBank { bank: usize },
|
||||
}
|
||||
|
||||
impl ConfirmAction {
|
||||
@@ -26,6 +27,7 @@ impl ConfirmAction {
|
||||
Self::ResetBank { bank } => format!("Reset bank {}?", bank + 1),
|
||||
Self::ResetPatterns { patterns, .. } => format!("Reset {} patterns?", patterns.len()),
|
||||
Self::ResetBanks { banks } => format!("Reset {} banks?", banks.len()),
|
||||
Self::ImportBank { bank } => format!("Import bank from clipboard? (replaces bank {:02})", bank + 1),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,8 @@ pub fn bindings_for(page: Page, plugin_mode: bool) -> Vec<(&'static str, &'stati
|
||||
bindings.push(("X", "Clear solos", "Clear all solos"));
|
||||
bindings.push(("d", "Prelude", "Edit prelude script"));
|
||||
bindings.push(("D", "Eval prelude", "Re-evaluate prelude without editing"));
|
||||
bindings.push(("g", "Share", "Export pattern to clipboard"));
|
||||
bindings.push(("G", "Import", "Import pattern from clipboard"));
|
||||
}
|
||||
Page::Patterns => {
|
||||
bindings.push(("←→↑↓", "Navigate", "Move between banks/patterns"));
|
||||
@@ -71,6 +73,8 @@ pub fn bindings_for(page: Page, plugin_mode: bool) -> Vec<(&'static str, &'stati
|
||||
bindings.push(("x", "Solo", "Stage solo for pattern"));
|
||||
bindings.push(("M", "Clear mutes", "Clear all mutes"));
|
||||
bindings.push(("X", "Clear solos", "Clear all solos"));
|
||||
bindings.push(("g", "Share", "Export bank or pattern to clipboard"));
|
||||
bindings.push(("G", "Import", "Import bank or pattern from clipboard"));
|
||||
bindings.push(("Ctrl+C", "Copy", "Copy bank/pattern"));
|
||||
bindings.push(("Ctrl+V", "Paste", "Paste bank/pattern"));
|
||||
bindings.push(("Del", "Reset", "Reset bank/pattern"));
|
||||
|
||||
Reference in New Issue
Block a user