P1b: SQLite project, content-addressed assets, autosave and undo
This commit is contained in:
@@ -51,6 +51,28 @@ pub struct MaterialSnapshot {
|
||||
pub cores: Vec<AudioCore>,
|
||||
}
|
||||
|
||||
/// A complete project as loaded from the store.
|
||||
#[derive(Clone)]
|
||||
pub struct ProjectData {
|
||||
pub name: String,
|
||||
pub revision: u64,
|
||||
pub bpm: f32,
|
||||
pub gains: [f32; VEIN_COUNT],
|
||||
pub pattern: Pattern,
|
||||
pub cores: Vec<Core>,
|
||||
pub journal: Vec<JournalEntry>,
|
||||
pub seq: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ProjectEdit {
|
||||
bpm: f32,
|
||||
gains: [f32; VEIN_COUNT],
|
||||
pattern: Pattern,
|
||||
}
|
||||
|
||||
const MAX_HISTORY: usize = 64;
|
||||
|
||||
/// The audio engine as seen by the session. Implementations must be real-time
|
||||
/// safe on the audio thread and non-blocking on these methods.
|
||||
pub trait Engine: Send + Sync {
|
||||
@@ -101,6 +123,9 @@ pub struct Session {
|
||||
pattern: Pattern,
|
||||
cores: Vec<Core>,
|
||||
journal: Vec<JournalEntry>,
|
||||
project_name: String,
|
||||
history: Vec<ProjectEdit>,
|
||||
redo: Vec<ProjectEdit>,
|
||||
engine: Arc<dyn Engine>,
|
||||
}
|
||||
|
||||
@@ -119,12 +144,104 @@ impl Session {
|
||||
pattern,
|
||||
cores: Vec::new(),
|
||||
journal: Vec::new(),
|
||||
project_name: "descent-04".into(),
|
||||
history: Vec::new(),
|
||||
redo: Vec::new(),
|
||||
engine,
|
||||
};
|
||||
session.sync_material();
|
||||
session
|
||||
}
|
||||
|
||||
pub fn from_project(engine: Arc<dyn Engine>, data: ProjectData) -> Self {
|
||||
let mut session = Self {
|
||||
revision: data.revision,
|
||||
seq: data.seq,
|
||||
bpm: data.bpm,
|
||||
playing: false,
|
||||
gains: data.gains,
|
||||
pattern: data.pattern,
|
||||
cores: data.cores,
|
||||
journal: data.journal,
|
||||
project_name: data.name,
|
||||
history: Vec::new(),
|
||||
redo: Vec::new(),
|
||||
engine,
|
||||
};
|
||||
session.sanitize_faces();
|
||||
session.sync_material();
|
||||
session
|
||||
}
|
||||
|
||||
pub fn project_name(&self) -> &str {
|
||||
&self.project_name
|
||||
}
|
||||
|
||||
pub fn set_project_name(&mut self, name: String) {
|
||||
self.project_name = name;
|
||||
}
|
||||
|
||||
pub fn can_undo(&self) -> bool {
|
||||
!self.history.is_empty()
|
||||
}
|
||||
|
||||
pub fn can_redo(&self) -> bool {
|
||||
!self.redo.is_empty()
|
||||
}
|
||||
|
||||
pub fn bpm(&self) -> f32 {
|
||||
self.bpm
|
||||
}
|
||||
|
||||
pub fn gains(&self) -> [f32; VEIN_COUNT] {
|
||||
self.gains
|
||||
}
|
||||
|
||||
fn sanitize_faces(&mut self) {
|
||||
for row in &mut self.pattern.rows {
|
||||
let Some(face) = &row.face else { continue };
|
||||
let exists = self.cores.iter().any(|core| {
|
||||
core.summary.id == face.core
|
||||
&& core
|
||||
.faces
|
||||
.iter()
|
||||
.any(|candidate| candidate.index == face.face)
|
||||
});
|
||||
if !exists {
|
||||
row.face = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn edit_snapshot(&self) -> ProjectEdit {
|
||||
ProjectEdit {
|
||||
bpm: self.bpm,
|
||||
gains: self.gains,
|
||||
pattern: self.pattern.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn push_history(&mut self) {
|
||||
if self.history.len() >= MAX_HISTORY {
|
||||
self.history.remove(0);
|
||||
}
|
||||
self.history.push(self.edit_snapshot());
|
||||
self.redo.clear();
|
||||
}
|
||||
|
||||
fn apply_edit(&mut self, edit: ProjectEdit) {
|
||||
self.bpm = edit.bpm;
|
||||
self.gains = edit.gains;
|
||||
self.pattern = edit.pattern;
|
||||
self.engine.set_tempo(self.bpm);
|
||||
for (index, gain) in self.gains.iter().enumerate() {
|
||||
if let Some(vein) = VeinId::from_index(index) {
|
||||
self.engine.set_gain(vein, *gain);
|
||||
}
|
||||
}
|
||||
self.sync_material();
|
||||
}
|
||||
|
||||
pub fn revision(&self) -> u64 {
|
||||
self.revision
|
||||
}
|
||||
@@ -141,6 +258,9 @@ impl Session {
|
||||
let meter = self.engine.meter();
|
||||
State {
|
||||
revision: self.revision,
|
||||
project_name: self.project_name.clone(),
|
||||
can_undo: self.can_undo(),
|
||||
can_redo: self.can_redo(),
|
||||
transport: TransportState {
|
||||
playing: self.playing,
|
||||
bpm: self.bpm,
|
||||
@@ -237,6 +357,7 @@ impl Session {
|
||||
Vec::new(),
|
||||
));
|
||||
}
|
||||
self.push_history();
|
||||
self.bpm = bpm;
|
||||
self.engine.set_tempo(bpm);
|
||||
self.revision += 1;
|
||||
@@ -261,6 +382,7 @@ impl Session {
|
||||
Vec::new(),
|
||||
));
|
||||
}
|
||||
self.push_history();
|
||||
self.gains[index] = gain;
|
||||
self.engine.set_gain(vein, gain);
|
||||
self.revision += 1;
|
||||
@@ -301,6 +423,52 @@ impl Session {
|
||||
Command::SetPatternFace { row, core, face } => {
|
||||
self.set_pattern_face(row, &core, face, actor)
|
||||
}
|
||||
Command::Undo => {
|
||||
let Some(edit) = self.history.pop() else {
|
||||
return Ok((
|
||||
CommandResult::State {
|
||||
state: self.snapshot(),
|
||||
},
|
||||
Vec::new(),
|
||||
));
|
||||
};
|
||||
self.redo.push(self.edit_snapshot());
|
||||
self.apply_edit(edit);
|
||||
self.revision += 1;
|
||||
self.record(actor, Command::Undo);
|
||||
Ok((
|
||||
CommandResult::State {
|
||||
state: self.snapshot(),
|
||||
},
|
||||
vec![Event::History {
|
||||
can_undo: self.can_undo(),
|
||||
can_redo: self.can_redo(),
|
||||
}],
|
||||
))
|
||||
}
|
||||
Command::Redo => {
|
||||
let Some(edit) = self.redo.pop() else {
|
||||
return Ok((
|
||||
CommandResult::State {
|
||||
state: self.snapshot(),
|
||||
},
|
||||
Vec::new(),
|
||||
));
|
||||
};
|
||||
self.history.push(self.edit_snapshot());
|
||||
self.apply_edit(edit);
|
||||
self.revision += 1;
|
||||
self.record(actor, Command::Redo);
|
||||
Ok((
|
||||
CommandResult::State {
|
||||
state: self.snapshot(),
|
||||
},
|
||||
vec![Event::History {
|
||||
can_undo: self.can_undo(),
|
||||
can_redo: self.can_redo(),
|
||||
}],
|
||||
))
|
||||
}
|
||||
Command::ImportCore { .. } => Err(CoreError::UnsupportedCommand),
|
||||
}
|
||||
}
|
||||
@@ -350,10 +518,12 @@ impl Session {
|
||||
return Err(CoreError::InvalidStep);
|
||||
}
|
||||
let velocity = velocity.min(127);
|
||||
let Some(row_ref) = self.pattern.rows.get_mut(row as usize) else {
|
||||
return Err(CoreError::InvalidRow);
|
||||
};
|
||||
if row_ref.velocities[step as usize] == velocity {
|
||||
let unchanged = self
|
||||
.pattern
|
||||
.rows
|
||||
.get(row as usize)
|
||||
.is_some_and(|row| row.velocities[step as usize] == velocity);
|
||||
if unchanged {
|
||||
return Ok((
|
||||
CommandResult::State {
|
||||
state: self.snapshot(),
|
||||
@@ -361,6 +531,10 @@ impl Session {
|
||||
Vec::new(),
|
||||
));
|
||||
}
|
||||
self.push_history();
|
||||
let Some(row_ref) = self.pattern.rows.get_mut(row as usize) else {
|
||||
return Err(CoreError::InvalidRow);
|
||||
};
|
||||
row_ref.velocities[step as usize] = velocity;
|
||||
self.revision += 1;
|
||||
self.record(
|
||||
@@ -399,10 +573,13 @@ impl Session {
|
||||
core: core.to_string(),
|
||||
face,
|
||||
};
|
||||
let Some(row_ref) = self.pattern.rows.get_mut(row as usize) else {
|
||||
return Err(CoreError::InvalidRow);
|
||||
};
|
||||
if row_ref.face.as_ref() == Some(&reference) {
|
||||
let unchanged = self
|
||||
.pattern
|
||||
.rows
|
||||
.get(row as usize)
|
||||
.and_then(|row| row.face.as_ref())
|
||||
.is_some_and(|face| face == &reference);
|
||||
if unchanged {
|
||||
return Ok((
|
||||
CommandResult::State {
|
||||
state: self.snapshot(),
|
||||
@@ -410,6 +587,10 @@ impl Session {
|
||||
Vec::new(),
|
||||
));
|
||||
}
|
||||
self.push_history();
|
||||
let Some(row_ref) = self.pattern.rows.get_mut(row as usize) else {
|
||||
return Err(CoreError::InvalidRow);
|
||||
};
|
||||
row_ref.face = Some(reference);
|
||||
self.revision += 1;
|
||||
self.record(
|
||||
@@ -810,6 +991,72 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_and_redo_project_edits() {
|
||||
let (mut session, _) = session();
|
||||
session
|
||||
.apply(Command::SetTempo { bpm: 132.0 }, Actor::Hand)
|
||||
.unwrap();
|
||||
session.set_pattern_step(0, 0, 0, Actor::Hand).unwrap();
|
||||
assert!(session.can_undo());
|
||||
assert!(!session.can_redo());
|
||||
|
||||
let (result, events) = session.apply(Command::Undo, Actor::Hand).unwrap();
|
||||
let CommandResult::State { state } = result else {
|
||||
panic!("expected state");
|
||||
};
|
||||
assert_eq!(state.pattern.rows[0].velocities[0], 112);
|
||||
assert_eq!(state.transport.bpm, 132.0);
|
||||
assert!(state.can_redo);
|
||||
assert_eq!(
|
||||
events,
|
||||
vec![Event::History {
|
||||
can_undo: true,
|
||||
can_redo: true
|
||||
}]
|
||||
);
|
||||
|
||||
let (result, _) = session.apply(Command::Undo, Actor::Hand).unwrap();
|
||||
let CommandResult::State { state } = result else {
|
||||
panic!("expected state");
|
||||
};
|
||||
assert_eq!(state.transport.bpm, 128.0);
|
||||
assert!(!state.can_undo);
|
||||
|
||||
let (result, _) = session.apply(Command::Redo, Actor::Hand).unwrap();
|
||||
let CommandResult::State { state } = result else {
|
||||
panic!("expected state");
|
||||
};
|
||||
assert_eq!(state.transport.bpm, 132.0);
|
||||
assert!(session.journal().len() >= 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_data_loads_cores_pattern_and_name() {
|
||||
let engine = Arc::new(TestEngine::new());
|
||||
let session = Session::from_project(
|
||||
engine.clone(),
|
||||
ProjectData {
|
||||
name: "loaded project".into(),
|
||||
revision: 7,
|
||||
bpm: 120.0,
|
||||
gains: [0.5; VEIN_COUNT],
|
||||
pattern: default_pattern(),
|
||||
cores: vec![test_core()],
|
||||
journal: Vec::new(),
|
||||
seq: 0,
|
||||
},
|
||||
);
|
||||
engine.clear();
|
||||
let state = session.snapshot();
|
||||
assert_eq!(state.project_name, "loaded project");
|
||||
assert_eq!(state.transport.bpm, 120.0);
|
||||
assert_eq!(state.veins[0].gain, 0.5);
|
||||
assert_eq!(state.cores.len(), 1);
|
||||
assert_eq!(state.journal_len, 0);
|
||||
assert!(!state.can_undo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn faces_are_validated_and_can_be_struck() {
|
||||
let (mut session, engine) = session();
|
||||
|
||||
@@ -12,6 +12,7 @@ path = "src/main.rs"
|
||||
zhila-core = { path = "../zhila-core" }
|
||||
zhila-engine = { path = "../zhila-engine" }
|
||||
zhila-material = { path = "../zhila-material" }
|
||||
zhila-project = { path = "../zhila-project" }
|
||||
zhila-protocol = { path = "../zhila-protocol" }
|
||||
tokio.workspace = true
|
||||
tokio-tungstenite.workspace = true
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! Text frames are JSON control messages; meter frames are binary. The daemon
|
||||
//! binds to loopback only and requires a token in the hello.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
@@ -17,10 +18,11 @@ use tokio_tungstenite::tungstenite::Message;
|
||||
use tokio_tungstenite::tungstenite::handshake::server::{ErrorResponse, Request, Response};
|
||||
use tokio_tungstenite::{WebSocketStream, accept_hdr_async};
|
||||
use tracing::{info, warn};
|
||||
use zhila_core::{CoreError, Engine, Session};
|
||||
use zhila_core::{CoreError, Engine, Session, default_pattern};
|
||||
use zhila_engine::{AudioEngine, NullEngine};
|
||||
use zhila_project::{ProjectSnapshot, ProjectStore};
|
||||
use zhila_protocol::{
|
||||
Actor, ClientMessage, CommandResult, ErrorCode, PROTOCOL_VERSION, Seal, ServerMessage,
|
||||
Actor, ClientMessage, Command, CommandResult, ErrorCode, PROTOCOL_VERSION, Seal, ServerMessage,
|
||||
};
|
||||
|
||||
const HELLO_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
@@ -44,6 +46,9 @@ struct Args {
|
||||
/// Run without an output device; useful for UI tests.
|
||||
#[arg(long)]
|
||||
no_audio: bool,
|
||||
/// Project directory. Defaults to ~/.local/share/zhila/descent-04.
|
||||
#[arg(long)]
|
||||
project: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -55,6 +60,7 @@ enum Outbound {
|
||||
#[derive(Clone)]
|
||||
struct Shared {
|
||||
session: Arc<Mutex<Session>>,
|
||||
store: Arc<std::sync::Mutex<ProjectStore>>,
|
||||
out: broadcast::Sender<Outbound>,
|
||||
token: Arc<String>,
|
||||
session_id: Arc<String>,
|
||||
@@ -72,7 +78,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
let args = Args::parse();
|
||||
let token = std::env::var("ZHILA_TOKEN").unwrap_or(args.token);
|
||||
|
||||
let pattern = zhila_core::default_pattern();
|
||||
let pattern = default_pattern();
|
||||
let audio_holder = if args.no_audio {
|
||||
None
|
||||
} else {
|
||||
@@ -90,15 +96,43 @@ async fn main() -> anyhow::Result<()> {
|
||||
};
|
||||
let shutdown = control.clone();
|
||||
let info = control.info();
|
||||
let session = Arc::new(Mutex::new(Session::with_pattern(control.clone(), pattern)));
|
||||
|
||||
let project_path = args.project.clone().unwrap_or_else(default_project_dir);
|
||||
let store = ProjectStore::open(&project_path).context("could not open the project")?;
|
||||
let loaded = store.load().context("could not load the project")?;
|
||||
let (session, project_name) = match loaded {
|
||||
Some(loaded) => {
|
||||
for warning in &loaded.warnings {
|
||||
warn!("project: {warning}");
|
||||
}
|
||||
let name = loaded.data.name.clone();
|
||||
info!(project = %name, path = %project_path.display(), "project loaded");
|
||||
(Session::from_project(control.clone(), loaded.data), name)
|
||||
}
|
||||
None => {
|
||||
let name = project_path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("descent-04")
|
||||
.to_string();
|
||||
let mut session = Session::with_pattern(control.clone(), pattern.clone());
|
||||
session.set_project_name(name.clone());
|
||||
info!(project = %name, path = %project_path.display(), "new project");
|
||||
(session, name)
|
||||
}
|
||||
};
|
||||
let session = Arc::new(Mutex::new(session));
|
||||
let store = Arc::new(std::sync::Mutex::new(store));
|
||||
let (out, _) = broadcast::channel::<Outbound>(128);
|
||||
let session_id = format!("zhila-{:x}", now_ms());
|
||||
let shared = Shared {
|
||||
session,
|
||||
store,
|
||||
out: out.clone(),
|
||||
token: Arc::new(token.clone()),
|
||||
session_id: Arc::new(session_id.clone()),
|
||||
};
|
||||
save_now(&shared).await;
|
||||
|
||||
let meter_engine = control.clone();
|
||||
let meter_out = out.clone();
|
||||
@@ -115,7 +149,14 @@ async fn main() -> anyhow::Result<()> {
|
||||
.await
|
||||
.with_context(|| format!("could not bind 127.0.0.1:{}", args.port))?;
|
||||
|
||||
print_banner(args.port, &token, &info, &session_id);
|
||||
print_banner(
|
||||
args.port,
|
||||
&token,
|
||||
&info,
|
||||
&session_id,
|
||||
&project_name,
|
||||
&project_path,
|
||||
);
|
||||
if token == "dev" {
|
||||
warn!("using the development token; pass --token or ZHILA_TOKEN to change it");
|
||||
}
|
||||
@@ -142,10 +183,18 @@ async fn main() -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_banner(port: u16, token: &str, info: &zhila_protocol::AudioInfo, session_id: &str) {
|
||||
fn print_banner(
|
||||
port: u16,
|
||||
token: &str,
|
||||
info: &zhila_protocol::AudioInfo,
|
||||
session_id: &str,
|
||||
project_name: &str,
|
||||
project_path: &std::path::Path,
|
||||
) {
|
||||
println!();
|
||||
println!(" ZHILA · P0 engine");
|
||||
println!(" ZHILA engine");
|
||||
println!(" ws://127.0.0.1:{port} · token {token}");
|
||||
println!(" project {project_name} · {}", project_path.display());
|
||||
if info.running {
|
||||
let buffer = if info.buffer_frames == 0 {
|
||||
"default".to_string()
|
||||
@@ -163,6 +212,37 @@ fn print_banner(port: u16, token: &str, info: &zhila_protocol::AudioInfo, sessio
|
||||
println!();
|
||||
}
|
||||
|
||||
async fn save_now(shared: &Shared) {
|
||||
let session = shared.session.lock().await;
|
||||
let state = session.snapshot();
|
||||
let gains = session.gains();
|
||||
let snapshot = ProjectSnapshot {
|
||||
name: session.project_name(),
|
||||
revision: state.revision,
|
||||
bpm: session.bpm(),
|
||||
gains: &gains,
|
||||
pattern: session.pattern(),
|
||||
cores: &state.cores,
|
||||
journal: session.journal(),
|
||||
};
|
||||
let result = {
|
||||
let mut store = shared.store.lock().expect("project store lock poisoned");
|
||||
store.save(&snapshot)
|
||||
};
|
||||
drop(session);
|
||||
if let Err(error) = result {
|
||||
warn!("project save failed: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
fn default_project_dir() -> PathBuf {
|
||||
let base = std::env::var_os("XDG_DATA_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share")))
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
base.join("zhila").join("descent-04")
|
||||
}
|
||||
|
||||
async fn handle(stream: TcpStream, shared: Shared) -> anyhow::Result<()> {
|
||||
let peer = stream.peer_addr().ok();
|
||||
let ws = accept_hdr_async(stream, handshake)
|
||||
@@ -316,17 +396,35 @@ async fn handle(stream: TcpStream, shared: Shared) -> anyhow::Result<()> {
|
||||
));
|
||||
continue;
|
||||
};
|
||||
let source_name = name.clone();
|
||||
let audio = bytes.to_vec();
|
||||
let asset_audio = audio.clone();
|
||||
let import = tokio::task::spawn_blocking(move || {
|
||||
zhila_material::import_bytes(&name, seal, bytes.to_vec())
|
||||
zhila_material::import_bytes(&name, seal, audio)
|
||||
})
|
||||
.await;
|
||||
match import {
|
||||
Ok(Ok(core)) => {
|
||||
let summary = core.summary.clone();
|
||||
let asset = {
|
||||
let mut store =
|
||||
shared.store.lock().expect("project store lock poisoned");
|
||||
store.write_asset(&summary, &source_name, &asset_audio)
|
||||
};
|
||||
if let Err(error) = asset {
|
||||
warn!("project asset failed: {error}");
|
||||
let _ = direct_tx.send(error_message(
|
||||
Some(id),
|
||||
ErrorCode::Internal,
|
||||
"the asset could not be saved",
|
||||
));
|
||||
continue;
|
||||
}
|
||||
let state = {
|
||||
let mut session = shared.session.lock().await;
|
||||
session.import_core(core, Actor::Hand)
|
||||
};
|
||||
save_now(&shared).await;
|
||||
let _ = direct_tx.send(Outbound::Text(
|
||||
serde_json::to_string(&ServerMessage::Imported { id, core: summary })
|
||||
.expect("import serializes"),
|
||||
@@ -393,12 +491,23 @@ async fn handle(stream: TcpStream, shared: Shared) -> anyhow::Result<()> {
|
||||
}
|
||||
}
|
||||
ClientMessage::Command { id, command } => {
|
||||
let should_save = !matches!(
|
||||
&command,
|
||||
Command::Play
|
||||
| Command::Stop
|
||||
| Command::Strike { .. }
|
||||
| Command::StrikeFace { .. }
|
||||
| Command::GetSnapshot
|
||||
);
|
||||
let outcome = {
|
||||
let mut session = shared.session.lock().await;
|
||||
session.apply(command, Actor::Hand)
|
||||
};
|
||||
match outcome {
|
||||
Ok((result, emitted)) => {
|
||||
if should_save {
|
||||
save_now(&shared).await;
|
||||
}
|
||||
let revision = match &result {
|
||||
CommandResult::Snapshot { state } | CommandResult::State { state } => {
|
||||
state.revision
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "zhila-project"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
zhila-core = { path = "../zhila-core" }
|
||||
zhila-protocol = { path = "../zhila-protocol" }
|
||||
zhila-material = { path = "../zhila-material" }
|
||||
rusqlite = { version = "0.41", features = ["bundled"] }
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,502 @@
|
||||
//! ZHILA project store: SQLite plus content-addressed audio assets.
|
||||
//!
|
||||
//! The database is the mutable session; the assets are the source material.
|
||||
//! On load a core's audio is decoded again, so the faces and peaks cannot drift
|
||||
//! from the bytes. The project directory is portable: `project.db` + `assets/`.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
use serde_json::Value;
|
||||
use thiserror::Error;
|
||||
use zhila_core::{JournalEntry, ProjectData};
|
||||
use zhila_protocol::{CoreSummary, Pattern, Seal, VEIN_COUNT};
|
||||
|
||||
const SCHEMA_VERSION: &str = "1";
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ProjectError {
|
||||
#[error("project filesystem: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("project database: {0}")]
|
||||
Db(#[from] rusqlite::Error),
|
||||
#[error("project JSON: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error("material: {0}")]
|
||||
Material(String),
|
||||
}
|
||||
|
||||
pub struct LoadedProject {
|
||||
pub data: ProjectData,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct ProjectStore {
|
||||
root: PathBuf,
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
pub struct ProjectSnapshot<'a> {
|
||||
pub name: &'a str,
|
||||
pub revision: u64,
|
||||
pub bpm: f32,
|
||||
pub gains: &'a [f32; VEIN_COUNT],
|
||||
pub pattern: &'a Pattern,
|
||||
pub cores: &'a [CoreSummary],
|
||||
pub journal: &'a [JournalEntry],
|
||||
}
|
||||
|
||||
impl ProjectStore {
|
||||
pub fn open(root: impl AsRef<Path>) -> Result<Self, ProjectError> {
|
||||
let root = root.as_ref().to_path_buf();
|
||||
fs::create_dir_all(root.join("assets"))?;
|
||||
let conn = Connection::open(root.join("project.db"))?;
|
||||
conn.busy_timeout(std::time::Duration::from_secs(3))?;
|
||||
conn.execute_batch(
|
||||
"PRAGMA journal_mode = WAL;
|
||||
PRAGMA synchronous = NORMAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS project (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
name TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL,
|
||||
bpm REAL NOT NULL,
|
||||
gains TEXT NOT NULL,
|
||||
pattern TEXT NOT NULL,
|
||||
saved_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS cores (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
seal TEXT NOT NULL,
|
||||
asset TEXT NOT NULL DEFAULT '',
|
||||
source_name TEXT NOT NULL DEFAULT '',
|
||||
imported_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS journal (
|
||||
seq INTEGER PRIMARY KEY,
|
||||
revision INTEGER NOT NULL,
|
||||
actor TEXT NOT NULL,
|
||||
at_ms INTEGER NOT NULL,
|
||||
command TEXT NOT NULL
|
||||
);",
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO meta (key, value) VALUES ('schema', ?1)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
params![SCHEMA_VERSION],
|
||||
)?;
|
||||
Ok(Self { root, conn })
|
||||
}
|
||||
|
||||
pub fn root(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
|
||||
pub fn project_db(&self) -> PathBuf {
|
||||
self.root.join("project.db")
|
||||
}
|
||||
|
||||
pub fn load(&self) -> Result<Option<LoadedProject>, ProjectError> {
|
||||
let row = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT name, revision, bpm, gains, pattern FROM project WHERE id = 1",
|
||||
[],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, i64>(1)?,
|
||||
row.get::<_, f64>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
row.get::<_, String>(4)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.optional()?;
|
||||
let Some((name, revision, bpm, gains_json, pattern_json)) = row else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let gains_value: Vec<f32> = serde_json::from_str(&gains_json)?;
|
||||
let mut gains = zhila_core::INITIAL_GAINS;
|
||||
for (slot, value) in gains.iter_mut().zip(gains_value.iter()) {
|
||||
*slot = *value;
|
||||
}
|
||||
let mut pattern: Pattern = serde_json::from_str(&pattern_json)?;
|
||||
let mut warnings = Vec::new();
|
||||
|
||||
let mut cores = Vec::new();
|
||||
{
|
||||
let mut statement = self.conn.prepare(
|
||||
"SELECT id, name, seal, asset, source_name FROM cores ORDER BY imported_at",
|
||||
)?;
|
||||
let rows = statement.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
row.get::<_, String>(4)?,
|
||||
))
|
||||
})?;
|
||||
for row in rows {
|
||||
let (id, name, seal_text, asset, source_name) = row?;
|
||||
if asset.is_empty() {
|
||||
warnings.push(format!("{id}: no asset recorded"));
|
||||
continue;
|
||||
}
|
||||
let path = self.root.join(&asset);
|
||||
let bytes = match fs::read(&path) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => {
|
||||
warnings.push(format!("{id}: {error}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let seal: Seal = serde_json::from_str(&seal_text).unwrap_or(Seal::Unknown);
|
||||
match zhila_material::import_bytes(&source_name, seal, bytes) {
|
||||
Ok(mut core) => {
|
||||
core.summary.name = name;
|
||||
if core.summary.id != id {
|
||||
warnings.push(format!("{id}: content hash is {}", core.summary.id));
|
||||
}
|
||||
cores.push(core);
|
||||
}
|
||||
Err(error) => warnings.push(format!("{id}: {error}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for row in &mut pattern.rows {
|
||||
let missing = row.face.as_ref().and_then(|face| {
|
||||
let valid = cores.iter().any(|core| {
|
||||
core.summary.id == face.core
|
||||
&& core
|
||||
.faces
|
||||
.iter()
|
||||
.any(|candidate| candidate.index == face.face)
|
||||
});
|
||||
(!valid).then(|| format!("pattern {}: face {:?} missing", row.vein.name(), face))
|
||||
});
|
||||
if let Some(message) = missing {
|
||||
row.face = None;
|
||||
warnings.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
let mut journal = Vec::new();
|
||||
{
|
||||
let mut statement = self
|
||||
.conn
|
||||
.prepare("SELECT seq, revision, actor, at_ms, command FROM journal ORDER BY seq")?;
|
||||
let rows = statement.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, i64>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, i64>(3)?,
|
||||
row.get::<_, String>(4)?,
|
||||
))
|
||||
})?;
|
||||
for row in rows {
|
||||
let (seq, revision, actor_text, at_ms, command_text) = row?;
|
||||
let actor: zhila_protocol::Actor = match serde_json::from_str(&actor_text) {
|
||||
Ok(actor) => actor,
|
||||
Err(_) => {
|
||||
warnings.push(format!("journal {seq}: actor unreadable"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let command: zhila_protocol::Command = match serde_json::from_str(&command_text) {
|
||||
Ok(command) => command,
|
||||
Err(_) => {
|
||||
warnings.push(format!("journal {seq}: command unreadable"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
journal.push(JournalEntry {
|
||||
seq: seq as u64,
|
||||
revision: revision as u64,
|
||||
actor,
|
||||
at_ms: at_ms as u64,
|
||||
command,
|
||||
});
|
||||
}
|
||||
}
|
||||
let seq = journal.last().map(|entry| entry.seq).unwrap_or(0);
|
||||
|
||||
Ok(Some(LoadedProject {
|
||||
data: ProjectData {
|
||||
name,
|
||||
revision: revision as u64,
|
||||
bpm: bpm as f32,
|
||||
gains,
|
||||
pattern,
|
||||
cores,
|
||||
journal,
|
||||
seq,
|
||||
},
|
||||
warnings,
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn save(&mut self, snapshot: &ProjectSnapshot<'_>) -> Result<(), ProjectError> {
|
||||
let gains = serde_json::to_string(snapshot.gains)?;
|
||||
let pattern = serde_json::to_string(snapshot.pattern)?;
|
||||
let saved_at = now_ms();
|
||||
let tx = self.conn.transaction()?;
|
||||
tx.execute(
|
||||
"INSERT INTO project (id, name, revision, bpm, gains, pattern, saved_at)
|
||||
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
revision = excluded.revision,
|
||||
bpm = excluded.bpm,
|
||||
gains = excluded.gains,
|
||||
pattern = excluded.pattern,
|
||||
saved_at = excluded.saved_at",
|
||||
params![
|
||||
snapshot.name,
|
||||
snapshot.revision as i64,
|
||||
snapshot.bpm as f64,
|
||||
gains,
|
||||
pattern,
|
||||
saved_at as i64
|
||||
],
|
||||
)?;
|
||||
for core in snapshot.cores {
|
||||
tx.execute(
|
||||
"INSERT INTO cores (id, name, seal, imported_at)
|
||||
VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
seal = excluded.seal",
|
||||
params![
|
||||
core.id,
|
||||
core.name,
|
||||
serde_json::to_string(&core.seal)?,
|
||||
now_ms() as i64
|
||||
],
|
||||
)?;
|
||||
}
|
||||
tx.execute("DELETE FROM journal", [])?;
|
||||
for entry in snapshot.journal {
|
||||
tx.execute(
|
||||
"INSERT INTO journal (seq, revision, actor, at_ms, command)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![
|
||||
entry.seq as i64,
|
||||
entry.revision as i64,
|
||||
serde_json::to_string(&entry.actor)?,
|
||||
entry.at_ms as i64,
|
||||
serde_json::to_string(&entry.command)?
|
||||
],
|
||||
)?;
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write the encoded source bytes and record the core row. Called once per
|
||||
/// import; `save` later keeps the name/seal in sync.
|
||||
pub fn write_asset(
|
||||
&mut self,
|
||||
summary: &CoreSummary,
|
||||
source_name: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<(), ProjectError> {
|
||||
let extension = Path::new(source_name)
|
||||
.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.map(|value| {
|
||||
value
|
||||
.chars()
|
||||
.filter(|character| character.is_ascii_alphanumeric())
|
||||
.take(8)
|
||||
.collect::<String>()
|
||||
.to_ascii_lowercase()
|
||||
})
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| "bin".into());
|
||||
let asset = format!("assets/{}.{}", summary.id, extension);
|
||||
let path = self.root.join(&asset);
|
||||
let temporary = path.with_extension("tmp");
|
||||
fs::write(&temporary, bytes)?;
|
||||
fs::rename(&temporary, &path)?;
|
||||
self.conn.execute(
|
||||
"INSERT INTO cores (id, name, seal, asset, source_name, imported_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
seal = excluded.seal,
|
||||
asset = excluded.asset,
|
||||
source_name = excluded.source_name,
|
||||
imported_at = excluded.imported_at",
|
||||
params![
|
||||
summary.id,
|
||||
summary.name,
|
||||
serde_json::to_string(&summary.seal)?,
|
||||
asset,
|
||||
source_name,
|
||||
now_ms() as i64
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn asset_path(&self, asset: &str) -> Option<PathBuf> {
|
||||
let relative = Path::new(asset);
|
||||
if relative.is_absolute()
|
||||
|| relative.components().any(|component| {
|
||||
matches!(
|
||||
component,
|
||||
std::path::Component::ParentDir
|
||||
| std::path::Component::RootDir
|
||||
| std::path::Component::Prefix(_)
|
||||
)
|
||||
})
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let path = self.root.join(relative);
|
||||
path.starts_with(&self.root).then_some(path)
|
||||
}
|
||||
|
||||
pub fn read_asset(&self, asset: &str) -> Result<Vec<u8>, ProjectError> {
|
||||
let path = self
|
||||
.asset_path(asset)
|
||||
.ok_or_else(|| ProjectError::Material("asset escapes the project".into()))?;
|
||||
Ok(fs::read(path)?)
|
||||
}
|
||||
|
||||
pub fn project_name(&self) -> Result<Option<String>, ProjectError> {
|
||||
let name = self
|
||||
.conn
|
||||
.query_row("SELECT name FROM project WHERE id = 1", [], |row| {
|
||||
row.get::<_, String>(0)
|
||||
})
|
||||
.optional()?;
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
pub fn debug_value(&self, sql: &str) -> Result<Value, ProjectError> {
|
||||
let mut statement = self.conn.prepare(sql)?;
|
||||
let mut rows = statement.query([])?;
|
||||
if let Some(row) = rows.next()? {
|
||||
let value: String = row.get(0)?;
|
||||
return Ok(serde_json::from_str(&value).unwrap_or(Value::String(value)));
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
}
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis() as u64)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use zhila_core::Core;
|
||||
use zhila_protocol::{Actor, Command, FaceRef, PatternRow, Seal};
|
||||
|
||||
fn fixture() -> Vec<u8> {
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/clicks-01.wav");
|
||||
std::fs::read(path).expect("fixture")
|
||||
}
|
||||
|
||||
fn pattern_with_face(core: &Core) -> Pattern {
|
||||
let top = core
|
||||
.summary
|
||||
.faces
|
||||
.iter()
|
||||
.find(|face| face.rank == 0)
|
||||
.map(|face| face.index)
|
||||
.unwrap_or(0);
|
||||
let mut rows = Vec::new();
|
||||
for vein in zhila_protocol::VeinId::ALL {
|
||||
let mut velocities = vec![0u8; 16];
|
||||
if vein == zhila_protocol::VeinId::Bass {
|
||||
velocities[0] = 96;
|
||||
velocities[8] = 72;
|
||||
}
|
||||
rows.push(PatternRow {
|
||||
vein,
|
||||
face: (vein == zhila_protocol::VeinId::Bass).then(|| FaceRef {
|
||||
core: core.summary.id.clone(),
|
||||
face: top,
|
||||
}),
|
||||
velocities,
|
||||
});
|
||||
}
|
||||
Pattern {
|
||||
steps: 16,
|
||||
steps_per_bar: 8,
|
||||
rows,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saves_and_loads_the_project_with_assets() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut store = ProjectStore::open(dir.path()).unwrap();
|
||||
assert!(store.load().unwrap().is_none());
|
||||
|
||||
let bytes = fixture();
|
||||
let core =
|
||||
zhila_material::import_bytes("clicks-01.wav", Seal::Found, bytes.clone()).unwrap();
|
||||
let pattern = pattern_with_face(&core);
|
||||
let gains = [0.8, 0.7, 0.6, 0.5, 0.4];
|
||||
let journal = vec![JournalEntry {
|
||||
seq: 1,
|
||||
revision: 1,
|
||||
actor: Actor::Hand,
|
||||
at_ms: 12,
|
||||
command: Command::SetTempo { bpm: 132.0 },
|
||||
}];
|
||||
store
|
||||
.write_asset(&core.summary, "clicks-01.wav", &bytes)
|
||||
.unwrap();
|
||||
store
|
||||
.save(&ProjectSnapshot {
|
||||
name: "clicks project",
|
||||
revision: 4,
|
||||
bpm: 132.0,
|
||||
gains: &gains,
|
||||
pattern: &pattern,
|
||||
cores: std::slice::from_ref(&core.summary),
|
||||
journal: &journal,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
drop(store);
|
||||
let store = ProjectStore::open(dir.path()).unwrap();
|
||||
let loaded = store.load().unwrap().expect("project");
|
||||
assert_eq!(loaded.data.name, "clicks project");
|
||||
assert_eq!(loaded.data.revision, 4);
|
||||
assert_eq!(loaded.data.bpm, 132.0);
|
||||
assert_eq!(loaded.data.gains, gains);
|
||||
assert_eq!(loaded.data.cores.len(), 1);
|
||||
assert_eq!(loaded.data.cores[0].summary.id, core.summary.id);
|
||||
assert_eq!(loaded.data.cores[0].faces.len(), 16);
|
||||
assert_eq!(loaded.data.pattern.rows.len(), 5);
|
||||
assert_eq!(loaded.data.pattern.rows[0].velocities[0], 96);
|
||||
assert_eq!(
|
||||
loaded.data.pattern.rows[0].face.as_ref().unwrap().core,
|
||||
core.summary.id
|
||||
);
|
||||
assert_eq!(loaded.data.journal.len(), 1);
|
||||
assert_eq!(loaded.data.seq, 1);
|
||||
assert!(loaded.warnings.is_empty());
|
||||
assert!(store.asset_path("assets/../project.db").is_none());
|
||||
}
|
||||
}
|
||||
@@ -202,6 +202,8 @@ pub enum Command {
|
||||
core: String,
|
||||
face: u8,
|
||||
},
|
||||
Undo,
|
||||
Redo,
|
||||
/// Journal-only: a core was imported through the binary path.
|
||||
ImportCore {
|
||||
name: String,
|
||||
@@ -248,6 +250,10 @@ pub enum Event {
|
||||
face: u8,
|
||||
velocity: f32,
|
||||
},
|
||||
History {
|
||||
can_undo: bool,
|
||||
can_redo: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -284,6 +290,9 @@ impl AudioInfo {
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct State {
|
||||
pub revision: u64,
|
||||
pub project_name: String,
|
||||
pub can_undo: bool,
|
||||
pub can_redo: bool,
|
||||
pub transport: TransportState,
|
||||
pub veins: Vec<VeinState>,
|
||||
pub pattern: Pattern,
|
||||
|
||||
Reference in New Issue
Block a user