// BWF Analyser — native file access for the macOS app. // // The frontend is the same client-side analyser that runs in a browser. The // one thing it can't do inside a WKWebView is touch the disk: Safari's engine // has no File System Access API, and its folder input is unreliable. So every // read and write goes through the commands below instead, and a small JS // bridge in the frontend presents them as the File / FileSystemFileHandle // objects the app already knows how to use. // // Reads are ranged on purpose. Pulling metadata out of a 4 GB take should read // a few kilobytes of chunk headers, not the whole file, which is exactly what // the parser asks for when it slices a File. // // Every command that touches the filesystem runs on a blocking thread. A card // full of takes is a lot of syscalls, and the async workers here also carry // event and channel traffic — stalling one of those stalls the UI. #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] use std::fs; use std::io::{Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; use std::time::UNIX_EPOCH; use serde::Serialize; use tauri::async_runtime::spawn_blocking; use tauri::ipc::{InvokeBody, Request, Response}; mod convert; mod play; /// How deep a folder scan will recurse before giving up. Symlinks are followed, /// so this is a genuine loop guard, not just a sanity limit. const MAX_DEPTH: usize = 24; /// A single read is capped well below what the IPC layer will happily try to /// copy. The bridge splits anything larger into ranged reads. const MAX_SINGLE_READ: u64 = 512 * 1024 * 1024; /// Matches the extensions the frontend accepts. fn is_recording(name: &str) -> bool { let lower = name.to_lowercase(); lower.ends_with(".wav") || lower.ends_with(".bwf") || lower.ends_with(".broadcastwave") } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct FileEntry { path: String, relative_path: String, name: String, size: u64, /// Milliseconds since the epoch, to match JS `File.lastModified`. last_modified: u64, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct DirEntry { name: String, path: String, /// "file" or "directory", mirroring FileSystemHandle.kind. kind: String, } fn modified_ms(meta: &fs::Metadata) -> u64 { meta.modified() .ok() .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) .map(|d| d.as_millis() as u64) .unwrap_or(0) } fn file_name_of(path: &Path) -> String { path.file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_default() } fn describe(path: &Path, relative_path: String) -> Result { let meta = fs::metadata(path).map_err(|e| format!("{}: {}", path.display(), e))?; Ok(FileEntry { path: path.to_string_lossy().to_string(), relative_path, name: file_name_of(path), size: meta.len(), last_modified: modified_ms(&meta), }) } fn walk(dir: &Path, prefix: &str, depth: usize, out: &mut Vec) -> Result<(), String> { if depth > MAX_DEPTH { return Ok(()); } let reader = fs::read_dir(dir).map_err(|e| format!("{}: {}", dir.display(), e))?; let mut children: Vec = reader.filter_map(|entry| entry.ok()).collect(); children.sort_by_key(|entry| entry.file_name()); for child in children { let name = child.file_name().to_string_lossy().to_string(); // Skip dotfiles: ._resource forks and .DS_Store are never recordings. if name.starts_with('.') { continue; } let path = child.path(); let relative = if prefix.is_empty() { name.clone() } else { format!("{}/{}", prefix, name) }; // fs::metadata follows symlinks; DirEntry::file_type does not, and a // card with an aliased folder on it should still be scanned. let meta = match fs::metadata(&path) { Ok(meta) => meta, Err(_) => continue, // Broken link or a file that just went away. }; if meta.is_dir() { walk(&path, &relative, depth + 1, out)?; } else if meta.is_file() && is_recording(&name) { out.push(FileEntry { path: path.to_string_lossy().to_string(), relative_path: relative, name, size: meta.len(), last_modified: modified_ms(&meta), }); } } Ok(()) } fn scan_blocking(paths: Vec) -> Result, String> { let mut out: Vec = Vec::new(); for raw in paths { let path = PathBuf::from(&raw); // One unreadable path shouldn't discard everything else that was // dropped alongside it. let meta = match fs::metadata(&path) { Ok(meta) => meta, Err(_) => continue, }; if meta.is_dir() { let base = file_name_of(&path); walk(&path, &base, 0, &mut out)?; } else { let name = file_name_of(&path); if is_recording(&name) { out.push(describe(&path, name)?); } } } Ok(out) } fn list_dir_blocking(path: String) -> Result, String> { let dir = PathBuf::from(&path); let reader = fs::read_dir(&dir).map_err(|e| format!("{}: {}", dir.display(), e))?; let mut children: Vec = reader.filter_map(|entry| entry.ok()).collect(); children.sort_by_key(|entry| entry.file_name()); let mut out = Vec::new(); for child in children { let name = child.file_name().to_string_lossy().to_string(); if name.starts_with('.') { continue; } let child_path = child.path(); let meta = match fs::metadata(&child_path) { Ok(meta) => meta, Err(_) => continue, }; let kind = if meta.is_dir() { "directory" } else if meta.is_file() { "file" } else { continue; }; out.push(DirEntry { name, path: child_path.to_string_lossy().to_string(), kind: kind.to_string(), }); } Ok(out) } fn read_range_blocking(path: String, offset: u64, length: u64) -> Result, String> { let mut file = fs::File::open(&path).map_err(|e| format!("{}: {}", path, e))?; let size = file .metadata() .map_err(|e| format!("{}: {}", path, e))? .len(); if offset >= size || length == 0 { return Ok(Vec::new()); } let take = std::cmp::min(length, size - offset); if take > MAX_SINGLE_READ { return Err(format!( "refusing to read {} bytes in one go — read it in ranges instead", take )); } file.seek(SeekFrom::Start(offset)) .map_err(|e| format!("{}: {}", path, e))?; let mut buffer = vec![0u8; take as usize]; file.read_exact(&mut buffer) .map_err(|e| format!("{}: {}", path, e))?; Ok(buffer) } /// Expands whatever the user picked or dropped — folders, single files, a mix — /// into a flat list of recordings, each with a relative path the table uses as /// its "Folder" column. #[tauri::command] async fn bwf_scan(paths: Vec) -> Result, String> { match spawn_blocking(move || scan_blocking(paths)).await { Ok(result) => result, Err(e) => Err(e.to_string()), } } /// One level of a directory, for the JS side's FileSystemDirectoryHandle shim. #[tauri::command] async fn bwf_list_dir(path: String) -> Result, String> { match spawn_blocking(move || list_dir_blocking(path)).await { Ok(result) => result, Err(e) => Err(e.to_string()), } } /// Re-reads size/mtime. The editor calls this before every save so it works /// from the file as it is on disk right now, not as it was at scan time. #[tauri::command] async fn bwf_stat(path: String) -> Result { match spawn_blocking(move || { let target = PathBuf::from(&path); let name = file_name_of(&target); describe(&target, name) }) .await { Ok(result) => result, Err(e) => Err(e.to_string()), } } /// The workhorse: a byte range, returned as raw bytes rather than a JSON array /// so it arrives in the webview as an ArrayBuffer with no serialization cost. /// Out-of-range requests clamp instead of failing, matching Blob.slice(). #[tauri::command] async fn bwf_read_range(path: String, offset: u64, length: u64) -> Result { match spawn_blocking(move || read_range_blocking(path, offset, length)).await { Ok(result) => result.map(Response::new), Err(e) => Err(e.to_string()), } } /// Whole-file read, for audio playback and for the writer when it needs the /// original bytes. Anything genuinely large is refused here and fetched by the /// bridge in ranges instead, so a 4 GB take never sits in memory three times /// over (once in Rust, once in the response, once in JS). #[tauri::command] async fn bwf_read_all(path: String) -> Result { match spawn_blocking(move || { let size = fs::metadata(&path) .map_err(|e| format!("{}: {}", path, e))? .len(); read_range_blocking(path, 0, size) }) .await { Ok(result) => result.map(Response::new), Err(e) => Err(e.to_string()), } } /// Reads a recording's audio format, and optionally scans it for its peak. /// /// The scan reads every sample, which is the point: a 32-bit float file can /// legally sit above 0 dBFS, and nothing else can tell you whether converting /// it to fixed point would clip. #[tauri::command] async fn bwf_probe(path: String, scan: bool) -> Result { match spawn_blocking(move || convert::probe(&path, scan)).await { Ok(result) => result, Err(e) => Err(e.to_string()), } } /// Writes one converted copy of a recording into a new folder. #[tauri::command] async fn bwf_export( src: String, dest: String, bits: u16, float: bool, gain: f64, overwrite: bool, channels: Vec, ) -> Result { match spawn_blocking(move || { convert::export(&src, &dest, bits, float, gain, overwrite, &channels) }) .await { Ok(result) => result, Err(e) => Err(e.to_string()), } } /// Splits a poly recording into one mono file per channel. #[tauri::command] async fn bwf_export_split( src: String, dest: String, names: Vec, bits: u16, float: bool, gain: f64, overwrite: bool, channels: Vec, ) -> Result { match spawn_blocking(move || { convert::export_split(&src, &dest, &names, bits, float, gain, overwrite, &channels) }) .await { Ok(result) => result, Err(e) => Err(e.to_string()), } } /// What combining a set of files would produce, without producing it. #[tauri::command] async fn bwf_combine_plan( sources: Vec, bits: u16, float: bool, ) -> Result { match spawn_blocking(move || convert::combine_plan(&sources, bits, float)).await { Ok(result) => result, Err(e) => Err(e.to_string()), } } /// Writes several recordings into one poly file, aligned by timecode. #[tauri::command] async fn bwf_combine( sources: Vec, dest: String, bits: u16, float: bool, gain: f64, overwrite: bool, ) -> Result { match spawn_blocking(move || convert::combine(&sources, &dest, bits, float, gain, overwrite)) .await { Ok(result) => result, Err(e) => Err(e.to_string()), } } /// Copies a recording to the export folder untouched. /// /// This is the path taken when nothing about the audio is changing. A byte-for- /// byte copy is a stronger promise about metadata than any rebuild, however /// careful, so it's worth having as its own case. #[tauri::command] async fn bwf_copy_file(src: String, dest: String, overwrite: bool) -> Result { match spawn_blocking(move || { let target = PathBuf::from(&dest); if target.exists() && !overwrite { return Err("bwf:exists".to_string()); } if let Ok(a) = fs::canonicalize(&src) { if let Ok(b) = fs::canonicalize(&dest) { if a == b { return Err("bwf:same-file".to_string()); } } } if let Some(parent) = target.parent() { fs::create_dir_all(parent).map_err(|e| format!("{}: {}", parent.display(), e))?; } fs::copy(&src, &dest).map_err(|e| format!("{}: {}", dest, e)) }) .await { Ok(result) => result, Err(e) => Err(e.to_string()), } } /// Creates the export folder, and reports whether it already had files in it. #[tauri::command] async fn bwf_prepare_dir(path: String) -> Result { match spawn_blocking(move || { fs::create_dir_all(&path).map_err(|e| format!("{}: {}", path, e))?; let count = fs::read_dir(&path) .map_err(|e| format!("{}: {}", path, e))? .filter(|entry| { entry .as_ref() .map(|e| is_recording(&file_name_of(&e.path()))) .unwrap_or(false) }) .count(); Ok(count as u64) }) .await { Ok(result) => result, Err(e) => Err(e.to_string()), } } fn decode_hex(input: &str) -> Result, String> { if input.len() % 2 != 0 { return Err("malformed path encoding".to_string()); } let bytes = input.as_bytes(); let mut out = Vec::with_capacity(input.len() / 2); for pair in bytes.chunks(2) { let hi = (pair[0] as char) .to_digit(16) .ok_or_else(|| "malformed path encoding".to_string())?; let lo = (pair[1] as char) .to_digit(16) .ok_or_else(|| "malformed path encoding".to_string())?; out.push((hi * 16 + lo) as u8); } Ok(out) } fn header<'a>(request: &'a Request<'_>, name: &str) -> Option<&'a str> { request.headers().get(name).and_then(|v| v.to_str().ok()) } /// Writes bytes back to a recording. /// /// The payload is the raw request body, so even a full multi-gigabyte rebuild /// never becomes a JSON array. Path and position ride along as headers; the /// path is hex-encoded because header values have to be ASCII and filenames /// very much do not. /// /// Headers: /// x-bwf-path hex-encoded UTF-8 absolute path /// x-bwf-position byte offset to write at /// x-bwf-truncate "1" to cut the file to `position + len` after writing /// x-bwf-create "1" to create the file if it doesn't exist (exports) /// /// This one is deliberately synchronous: it keeps the borrowed `Request` /// simple, and the bridge sends everything in bounded chunks, so no single /// call holds the main thread for long. #[tauri::command] fn bwf_write(request: Request<'_>) -> Result { let data = match request.body() { InvokeBody::Raw(bytes) => bytes, // Tauri falls back to the postMessage IPC if the custom protocol ever // fails, and that path JSON-encodes the body. Nothing here can recover // from it, but the message should at least point at the real cause. InvokeBody::Json(_) => { return Err("write payload did not arrive as raw bytes (IPC fell back to JSON)".to_string()) } }; let path_hex = header(&request, "x-bwf-path").ok_or("missing x-bwf-path header")?; let path = String::from_utf8(decode_hex(path_hex)?).map_err(|e| e.to_string())?; let position: u64 = header(&request, "x-bwf-position") .unwrap_or("0") .parse() .map_err(|_| "invalid x-bwf-position header".to_string())?; let truncate = header(&request, "x-bwf-truncate").unwrap_or("0") == "1"; let create = header(&request, "x-bwf-create").unwrap_or("0") == "1"; let mut file = fs::OpenOptions::new() .write(true) .create(create) .open(&path) .map_err(|e| format!("{}: {}", path, e))?; file.seek(SeekFrom::Start(position)) .map_err(|e| format!("{}: {}", path, e))?; file.write_all(data) .map_err(|e| format!("{}: {}", path, e))?; if truncate { file.set_len(position + data.len() as u64) .map_err(|e| format!("{}: {}", path, e))?; } file.flush().map_err(|e| format!("{}: {}", path, e))?; Ok(data.len() as u64) } /* ------------------------------------------------------------------ */ /* Playback */ /* ------------------------------------------------------------------ */ /// The waveform, without decoding the file into memory to get it. #[tauri::command] async fn bwf_peaks(path: String, buckets: usize) -> Result { match spawn_blocking(move || convert::peaks(&path, buckets)).await { Ok(result) => result, Err(e) => Err(e.to_string()), } } /// A picture of the file: time across, frequency up. #[tauri::command] async fn bwf_spectrogram( path: String, columns: usize, window: usize, gains: Vec, ) -> Result { match spawn_blocking(move || convert::spectrogram(&path, columns, window, &gains)).await { Ok(result) => result, Err(e) => Err(e.to_string()), } } /// Starts a file on the app's own output. `gains` is one linear gain per /// source channel, which is how the channel chips mute and solo. #[tauri::command] fn bwf_play(path: String, offset: f64, gains: Vec) -> Result<(), String> { play::play(path, offset, gains) } #[tauri::command] fn bwf_pause() -> Result<(), String> { play::pause() } #[tauri::command] fn bwf_resume() -> Result<(), String> { play::resume() } #[tauri::command] fn bwf_stop() -> Result<(), String> { play::stop() } #[tauri::command] fn bwf_seek(seconds: f64) -> Result<(), String> { play::seek(seconds) } #[tauri::command] fn bwf_gains(gains: Vec) -> Result<(), String> { play::gains(gains) } /// Restarts the app. /// /// The last resort behind the settings. It used to reload the page, which was /// the wrong instrument: a reload builds a new document and a new audio /// context and the sound stayed gone, which is what proved the fault was /// below the page in the first place. Playback is the app's own now, so this /// should never be needed; it stays because the failure it covers took four /// attempts to find, and the folder is reopened on the way back up. #[tauri::command] fn bwf_restart(app: tauri::AppHandle) { app.restart(); } fn main() { tauri::Builder::default() // Restores the window's size and position from the last run, and // saves them on exit. Fullscreen and visibility are left out: an app // quit while fullscreen should come back as a window, and "was it // visible" is not a question worth persisting for a single-window // tool. .plugin( tauri_plugin_window_state::Builder::new() .with_state_flags( tauri_plugin_window_state::StateFlags::SIZE | tauri_plugin_window_state::StateFlags::POSITION | tauri_plugin_window_state::StateFlags::MAXIMIZED, ) .build(), ) .plugin(tauri_plugin_dialog::init()) .invoke_handler(tauri::generate_handler![ bwf_scan, bwf_list_dir, bwf_stat, bwf_read_range, bwf_read_all, bwf_write, bwf_probe, bwf_export, bwf_export_split, bwf_combine_plan, bwf_combine, bwf_copy_file, bwf_prepare_dir, bwf_peaks, bwf_spectrogram, bwf_play, bwf_pause, bwf_resume, bwf_stop, bwf_seek, bwf_gains, bwf_restart ]) // The audio engine is a thread of the app's own, started once and // living as long as the app does. It holds the output stream, which // is not Send on macOS, so it can't be parked in a global and handed // around: commands reach it over a channel instead. .setup(|app| { play::launch(app.handle().clone()); Ok(()) }) .run(tauri::generate_context!()) .expect("error while running BWF Analyser"); }