P1b: SQLite project, content-addressed assets, autosave and undo
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user