Reads and edits BWF metadata for production sound. One source tree builds a single self-contained page and a native Tauri app with a Rust audio engine and WAV writer. Around 370 checks across seven test suites. First commit of the existing state, so that from here every change can be seen and undone.
640 lines
20 KiB
Rust
640 lines
20 KiB
Rust
// 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<FileEntry, String> {
|
|
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<FileEntry>) -> 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<fs::DirEntry> = 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<String>) -> Result<Vec<FileEntry>, String> {
|
|
let mut out: Vec<FileEntry> = 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<Vec<DirEntry>, String> {
|
|
let dir = PathBuf::from(&path);
|
|
let reader = fs::read_dir(&dir).map_err(|e| format!("{}: {}", dir.display(), e))?;
|
|
|
|
let mut children: Vec<fs::DirEntry> = 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<Vec<u8>, 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<String>) -> Result<Vec<FileEntry>, 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<Vec<DirEntry>, 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<FileEntry, String> {
|
|
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<Response, String> {
|
|
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<Response, String> {
|
|
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<convert::Probe, String> {
|
|
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<u16>,
|
|
) -> Result<convert::Exported, String> {
|
|
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<String>,
|
|
bits: u16,
|
|
float: bool,
|
|
gain: f64,
|
|
overwrite: bool,
|
|
channels: Vec<u16>,
|
|
) -> Result<convert::SplitExported, String> {
|
|
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<String>,
|
|
bits: u16,
|
|
float: bool,
|
|
) -> Result<convert::CombinePlan, String> {
|
|
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<String>,
|
|
dest: String,
|
|
bits: u16,
|
|
float: bool,
|
|
gain: f64,
|
|
overwrite: bool,
|
|
) -> Result<convert::Combined, String> {
|
|
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<u64, String> {
|
|
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<u64, String> {
|
|
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<Vec<u8>, 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<u64, String> {
|
|
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<convert::Peaks, String> {
|
|
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<f32>,
|
|
) -> Result<convert::Spectrogram, String> {
|
|
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<f32>) -> 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<f32>) -> 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");
|
|
}
|