P1b: SQLite project, content-addressed assets, autosave and undo

This commit is contained in:
Emil
2026-11-01 01:19:55 +03:00
parent 4f4ee05a3a
commit 0acf11ccb0
22 changed files with 1405 additions and 32 deletions
+255 -8
View File
@@ -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();
+1
View File
@@ -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
+117 -8
View File
@@ -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
+16
View File
@@ -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"
+502
View File
@@ -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());
}
}
+9
View File
@@ -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,