The web build could not reach any file: File > Open Session…, Import > Audio… and Import > MIDI… showed a text box for a path the browser can't read, and every save or export failed with «operation not supported on this platform», so work was lost on close. On the web now: - Open Session…, Import Audio… and Import MIDI… show the browser's file picker; files dropped on the page open (sessions) or import (audio, MIDI) by extension. Files travel as bytes; imports are one undo step each, like the import commands. - A session is saved as ONE file, a bundle `<name>.scraftz`: a zip (stored entries) holding the `.scraft` and its `Audio Files/` as 32-bit float WAV — the same layout as a session folder, since the browser has no folders. `io::open_session_bytes` opens a bundle or a bare `.scraft` (its audio is then reported missing). Bundles are hostile input: entry count and size are capped, and declared sizes are never read past. - Every single-file output (save, Save As Template, bounce, MIDI/score/text export, track presets) goes through `io::write_file`: a file natively, a download in the browser via a delivery hook (`io::set_deliver`) that only the web app installs. - In the browser Save As asks for the bundle's name (`<name>.scraftz`) instead of a folder path. Native builds keep writing files and session folders as before. Folder outputs (stems, clip exports) are still not available on the web. Pairs with #157 (Save on a never-saved session opens Save As), which the browser needs too: the web app always starts on the unsaved demo. New dependency: `zip` (MIT), default features off (stored entries only). Checked: engine tests for the bundle round trip (every sample identical), same-named sources, bare sessions and hostile bundles; `cargo xtask ci`. In Chromium on the packaged web build: ⌘⇧I picks and imports a WAV; WAV, MP3, FLAC, OGG, M4A, AIFF and MIDI import by drop; Save As → `Midnight Groove.scraftz` downloads with the imported audio inside; that bundle reopens in a fresh page with all tracks and no missing media, and saves again with the same content. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
330a698397
commit
6964c50d00
|
|
@ -2133,6 +2133,18 @@ dependencies = [
|
|||
"jni-sys 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.31.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nohash-hasher"
|
||||
version = "0.2.0"
|
||||
|
|
@ -3502,6 +3514,7 @@ dependencies = [
|
|||
"soundcraft-video",
|
||||
"soundcraft-vst3-host",
|
||||
"thiserror 2.0.21",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3623,8 +3636,12 @@ version = "0.4.0"
|
|||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
"js-sys",
|
||||
"log",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"soundcraft-engine",
|
||||
"soundcraft-model",
|
||||
"soundcraft-playback",
|
||||
"soundcraft-ui-egui",
|
||||
"wasm-bindgen",
|
||||
|
|
@ -4084,6 +4101,12 @@ dependencies = [
|
|||
"rustc-hash 2.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typed-path"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
version = "1.2.1"
|
||||
|
|
@ -5374,6 +5397,19 @@ dependencies = [
|
|||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "9.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e49ade69c4434c55dd651d36fe05f38e0578db948b773cbc01c48c4b5e560692"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"indexmap",
|
||||
"memchr",
|
||||
"nix",
|
||||
"typed-path",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.8"
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ proptest = "1"
|
|||
# audio
|
||||
symphonia = { version = "0.5", default-features = false, features = ["mp3", "flac", "vorbis", "ogg", "wav", "pcm", "aiff", "isomp4", "aac", "alac", "adpcm", "caf", "mkv"] }
|
||||
rustfft = "6"
|
||||
# Session bundles (one file holding a session and its audio, for the browser): stored entries only.
|
||||
zip = { version = "9", default-features = false }
|
||||
cpal = "0.16"
|
||||
|
||||
# ui
|
||||
|
|
|
|||
|
|
@ -17,10 +17,14 @@ path = "src/main.rs"
|
|||
soundcraft-engine = { workspace = true }
|
||||
soundcraft-ui-egui = { workspace = true }
|
||||
soundcraft-playback = { workspace = true }
|
||||
soundcraft-model = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
js-sys = "0.3"
|
||||
# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback.
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "Location"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "CssStyleDeclaration"] }
|
||||
|
|
|
|||
|
|
@ -7,7 +7,10 @@
|
|||
//!
|
||||
//! Differences from the desktop app:
|
||||
//! - no TCP control channel (browsers can't listen on sockets);
|
||||
//! - no native file dialogs: the demo session is loaded on start;
|
||||
//! - no file system: File > Open Session…, Import > Audio…/MIDI… and files dropped on the page are
|
||||
//! read as bytes through the browser's file picker; a session is saved as ONE bundle file
|
||||
//! (`<name>.scraftz`: the `.scraft` and its `Audio Files/`), and every save or export arrives as a
|
||||
//! download (the demo session is still loaded on start);
|
||||
//! - browsers only start audio after a user gesture, so the first click may be needed before
|
||||
//! playback is heard.
|
||||
//!
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
//! The browser shell: the eframe web runner around [`SoundApp`].
|
||||
|
||||
use soundcraft_engine::Engine;
|
||||
use serde_json::json;
|
||||
use soundcraft_engine::{Engine, io};
|
||||
use soundcraft_ui_egui::dialogs::Dialog;
|
||||
use soundcraft_ui_egui::{Services, SoundApp};
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use wasm_bindgen::JsCast as _;
|
||||
|
||||
|
|
@ -10,6 +14,7 @@ const LOADING_ID: &str = "soundcraft_loading";
|
|||
|
||||
pub fn start() {
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
io::set_deliver(download);
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
let Some(document) = web_sys::window().and_then(|w| w.document()) else {
|
||||
log::error!("no document");
|
||||
|
|
@ -36,7 +41,7 @@ pub fn start() {
|
|||
let engine = if query().contains("empty") { Engine::default() } else { soundcraft_engine::demo::demo_engine() };
|
||||
let player = soundcraft_playback::Player::new(Arc::new(engine.session().clone()));
|
||||
let app = SoundApp::new(engine, Some(player), Services::default());
|
||||
Ok(Box::new(WebShell(app)))
|
||||
Ok(Box::new(WebShell { app, inbox: Rc::default() }))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
|
@ -53,18 +58,182 @@ fn query() -> String {
|
|||
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
struct WebShell(SoundApp);
|
||||
/// What a file read from the browser is for.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum Purpose {
|
||||
Session,
|
||||
Audio,
|
||||
Midi,
|
||||
}
|
||||
|
||||
const SESSION_EXTS: &[&str] = &[io::BUNDLE_EXTENSION, soundcraft_model::SESSION_EXTENSION];
|
||||
const AUDIO_EXTS: &[&str] = &["wav", "wave", "bwf", "aif", "aiff", "aifc", "flac", "mp3", "ogg", "oga", "m4a", "aac", "mp4", "caf", "mkv", "webm"];
|
||||
const MIDI_EXTS: &[&str] = &["mid", "midi", "smf"];
|
||||
|
||||
impl Purpose {
|
||||
/// A dropped file goes where its extension says: sessions open, MIDI and audio import.
|
||||
fn of(name: &str) -> Self {
|
||||
let ext = name.rsplit_once('.').map(|(_, x)| x.to_ascii_lowercase()).unwrap_or_default();
|
||||
if SESSION_EXTS.contains(&ext.as_str()) {
|
||||
Self::Session
|
||||
} else if MIDI_EXTS.contains(&ext.as_str()) {
|
||||
Self::Midi
|
||||
} else {
|
||||
Self::Audio
|
||||
}
|
||||
}
|
||||
|
||||
/// The command whose path prompt this replaces in the browser.
|
||||
fn for_command(cmd: &str) -> Option<Self> {
|
||||
match cmd {
|
||||
"session.open" => Some(Self::Session),
|
||||
"file.import_audio" => Some(Self::Audio),
|
||||
"file.import_midi" => Some(Self::Midi),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn filter(self) -> (&'static str, &'static [&'static str]) {
|
||||
match self {
|
||||
Self::Session => ("SoundCraft Session", SESSION_EXTS),
|
||||
Self::Audio => ("Audio", AUDIO_EXTS),
|
||||
Self::Midi => ("MIDI", MIDI_EXTS),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Files picked or dropped, read asynchronously (browsers can only read files that way) and
|
||||
/// handled on the next frame. The browser has no paths: files travel as bytes.
|
||||
type Inbox = Rc<RefCell<Vec<(Purpose, String, Vec<u8>)>>>;
|
||||
|
||||
struct WebShell {
|
||||
app: SoundApp,
|
||||
inbox: Inbox,
|
||||
}
|
||||
|
||||
impl WebShell {
|
||||
/// File > Open Session… / Import > Audio… / MIDI… would show a text box for a path, which the
|
||||
/// browser can't read (#63, #64). Show the browser's file picker instead.
|
||||
fn take_path_prompt(&mut self, ctx: &egui::Context) {
|
||||
let purpose = match &self.app.dialogs.open {
|
||||
Some(Dialog::PathPrompt { cmd, .. }) => Purpose::for_command(cmd),
|
||||
_ => None,
|
||||
};
|
||||
let Some(purpose) = purpose else { return };
|
||||
self.app.dialogs.open = None;
|
||||
let (inbox, ctx) = (self.inbox.clone(), ctx.clone());
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let (label, exts) = purpose.filter();
|
||||
let Some(file) = rfd::AsyncFileDialog::new().add_filter(label, exts).pick_file().await else { return };
|
||||
let bytes = file.read().await;
|
||||
inbox.borrow_mut().push((purpose, file.file_name(), bytes));
|
||||
ctx.request_repaint();
|
||||
});
|
||||
}
|
||||
|
||||
fn take_drops(&mut self, ctx: &egui::Context) {
|
||||
// In the browser a dropped file's `path()` is only its name: read its bytes instead.
|
||||
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
|
||||
for f in dropped {
|
||||
let name = f.path().file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "Audio.wav".into());
|
||||
let (inbox, ctx) = (self.inbox.clone(), ctx.clone());
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
match f.bytes_async().await {
|
||||
Ok(bytes) => {
|
||||
inbox.borrow_mut().push((Purpose::of(&name), name, bytes));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn handle(&mut self, purpose: Purpose, name: &str, bytes: &[u8]) {
|
||||
let e = &mut self.app.engine;
|
||||
let at = e.session().edit.selection.start.max(0);
|
||||
let result = match purpose {
|
||||
Purpose::Session => {
|
||||
let _ = self.app.run("transport.stop", json!({}));
|
||||
io::open_session_bytes(&mut self.app.engine, name, bytes).map(|missing| {
|
||||
if missing.is_empty() { format!("Opened {name}") } else { format!("Opened {name} ({} missing media)", missing.len()) }
|
||||
})
|
||||
}
|
||||
// Imports are edits: one undo step each, like the import commands.
|
||||
Purpose::Audio => {
|
||||
let before = e.session_arc();
|
||||
io::import_audio_bytes(e, name, bytes, None, None, at).map(|_| {
|
||||
e.push_undo("Import Audio", before);
|
||||
format!("Imported {name}")
|
||||
})
|
||||
}
|
||||
Purpose::Midi => {
|
||||
let before = e.session_arc();
|
||||
io::import_midi_bytes(e, bytes, at, true).map(|_| {
|
||||
e.push_undo("Import MIDI", before);
|
||||
format!("Imported {name}")
|
||||
})
|
||||
}
|
||||
};
|
||||
self.app.ui.status = result.unwrap_or_else(|err| err.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for WebShell {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.0.logic(ctx);
|
||||
self.take_drops(ctx);
|
||||
self.take_path_prompt(ctx);
|
||||
let pending = std::mem::take(&mut *self.inbox.borrow_mut());
|
||||
for (purpose, name, bytes) in pending {
|
||||
self.handle(purpose, &name, &bytes);
|
||||
}
|
||||
self.app.logic(ctx);
|
||||
// A prompt opened during this frame becomes a picker on the next one.
|
||||
self.take_path_prompt(ctx);
|
||||
}
|
||||
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.0.raw_input_hook(raw);
|
||||
self.app.raw_input_hook(raw);
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.0.ui(ui);
|
||||
self.app.ui(ui);
|
||||
}
|
||||
}
|
||||
|
||||
/// Hand a saved or exported file to the person as a download named `name` (the engine's delivery
|
||||
/// hook on the web).
|
||||
fn download(name: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
let js = |e: wasm_bindgen::JsValue| format!("download failed: {e:?}");
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let document = window.document().ok_or("no document")?;
|
||||
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
|
||||
let opts = web_sys::BlobPropertyBag::new();
|
||||
opts.set_type(match name.rsplit_once('.').map(|(_, x)| x.to_ascii_lowercase()).as_deref() {
|
||||
Some("scraftz") => "application/zip",
|
||||
Some("wav") => "audio/wav",
|
||||
Some("aif" | "aiff") => "audio/aiff",
|
||||
Some("flac") => "audio/flac",
|
||||
Some("mid" | "midi") => "audio/midi",
|
||||
Some("svg") => "image/svg+xml",
|
||||
Some("txt") => "text/plain",
|
||||
Some("json" | "scraft") => "application/json",
|
||||
Some("musicxml") => "application/vnd.recordare.musicxml+xml",
|
||||
_ => "application/octet-stream",
|
||||
});
|
||||
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?;
|
||||
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?;
|
||||
let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(js)?.dyn_into().map_err(|_| "not an anchor")?;
|
||||
a.set_href(&url);
|
||||
a.set_download(name);
|
||||
a.style().set_property("display", "none").map_err(js)?;
|
||||
document.body().ok_or("no body")?.append_child(&a).map_err(js)?;
|
||||
a.click();
|
||||
a.remove();
|
||||
// Revoke after the click was dispatched; the download keeps its own reference.
|
||||
let revoke = wasm_bindgen::closure::Closure::once_into_js(move || {
|
||||
let _ = web_sys::Url::revoke_object_url(&url);
|
||||
});
|
||||
window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000).map_err(js)?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ serde.workspace = true
|
|||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
log.workspace = true
|
||||
zip.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
proptest.workspace = true
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
s.sources.clear();
|
||||
s.videos.clear();
|
||||
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
std::fs::write(&path, text).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
|
||||
crate::io::write_file(&path, text.as_bytes())?;
|
||||
Ok(json!({"path": path}))
|
||||
}),
|
||||
cmd!(noundo "session.revert", "Revert Session to Saved...", ["File"], None, "{}", always, |e, _| {
|
||||
|
|
@ -99,13 +99,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
cmd!(noundo "file.export_midi", "MIDI...", ["File", "Export"], None, "{path, tracks?}", always, |e, p| {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("file.export_midi", "`path` required"))?.to_string();
|
||||
let bytes = crate::io::export_midi(e)?;
|
||||
std::fs::write(&path, &bytes).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
|
||||
crate::io::write_file(&path, &bytes)?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len()}))
|
||||
}),
|
||||
cmd!(noundo "file.export_session_text", "Session Info as Text...", ["File", "Export"], None, "{path?}", always, |e, p| {
|
||||
let text = crate::inspect::session_text(e);
|
||||
if let Some(path) = str_param(p, "path") {
|
||||
std::fs::write(path, &text).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
|
||||
crate::io::write_file(path, text.as_bytes())?;
|
||||
}
|
||||
Ok(json!({"text": text}))
|
||||
}),
|
||||
|
|
@ -261,7 +261,7 @@ fn bounce(e: &mut Engine, p: &Value) -> Result<Value> {
|
|||
let normalize = bool_or(p, "normalize", false);
|
||||
let fold = fold_down_param(p, "file.bounce_mix")?;
|
||||
let (bytes, stats) = crate::io::bounce_bytes_with(e, r, &opts, normalize, fold)?;
|
||||
std::fs::write(&path, &bytes).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
|
||||
crate::io::write_file(&path, &bytes)?;
|
||||
Ok(
|
||||
json!({"path": path, "bytes": bytes.len(), "seconds": e.session().sample_rate.seconds(r.len()), "peak_db": stats.0, "lufs": stats.1, "true_peak_db": stats.2}),
|
||||
)
|
||||
|
|
@ -280,7 +280,7 @@ fn write_score(e: &mut Engine, p: &Value, id: &str, svg: bool) -> Result<Value>
|
|||
Some(p) => p.to_string(),
|
||||
None => std::env::temp_dir().join(format!("{}.{ext}", crate::io::sanitize_name(&setup.title))).to_string_lossy().to_string(),
|
||||
};
|
||||
std::fs::write(&path, &text).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
|
||||
crate::io::write_file(&path, text.as_bytes())?;
|
||||
let measures = parts.iter().map(|p| p.measures.len()).max().unwrap_or(0);
|
||||
Ok(json!({"path": path, "parts": parts.len(), "measures": measures}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -263,7 +263,7 @@ fn save_preset(e: &mut Engine, p: &Value) -> Result<Value> {
|
|||
"comments": tr.comments,
|
||||
});
|
||||
let text = serde_json::to_string_pretty(&preset).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
std::fs::write(&path, text).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
|
||||
crate::io::write_file(&path, text.as_bytes())?;
|
||||
Ok(json!({"path": path, "track": first}))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -456,7 +456,13 @@ pub fn sanitize_name(n: &str) -> String {
|
|||
|
||||
/// Save the session to `path` (a `.scraft` file). Audio that only exists in memory is written
|
||||
/// as 32-bit float WAV into `Audio Files/` next to it. Returns how many audio files were written.
|
||||
///
|
||||
/// In the browser there are no folders: the session and all its audio go out as one bundle file
|
||||
/// (`<name>.scraftz`, see [`session_bundle`]) through the host's delivery hook ([`set_deliver`]).
|
||||
pub fn save_session(e: &mut Engine, path: &str) -> Result<usize> {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return save_session_bundle(e, path);
|
||||
}
|
||||
let (fresh, written, p) = write_session(e.session(), path, false)?;
|
||||
// Keep the in-memory document in sync (paths, unsaved flags) without an undo step.
|
||||
*e.session_mut() = fresh;
|
||||
|
|
@ -535,16 +541,11 @@ pub fn open_session(e: &mut Engine, path: &str) -> Result<Vec<String>> {
|
|||
for src in s.sources.clone() {
|
||||
let p = if Path::new(&src.path).is_absolute() { PathBuf::from(&src.path) } else { dir.join(&src.path) };
|
||||
match std::fs::read(&p) {
|
||||
Ok(bytes) => match soundcraft_audio_io::decode(&bytes, p.extension().and_then(|x| x.to_str())) {
|
||||
Ok((_, mut buf)) => {
|
||||
if buf.sample_rate != s.sample_rate.hz() && buf.sample_rate > 0 {
|
||||
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, s.sample_rate.hz());
|
||||
buf.sample_rate = s.sample_rate.hz();
|
||||
Ok(bytes) => {
|
||||
if let Err(err) = load_source_audio(&mut s, src.id, &bytes, p.extension().and_then(|x| x.to_str())) {
|
||||
missing.push(format!("{}: {err}", p.display()));
|
||||
}
|
||||
s.pool.insert(src.id, Arc::new(SourceAudio::new(buf)));
|
||||
}
|
||||
Err(err) => missing.push(format!("{}: {err}", p.display())),
|
||||
},
|
||||
Err(_) => missing.push(p.display().to_string()),
|
||||
}
|
||||
}
|
||||
|
|
@ -557,6 +558,173 @@ pub fn open_session(e: &mut Engine, path: &str) -> Result<Vec<String>> {
|
|||
Ok(missing)
|
||||
}
|
||||
|
||||
/// Decode one source's audio file into the session pool, at the session rate.
|
||||
fn load_source_audio(s: &mut Session, id: SourceId, bytes: &[u8], ext: Option<&str>) -> std::result::Result<(), String> {
|
||||
let (_, mut buf) = soundcraft_audio_io::decode(bytes, ext).map_err(|err| err.to_string())?;
|
||||
if buf.sample_rate != s.sample_rate.hz() && buf.sample_rate > 0 {
|
||||
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, s.sample_rate.hz());
|
||||
buf.sample_rate = s.sample_rate.hz();
|
||||
}
|
||||
s.pool.insert(id, Arc::new(SourceAudio::new(buf)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── Hosts without a filesystem (the browser) ─────────────────────────────────────────────────
|
||||
|
||||
/// Extension of a session bundle: one zip file holding the `.scraft` session and its
|
||||
/// `Audio Files/`, the same layout as a saved session folder. The browser has no folders, so it
|
||||
/// saves and opens sessions as bundles.
|
||||
pub const BUNDLE_EXTENSION: &str = "scraftz";
|
||||
|
||||
/// Most entries and bytes a bundle may unpack to (bundles are hostile input).
|
||||
const MAX_BUNDLE_ENTRIES: usize = 10_000;
|
||||
const MAX_BUNDLE_BYTES: u64 = 4 << 30;
|
||||
|
||||
/// Hands a finished file (name, bytes) to the person on a host without a filesystem: the web app
|
||||
/// installs one that starts a download. Native builds write files and never install it.
|
||||
pub type DeliverHook = fn(&str, &[u8]) -> std::result::Result<(), String>;
|
||||
|
||||
static DELIVER: std::sync::OnceLock<DeliverHook> = std::sync::OnceLock::new();
|
||||
|
||||
/// Install the delivery hook (once, at start-up of the web app).
|
||||
pub fn set_deliver(h: DeliverHook) {
|
||||
let _ = DELIVER.set(h);
|
||||
}
|
||||
|
||||
/// Write one output file: to `path` natively; in the browser, delivered under its file name.
|
||||
/// Without this, every export on the web failed with «operation not supported on this platform».
|
||||
pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
let deliver = DELIVER.get().ok_or_else(|| EngineError::Io("saving files is not available in this build".into()))?;
|
||||
let name = Path::new(path).file_name().and_then(|n| n.to_str()).filter(|n| !n.is_empty()).unwrap_or("Untitled");
|
||||
return deliver(name, bytes).map_err(EngineError::Io);
|
||||
}
|
||||
std::fs::write(path, bytes).map_err(|err| EngineError::Io(format!("{path}: {err}")))
|
||||
}
|
||||
|
||||
/// The session as a bundle: `<name>.scraft` plus every source's audio as 32-bit float WAV under
|
||||
/// `Audio Files/` (stored, not compressed: audio barely compresses). Returns the bytes and the
|
||||
/// session with its source paths pointing into the bundle.
|
||||
pub fn session_bundle(session: &Session) -> Result<(Vec<u8>, Session)> {
|
||||
use std::io::Write as _;
|
||||
let zerr = |err: zip::result::ZipError| EngineError::Io(format!("bundle: {err}"));
|
||||
let ioerr = |err: std::io::Error| EngineError::Io(format!("bundle: {err}"));
|
||||
let mut s = session.clone();
|
||||
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored).large_file(true);
|
||||
let mut taken = std::collections::HashSet::new();
|
||||
for src in &mut s.sources {
|
||||
let Some(audio) = s.pool.get(src.id) else { continue };
|
||||
let base = sanitize_name(&src.name);
|
||||
let mut target = format!("Audio Files/{base}.wav");
|
||||
let mut k = 1;
|
||||
while taken.contains(&target) && k <= 9_999 {
|
||||
target = format!("Audio Files/{base}_{k:02}.wav");
|
||||
k += 1;
|
||||
}
|
||||
let opts_wav = EncodeOptions { format: FileFormat::Wav, bit_depth: soundcraft_audio_io::BitDepth::Float32, dither: false, bwf: None };
|
||||
let bytes = soundcraft_audio_io::encode(&audio.buffer, &opts_wav).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
w.start_file(target.as_str(), opts).map_err(zerr)?;
|
||||
w.write_all(&bytes).map_err(ioerr)?;
|
||||
taken.insert(target.clone());
|
||||
src.path = target;
|
||||
src.unsaved = false;
|
||||
src.format = FileFormat::Wav;
|
||||
}
|
||||
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
w.start_file(format!("{}.{}", sanitize_name(&s.name), soundcraft_model::SESSION_EXTENSION), opts).map_err(zerr)?;
|
||||
w.write_all(text.as_bytes()).map_err(ioerr)?;
|
||||
let bytes = w.finish().map_err(zerr)?.into_inner();
|
||||
Ok((bytes, s))
|
||||
}
|
||||
|
||||
/// Save as a bundle named after `path`'s file name, delivered through the host ([`write_file`]).
|
||||
fn save_session_bundle(e: &mut Engine, path: &str) -> Result<usize> {
|
||||
let stem = Path::new(path).file_stem().and_then(|x| x.to_str()).filter(|x| !x.is_empty()).map(str::to_string);
|
||||
let mut s = e.session().clone();
|
||||
if let Some(stem) = stem {
|
||||
s.name = stem;
|
||||
}
|
||||
let (bytes, fresh) = session_bundle(&s)?;
|
||||
let file = format!("{}.{BUNDLE_EXTENSION}", sanitize_name(&fresh.name));
|
||||
write_file(&file, &bytes)?;
|
||||
let written = fresh.sources.iter().filter(|x| fresh.pool.get(x.id).is_some()).count();
|
||||
*e.session_mut() = fresh;
|
||||
e.path = Some(file);
|
||||
e.mark_clean();
|
||||
Ok(written)
|
||||
}
|
||||
|
||||
/// Open a session from bytes (the browser has no paths): a bundle ([`session_bundle`]) or a bare
|
||||
/// `.scraft`, whose audio is then reported missing. Returns the missing media, like
|
||||
/// [`open_session`].
|
||||
pub fn open_session_bytes(e: &mut Engine, name: &str, bytes: &[u8]) -> Result<Vec<String>> {
|
||||
let (text, mut files) = if bytes.starts_with(b"PK\x03\x04") {
|
||||
read_bundle(bytes)?
|
||||
} else {
|
||||
(
|
||||
String::from_utf8(bytes.to_vec()).map_err(|_| EngineError::Io(format!("{name}: not a SoundCraft session")))?,
|
||||
std::collections::HashMap::new(),
|
||||
)
|
||||
};
|
||||
let mut s = Session::from_json(&text).map_err(|err| EngineError::Io(format!("{name}: {err}")))?;
|
||||
let mut missing = Vec::new();
|
||||
for src in s.sources.clone() {
|
||||
match files.remove(&src.path) {
|
||||
Some(audio) => {
|
||||
let ext = Path::new(&src.path).extension().and_then(|x| x.to_str()).map(str::to_string);
|
||||
if let Err(err) = load_source_audio(&mut s, src.id, &audio, ext.as_deref()) {
|
||||
missing.push(format!("{}: {err}", src.path));
|
||||
}
|
||||
}
|
||||
None => missing.push(src.path.clone()),
|
||||
}
|
||||
}
|
||||
// Movies are referenced by path, never copied into a session: none can be found here.
|
||||
missing.extend(s.videos.iter().map(|v| v.path.clone()));
|
||||
let stem = Path::new(name).file_stem().and_then(|x| x.to_str()).filter(|x| !x.is_empty()).unwrap_or("Untitled").to_string();
|
||||
e.replace_session(s);
|
||||
e.path = Some(format!("{}.{BUNDLE_EXTENSION}", sanitize_name(&stem)));
|
||||
for m in &missing {
|
||||
e.message(format!("missing media: {m}"));
|
||||
}
|
||||
Ok(missing)
|
||||
}
|
||||
|
||||
/// Unpack a bundle: the one `.scraft` at its top level and the files beside it, by relative path.
|
||||
fn read_bundle(bytes: &[u8]) -> Result<(String, std::collections::HashMap<String, Vec<u8>>)> {
|
||||
use std::io::Read as _;
|
||||
let zerr = |err: zip::result::ZipError| EngineError::Io(format!("bundle: {err}"));
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(zerr)?;
|
||||
if z.len() > MAX_BUNDLE_ENTRIES {
|
||||
return Err(EngineError::Io("bundle: too many files".into()));
|
||||
}
|
||||
let (mut text, mut files, mut total) = (None, std::collections::HashMap::new(), 0u64);
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).map_err(zerr)?;
|
||||
if f.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let rel = f.name().map_err(zerr)?.to_string();
|
||||
let size = f.size();
|
||||
total = total.saturating_add(size);
|
||||
if total > MAX_BUNDLE_BYTES {
|
||||
return Err(EngineError::Io("bundle: too large".into()));
|
||||
}
|
||||
let mut data = Vec::new();
|
||||
// The declared size is hostile too: never read past it.
|
||||
f.by_ref().take(size).read_to_end(&mut data).map_err(|err| EngineError::Io(format!("bundle: {rel}: {err}")))?;
|
||||
let is_session = !rel.contains('/') && Path::new(&rel).extension().and_then(|x| x.to_str()) == Some(soundcraft_model::SESSION_EXTENSION);
|
||||
if is_session && text.is_none() {
|
||||
text = Some(String::from_utf8(data).map_err(|_| EngineError::Io(format!("bundle: {rel} is not text")))?);
|
||||
} else {
|
||||
files.insert(rel, data);
|
||||
}
|
||||
}
|
||||
let text = text.ok_or_else(|| EngineError::Io("bundle: no .scraft session inside".into()))?;
|
||||
Ok((text, files))
|
||||
}
|
||||
|
||||
/// Turn a recorded take into clips on the record-armed tracks (one undo step: "Record").
|
||||
/// `channels` is the planar input capture at `rate`; each track takes the input channels its
|
||||
/// input route names ("In 3" → channel 2), defaulting to the first channels.
|
||||
|
|
@ -638,3 +806,75 @@ mod stem_tests {
|
|||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod bundle_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bundle_round_trips_session_and_audio() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
e.execute("mix.volume", &serde_json::json!({"track": "Kick", "db": -7.0})).unwrap();
|
||||
let (bytes, fresh) = session_bundle(e.session()).unwrap();
|
||||
assert!(bytes.starts_with(b"PK\x03\x04"));
|
||||
assert!(fresh.sources.iter().all(|s| s.path.starts_with("Audio Files/") && !s.unsaved));
|
||||
let mut r = Engine::default();
|
||||
let missing = open_session_bytes(&mut r, "Midnight Groove.scraftz", &bytes).unwrap();
|
||||
assert!(missing.is_empty(), "{missing:?}");
|
||||
assert_eq!(r.session().track_by_name("Kick").unwrap().mixer.volume_db, -7.0);
|
||||
assert_eq!(r.session().tracks.len(), e.session().tracks.len());
|
||||
for src in &e.session().sources {
|
||||
let (a, b) = (e.session().pool.get(src.id).unwrap(), r.session().pool.get(src.id).unwrap());
|
||||
assert_eq!(a.buffer.channels, b.buffer.channels, "{}: float WAV keeps every sample", src.name);
|
||||
}
|
||||
assert!(!r.is_dirty());
|
||||
assert_eq!(r.path.as_deref(), Some("Midnight Groove.scraftz"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_named_sources_get_distinct_files() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let name = e.session().sources[0].name.clone();
|
||||
let s = e.session_mut();
|
||||
let n = s.sources.len();
|
||||
s.sources[1].name = name;
|
||||
let (bytes, fresh) = session_bundle(e.session()).unwrap();
|
||||
let paths: std::collections::HashSet<_> = fresh.sources.iter().map(|s| s.path.clone()).collect();
|
||||
assert_eq!(paths.len(), n);
|
||||
let mut r = Engine::default();
|
||||
assert!(open_session_bytes(&mut r, "x.scraftz", &bytes).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_session_opens_with_its_audio_reported_missing() {
|
||||
let e = crate::demo::demo_engine();
|
||||
let (_, fresh) = session_bundle(e.session()).unwrap();
|
||||
let text = fresh.to_json().unwrap();
|
||||
let mut r = Engine::default();
|
||||
let missing = open_session_bytes(&mut r, "Song.scraft", text.as_bytes()).unwrap();
|
||||
assert_eq!(missing.len(), fresh.sources.len());
|
||||
assert_eq!(r.session().tracks.len(), fresh.tracks.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_bundles_are_errors_not_panics() {
|
||||
let mut r = Engine::default();
|
||||
for bad in [&b"PK\x03\x04 not really a zip"[..], b"", b"{ not json", b"\xff\xfe\x00"] {
|
||||
assert!(open_session_bytes(&mut r, "bad.scraftz", bad).is_err());
|
||||
}
|
||||
// A valid zip without a session inside.
|
||||
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
w.start_file("Audio Files/a.wav", zip::write::SimpleFileOptions::default()).unwrap();
|
||||
let only_audio = w.finish().unwrap().into_inner();
|
||||
assert!(open_session_bytes(&mut r, "a.scraftz", &only_audio).is_err());
|
||||
assert_eq!(r.session().tracks.len(), 0, "a failed open leaves the document alone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_file_writes_to_disk_natively() {
|
||||
let p = std::env::temp_dir().join(format!("soundcraft-write-file-{}.txt", std::process::id()));
|
||||
write_file(&p.to_string_lossy(), b"hello").unwrap();
|
||||
assert_eq!(std::fs::read(&p).unwrap(), b"hello");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,7 +75,12 @@ impl Dialogs {
|
|||
"session.save_as" | "session.save_copy" => Dialog::PathPrompt {
|
||||
cmd: id.into(),
|
||||
title: "Save Session As".into(),
|
||||
path: format!("{home}/{name}/{name}.scraft"),
|
||||
// The browser has no folders: the name becomes the bundle file's name.
|
||||
path: if home.is_empty() {
|
||||
format!("{name}.{}", soundcraft_engine::io::BUNDLE_EXTENSION)
|
||||
} else {
|
||||
format!("{home}/{name}/{name}.scraft")
|
||||
},
|
||||
key: "path".into(),
|
||||
},
|
||||
"edit.repeat" => Dialog::Number { cmd: id.into(), title: "Repeat".into(), key: "count".into(), value: 2.0, suffix: "times".into() },
|
||||
|
|
|
|||
Loading…
Reference in New Issue