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
+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