BWF Analyser: browser page and macOS app
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.
This commit is contained in:
Generated
+4828
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
[package]
|
||||
name = "bwf-analyser"
|
||||
version = "1.5.1"
|
||||
description = "Broadcast Wave metadata analyser for location sound"
|
||||
authors = ["Vincent Rozenberg"]
|
||||
license = "MIT"
|
||||
edition = "2021"
|
||||
# Tauri itself only asks for 1.77, but crates deep in its dependency tree are
|
||||
# published as edition 2024, which older toolchains won't even parse. Declaring
|
||||
# the real floor here turns that into a clear message instead of a confusing
|
||||
# manifest error from some transitive dependency.
|
||||
rust-version = "1.85"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
# devtools: right-click gives Inspect Element in the built app. Without it a
|
||||
# problem in the page can only be guessed at from the outside.
|
||||
tauri = { version = "2", features = ["devtools"] }
|
||||
tauri-plugin-dialog = "2"
|
||||
# Remembers the window's size and position between launches.
|
||||
tauri-plugin-window-state = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
# Playback. The app owns its own output stream rather than borrowing the
|
||||
# webview's: WebKit's audio dies after the machine has been left alone and
|
||||
# only a relaunch brings it back, which is not something a page can fix.
|
||||
cpal = "0.16"
|
||||
|
||||
# Reading metadata is all disk and no arithmetic, but converting a card of
|
||||
# 32-bit float files is a per-sample loop over tens of gigabytes, so the
|
||||
# optimiser gets its head. `codegen-units = 1` and LTO keep the binary small
|
||||
# anyway.
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
opt-level = 3
|
||||
# Unwinding is left on: an Objective-C exception coming back through the
|
||||
# webview should produce a usable crash report, not an immediate abort.
|
||||
strip = true
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"identifier": "default",
|
||||
"description": "Core APIs plus the native open dialog. The app's own bwf_* commands are not listed here: commands defined by the application itself, called from its own local frontend, are allowed without an ACL entry. Only plugin commands need one.",
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save",
|
||||
"window-state:default"
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"default":{"identifier":"default","description":"Core APIs plus the native open dialog. The app's own bwf_* commands are not listed here: commands defined by the application itself, called from its own local frontend, are allowed without an ACL entry. Only plugin commands need one.","local":true,"windows":["main"],"permissions":["core:default","dialog:allow-open","dialog:allow-save","window-state:default"]}}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 5.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,639 @@
|
||||
// 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");
|
||||
}
|
||||
@@ -0,0 +1,828 @@
|
||||
//! Playing a take, in the app rather than in the webview.
|
||||
//!
|
||||
//! This used to be the Web Audio API inside the WKWebView, and it kept
|
||||
//! failing the same way: after the machine had been left alone for a while,
|
||||
//! the transport ran, the clock advanced, and nothing came out of the
|
||||
//! speakers. Reloading the page didn't fix it. Only quitting the app did,
|
||||
//! which is the tell: a page reload builds a brand new document and a brand
|
||||
//! new AudioContext, so if that is still silent then the fault is below the
|
||||
//! page, in the WebKit content process that renders our audio. Nothing in
|
||||
//! JavaScript can reach that, which is why three attempts to fix it from
|
||||
//! there could never have worked.
|
||||
//!
|
||||
//! So the app owns its own output stream now. When a device goes away or a
|
||||
//! stream faults, this rebuilds it in process, and there is no WebKit audio
|
||||
//! path left to lose.
|
||||
//!
|
||||
//! The shape is a single engine thread that owns the cpal stream (a stream is
|
||||
//! not `Send` on macOS, so it can't be parked in a global) and takes commands
|
||||
//! over a channel. A reader thread streams the file from disk, because a day
|
||||
//! file is tens of gigabytes and the old player decoded whole files into
|
||||
//! memory to play them.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::mpsc::{self, Receiver, Sender, SyncSender, TryRecvError};
|
||||
use std::sync::{Arc, LazyLock, Mutex};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
use crate::convert::Take;
|
||||
|
||||
/// Frames per block handed from the reader to the audio callback.
|
||||
const BLOCK: usize = 4096;
|
||||
|
||||
/// Blocks in flight. Four at 48 kHz is roughly a third of a second: enough
|
||||
/// that a busy disk doesn't stutter, short enough that a seek doesn't have an
|
||||
/// audible tail of the old position.
|
||||
const BLOCKS: usize = 4;
|
||||
|
||||
/// How often the engine looks at the world: emits a position, notices the end
|
||||
/// of a file, notices the output device changed underneath it.
|
||||
const TICK: Duration = Duration::from_millis(100);
|
||||
|
||||
/// Rebuilds in a row before the output is called dead. Each one waits a little
|
||||
/// longer than the last.
|
||||
const REBUILD_LIMIT: u32 = 5;
|
||||
|
||||
/// What the frontend can ask for.
|
||||
enum Cmd {
|
||||
Play { path: String, offset: f64, gains: Vec<f32> },
|
||||
Pause,
|
||||
Resume,
|
||||
Stop,
|
||||
Seek(f64),
|
||||
Gains(Vec<f32>),
|
||||
}
|
||||
|
||||
/// What the frontend is told, on `bwf://playback`.
|
||||
#[derive(Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Status {
|
||||
pub playing: bool,
|
||||
pub seconds: f64,
|
||||
pub duration: f64,
|
||||
pub channels: u16,
|
||||
pub sample_rate: u32,
|
||||
pub ended: bool,
|
||||
/// Set when the output was rebuilt under a playing file, so the status
|
||||
/// line can say so rather than leaving a gap nobody can explain.
|
||||
pub reopened: bool,
|
||||
/// Peak level per source channel since the last status, 0.0 to 1.0, taken
|
||||
/// before the faders so a meter shows what is on the track rather than
|
||||
/// what you have done to it. Empty when nothing is playing.
|
||||
pub levels: Vec<f32>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// State shared with the audio callback. Everything here is touched from a
|
||||
/// real-time thread, so it is all atomics: no locks, no allocation.
|
||||
struct Shared {
|
||||
/// One f32, as bits, per source channel.
|
||||
gains: Vec<AtomicU32>,
|
||||
/// Peak magnitude per source channel, as f32 bits, raised by the callback
|
||||
/// and taken by the status tick. For values that are never negative the
|
||||
/// IEEE bit pattern orders the same way the numbers do, which is what
|
||||
/// makes fetch_max correct here.
|
||||
meters: Vec<AtomicU32>,
|
||||
/// Output frames the callback has written since this stream started.
|
||||
played: AtomicU64,
|
||||
/// Where the file was when the stream started, in output frames.
|
||||
start: AtomicU64,
|
||||
/// The reader reached the end of the file.
|
||||
finished: AtomicBool,
|
||||
/// The reader stopped because it couldn't read, which is a different
|
||||
/// thing from the file ending and must not be reported as one.
|
||||
unreadable: AtomicBool,
|
||||
/// The callback ran out of audio after the reader had finished.
|
||||
drained: AtomicBool,
|
||||
/// cpal reported an error on the stream.
|
||||
broken: AtomicBool,
|
||||
}
|
||||
|
||||
impl Shared {
|
||||
fn gain(&self, channel: usize) -> f32 {
|
||||
match self.gains.get(channel) {
|
||||
Some(cell) => f32::from_bits(cell.load(Ordering::Relaxed)),
|
||||
// A channel nobody sent a gain for is on, matching set_gains.
|
||||
None => 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Raises a channel's peak. Called once per channel per callback, not per
|
||||
/// sample: the callback maxes into its own stack array first.
|
||||
fn raise(&self, channel: usize, level: f32) {
|
||||
if let Some(cell) = self.meters.get(channel) {
|
||||
cell.fetch_max(level.to_bits(), Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the peaks and clears them, so each status covers the period since
|
||||
/// the last one rather than the loudest thing that ever happened.
|
||||
fn take_levels(&self) -> Vec<f32> {
|
||||
self.meters
|
||||
.iter()
|
||||
.map(|cell| f32::from_bits(cell.swap(0f32.to_bits(), Ordering::Relaxed)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn set_gains(&self, gains: &[f32]) {
|
||||
for (index, cell) in self.gains.iter().enumerate() {
|
||||
let value = gains.get(index).copied().unwrap_or(1.0);
|
||||
cell.store(value.to_bits(), Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
fn seconds(&self, device_rate: u32) -> f64 {
|
||||
if device_rate == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
let frames = self.start.load(Ordering::Relaxed) + self.played.load(Ordering::Relaxed);
|
||||
frames as f64 / device_rate as f64
|
||||
}
|
||||
}
|
||||
|
||||
/// One file, open and playing (or paused).
|
||||
struct Playing {
|
||||
path: String,
|
||||
/// Dropped to close the output. Not `Send`, hence the engine thread.
|
||||
stream: cpal::Stream,
|
||||
shared: Arc<Shared>,
|
||||
/// Tells the reader thread to stop and let go of the file.
|
||||
halt: Arc<AtomicBool>,
|
||||
channels: u16,
|
||||
source_rate: u32,
|
||||
device_rate: u32,
|
||||
duration: f64,
|
||||
/// Consecutive rebuilds without a stretch of successful playback between
|
||||
/// them. A dead output must not be retried forever.
|
||||
attempts: u32,
|
||||
/// What the person asked for, which is not the same as what the stream
|
||||
/// is doing: a resume that failed leaves the stream stopped, and the
|
||||
/// rebuild has to know it was meant to be playing.
|
||||
wanted: bool,
|
||||
paused: bool,
|
||||
device: String,
|
||||
gains: Vec<f32>,
|
||||
}
|
||||
|
||||
static ENGINE: LazyLock<Mutex<Option<Sender<Cmd>>>> = LazyLock::new(|| Mutex::new(None));
|
||||
|
||||
fn send(cmd: Cmd) -> Result<(), String> {
|
||||
let engine = ENGINE.lock().map_err(|_| "the player is wedged".to_string())?;
|
||||
match engine.as_ref() {
|
||||
Some(tx) => tx.send(cmd).map_err(|_| "the player has stopped".to_string()),
|
||||
None => Err("the player hasn't started".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* What the frontend calls */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
pub fn play(path: String, offset: f64, gains: Vec<f32>) -> Result<(), String> {
|
||||
send(Cmd::Play { path, offset, gains })
|
||||
}
|
||||
|
||||
pub fn pause() -> Result<(), String> {
|
||||
send(Cmd::Pause)
|
||||
}
|
||||
|
||||
pub fn resume() -> Result<(), String> {
|
||||
send(Cmd::Resume)
|
||||
}
|
||||
|
||||
pub fn stop() -> Result<(), String> {
|
||||
send(Cmd::Stop)
|
||||
}
|
||||
|
||||
pub fn seek(seconds: f64) -> Result<(), String> {
|
||||
send(Cmd::Seek(seconds))
|
||||
}
|
||||
|
||||
pub fn gains(values: Vec<f32>) -> Result<(), String> {
|
||||
send(Cmd::Gains(values))
|
||||
}
|
||||
|
||||
/// Starts the engine thread. Called once, as the app comes up.
|
||||
pub fn launch(app: AppHandle) {
|
||||
let (tx, rx) = mpsc::channel::<Cmd>();
|
||||
if let Ok(mut engine) = ENGINE.lock() {
|
||||
*engine = Some(tx);
|
||||
}
|
||||
if thread::Builder::new()
|
||||
.name("bwf-audio".to_string())
|
||||
.spawn(move || run(app, rx))
|
||||
.is_err()
|
||||
{
|
||||
// Otherwise every command afterwards succeeds into a channel nobody
|
||||
// is reading, which looks exactly like playback that does nothing.
|
||||
if let Ok(mut engine) = ENGINE.lock() {
|
||||
*engine = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* The engine thread */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
fn run(app: AppHandle, rx: Receiver<Cmd>) {
|
||||
let mut current: Option<Playing> = None;
|
||||
|
||||
loop {
|
||||
match rx.recv_timeout(TICK) {
|
||||
Ok(Cmd::Play { path, offset, gains }) => {
|
||||
current = None; // Closes the old stream before opening a new one.
|
||||
match open(&path, offset, &gains, true) {
|
||||
Ok(playing) => {
|
||||
report(&app, &playing, false, false, None);
|
||||
current = Some(playing);
|
||||
}
|
||||
Err(e) => fail(&app, e),
|
||||
}
|
||||
}
|
||||
Ok(Cmd::Stop) => {
|
||||
current = None;
|
||||
let _ = app.emit("bwf://playback", Status {
|
||||
playing: false,
|
||||
seconds: 0.0,
|
||||
duration: 0.0,
|
||||
channels: 0,
|
||||
sample_rate: 0,
|
||||
ended: false,
|
||||
reopened: false,
|
||||
levels: Vec::new(),
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
Ok(Cmd::Pause) => {
|
||||
if let Some(playing) = current.as_mut() {
|
||||
playing.wanted = false;
|
||||
if !playing.paused {
|
||||
let _ = playing.stream.pause();
|
||||
playing.paused = true;
|
||||
}
|
||||
report(&app, playing, false, false, None);
|
||||
}
|
||||
}
|
||||
Ok(Cmd::Resume) => {
|
||||
if let Some(playing) = current.as_mut() {
|
||||
playing.wanted = true;
|
||||
if playing.paused {
|
||||
if playing.stream.play().is_err() {
|
||||
// Left for the tick to rebuild, which now knows
|
||||
// it was meant to be playing.
|
||||
playing.shared.broken.store(true, Ordering::Relaxed);
|
||||
} else {
|
||||
playing.paused = false;
|
||||
}
|
||||
}
|
||||
report(&app, playing, false, false, None);
|
||||
}
|
||||
}
|
||||
Ok(Cmd::Seek(seconds)) => {
|
||||
if let Some(old) = current.take() {
|
||||
let at = seconds.max(0.0).min(old.duration);
|
||||
let paused = !old.wanted;
|
||||
let path = old.path.clone();
|
||||
let gains = old.gains.clone();
|
||||
drop(old);
|
||||
match open(&path, at, &gains, !paused) {
|
||||
Ok(playing) => {
|
||||
report(&app, &playing, false, false, None);
|
||||
current = Some(playing);
|
||||
}
|
||||
Err(e) => fail(&app, e),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Cmd::Gains(values)) => {
|
||||
if let Some(playing) = current.as_mut() {
|
||||
playing.shared.set_gains(&values);
|
||||
playing.gains = values;
|
||||
}
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {
|
||||
current = tick(&app, current);
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The regular look around: where are we, has the file ended, is the output
|
||||
/// still the one we opened.
|
||||
fn tick(app: &AppHandle, current: Option<Playing>) -> Option<Playing> {
|
||||
let mut playing = current?;
|
||||
|
||||
if playing.shared.drained.load(Ordering::Relaxed) {
|
||||
if playing.shared.unreadable.load(Ordering::Relaxed) {
|
||||
fail(app, format!("{}: stopped reading part way through", playing.path));
|
||||
return None;
|
||||
}
|
||||
let _ = app.emit("bwf://playback", Status {
|
||||
playing: false,
|
||||
seconds: playing.duration,
|
||||
duration: playing.duration,
|
||||
channels: playing.channels,
|
||||
sample_rate: playing.source_rate,
|
||||
ended: true,
|
||||
reopened: false,
|
||||
levels: Vec::new(),
|
||||
error: None,
|
||||
});
|
||||
return None;
|
||||
}
|
||||
|
||||
// The two ways an output goes away underneath a running app: the stream
|
||||
// itself faults, or the default device changes because something was
|
||||
// plugged in, woke up, or was switched in System Settings. Both used to
|
||||
// be unrecoverable because the audio belonged to WebKit. Now the file is
|
||||
// simply reopened where it was, on whatever the output is now.
|
||||
//
|
||||
// A device name that comes back empty is a CoreAudio hiccup, not a new
|
||||
// device, and tearing the stream down for one would put an audible gap in
|
||||
// a take for no reason.
|
||||
let now = current_device_name();
|
||||
let moved = !now.is_empty() && now != playing.device;
|
||||
if playing.shared.broken.load(Ordering::Relaxed) || moved {
|
||||
// A device that enumerates but won't play would otherwise be rebuilt
|
||||
// ten times a second for as long as the app is open: a new header
|
||||
// parse, a new file handle, a new thread and a new audio unit each
|
||||
// time. Backed off, and given up on.
|
||||
if playing.attempts >= REBUILD_LIMIT {
|
||||
fail(app, "the audio output stopped responding".to_string());
|
||||
return None;
|
||||
}
|
||||
let at = playing.shared.seconds(playing.device_rate).min(playing.duration);
|
||||
let path = playing.path.clone();
|
||||
let gains = playing.gains.clone();
|
||||
let wanted = playing.wanted;
|
||||
let attempts = playing.attempts + 1;
|
||||
drop(playing);
|
||||
thread::sleep(Duration::from_millis(120 * attempts as u64));
|
||||
match open(&path, at, &gains, wanted) {
|
||||
Ok(mut fresh) => {
|
||||
fresh.attempts = attempts;
|
||||
report(app, &fresh, false, true, None);
|
||||
return Some(fresh);
|
||||
}
|
||||
Err(e) => {
|
||||
fail(app, e);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A stream that has played for a while is a stream that works, so the
|
||||
// count of consecutive rebuilds is forgotten.
|
||||
if playing.shared.played.load(Ordering::Relaxed) > playing.device_rate as u64 {
|
||||
playing.attempts = 0;
|
||||
}
|
||||
|
||||
report(app, &playing, false, false, None);
|
||||
Some(playing)
|
||||
}
|
||||
|
||||
fn report(app: &AppHandle, playing: &Playing, ended: bool, reopened: bool, error: Option<String>) {
|
||||
let _ = app.emit("bwf://playback", Status {
|
||||
playing: !playing.paused,
|
||||
seconds: playing.shared.seconds(playing.device_rate).min(playing.duration),
|
||||
duration: playing.duration,
|
||||
channels: playing.channels,
|
||||
sample_rate: playing.source_rate,
|
||||
ended,
|
||||
reopened,
|
||||
levels: playing.shared.take_levels(),
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
fn fail(app: &AppHandle, message: String) {
|
||||
let _ = app.emit("bwf://playback", Status {
|
||||
playing: false,
|
||||
seconds: 0.0,
|
||||
duration: 0.0,
|
||||
channels: 0,
|
||||
sample_rate: 0,
|
||||
ended: false,
|
||||
reopened: false,
|
||||
levels: Vec::new(),
|
||||
error: Some(message),
|
||||
});
|
||||
}
|
||||
|
||||
fn current_device_name() -> String {
|
||||
cpal::default_host()
|
||||
.default_output_device()
|
||||
.and_then(|device| device.name().ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Opening one file on the output */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/// Opens a file on the output, already stopped when it isn't wanted playing,
|
||||
/// so a seek or a rebuild while paused doesn't leak a buffer of sound at the
|
||||
/// new position before the pause lands.
|
||||
fn open(path: &str, offset: f64, gains: &[f32], wanted: bool) -> Result<Playing, String> {
|
||||
let mut take = Take::open(path)?;
|
||||
let channels = take.channels();
|
||||
let source_rate = take.sample_rate();
|
||||
let duration = take.seconds();
|
||||
if channels == 0 || source_rate == 0 {
|
||||
return Err(format!("{}: nothing to play", path));
|
||||
}
|
||||
|
||||
let host = cpal::default_host();
|
||||
let device = host
|
||||
.default_output_device()
|
||||
.ok_or_else(|| "no audio output to play through".to_string())?;
|
||||
let device_name = device.name().unwrap_or_default();
|
||||
|
||||
// The file's own rate if the device will take it, which on a location
|
||||
// card and a Mac is nearly always the case, so nearly always no
|
||||
// resampling at all.
|
||||
let config = pick_config(&device, source_rate)?;
|
||||
let device_rate = config.sample_rate().0;
|
||||
let out_channels = config.channels() as usize;
|
||||
let format = config.sample_format();
|
||||
let stream_config: cpal::StreamConfig = config.into();
|
||||
|
||||
let start_frame = (offset.max(0.0) * source_rate as f64).round() as u64;
|
||||
take.seek(start_frame)?;
|
||||
|
||||
let shared = Arc::new(Shared {
|
||||
gains: (0..channels).map(|_| AtomicU32::new(1f32.to_bits())).collect(),
|
||||
meters: (0..channels).map(|_| AtomicU32::new(0f32.to_bits())).collect(),
|
||||
played: AtomicU64::new(0),
|
||||
start: AtomicU64::new(
|
||||
(offset.max(0.0) * device_rate as f64).round() as u64,
|
||||
),
|
||||
finished: AtomicBool::new(false),
|
||||
unreadable: AtomicBool::new(false),
|
||||
drained: AtomicBool::new(false),
|
||||
broken: AtomicBool::new(false),
|
||||
});
|
||||
shared.set_gains(gains);
|
||||
|
||||
let (blocks_tx, blocks_rx) = mpsc::sync_channel::<Vec<f32>>(BLOCKS);
|
||||
// Bounded, so returning a spent block from the audio callback is a
|
||||
// fixed-size store rather than a queue that occasionally allocates.
|
||||
let (spare_tx, spare_rx) = mpsc::sync_channel::<Vec<f32>>(BLOCKS + 1);
|
||||
let halt = Arc::new(AtomicBool::new(false));
|
||||
|
||||
spawn_reader(
|
||||
take,
|
||||
channels as usize,
|
||||
source_rate,
|
||||
device_rate,
|
||||
blocks_tx,
|
||||
spare_rx,
|
||||
Arc::clone(&shared),
|
||||
Arc::clone(&halt),
|
||||
);
|
||||
|
||||
let stream = build_stream(
|
||||
&device,
|
||||
&stream_config,
|
||||
format,
|
||||
channels as usize,
|
||||
out_channels,
|
||||
blocks_rx,
|
||||
spare_tx,
|
||||
Arc::clone(&shared),
|
||||
)?;
|
||||
if wanted {
|
||||
stream.play().map_err(|e| format!("the output refused to start: {}", e))?;
|
||||
}
|
||||
|
||||
Ok(Playing {
|
||||
path: path.to_string(),
|
||||
stream,
|
||||
shared,
|
||||
halt,
|
||||
channels,
|
||||
source_rate,
|
||||
device_rate,
|
||||
duration,
|
||||
attempts: 0,
|
||||
wanted,
|
||||
paused: !wanted,
|
||||
device: device_name,
|
||||
gains: gains.to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
impl Drop for Playing {
|
||||
fn drop(&mut self) {
|
||||
self.halt.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// The output config to open: the file's own rate when the device supports
|
||||
/// it, the device's preference otherwise.
|
||||
fn pick_config(
|
||||
device: &cpal::Device,
|
||||
wanted: u32,
|
||||
) -> Result<cpal::SupportedStreamConfig, String> {
|
||||
let default = device
|
||||
.default_output_config()
|
||||
.map_err(|e| format!("no usable audio output: {}", e))?;
|
||||
if default.sample_rate().0 == wanted {
|
||||
return Ok(default);
|
||||
}
|
||||
if let Ok(ranges) = device.supported_output_configs() {
|
||||
for range in ranges {
|
||||
let matches_format = range.sample_format() == default.sample_format();
|
||||
let holds_rate = range.min_sample_rate().0 <= wanted && wanted <= range.max_sample_rate().0;
|
||||
if matches_format && holds_rate {
|
||||
return Ok(range.with_sample_rate(cpal::SampleRate(wanted)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(default)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* The reader thread */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn spawn_reader(
|
||||
mut take: Take,
|
||||
channels: usize,
|
||||
source_rate: u32,
|
||||
device_rate: u32,
|
||||
blocks: SyncSender<Vec<f32>>,
|
||||
spare: Receiver<Vec<f32>>,
|
||||
shared: Arc<Shared>,
|
||||
halt: Arc<AtomicBool>,
|
||||
) {
|
||||
thread::Builder::new()
|
||||
.name("bwf-audio-read".to_string())
|
||||
.spawn(move || {
|
||||
let ratio = source_rate as f64 / device_rate as f64;
|
||||
let straight = (ratio - 1.0).abs() < 1e-9;
|
||||
let mut input = vec![0f32; BLOCK * channels];
|
||||
let mut carry: Vec<f32> = Vec::new();
|
||||
let mut position = 0f64;
|
||||
|
||||
loop {
|
||||
if halt.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let got = match take.read(&mut input, BLOCK) {
|
||||
Ok(got) => got,
|
||||
Err(_) => {
|
||||
// A card pulled mid-take, or a file that lied about
|
||||
// its length. It didn't end, it broke, and saying
|
||||
// "finished" would be the app inventing a clean stop.
|
||||
shared.unreadable.store(true, Ordering::Relaxed);
|
||||
shared.finished.store(true, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if got == 0 {
|
||||
shared.finished.store(true, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut out = spare.try_recv().unwrap_or_default();
|
||||
out.clear();
|
||||
if straight {
|
||||
out.extend_from_slice(&input[..got * channels]);
|
||||
} else {
|
||||
resample(
|
||||
&input[..got * channels],
|
||||
channels,
|
||||
ratio,
|
||||
&mut carry,
|
||||
&mut position,
|
||||
&mut out,
|
||||
);
|
||||
}
|
||||
|
||||
if out.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Blocking, not polling: a paused transport leaves the queue
|
||||
// full, and a thread waking two hundred times a second to
|
||||
// find that out is a thread nobody asked for. The stream
|
||||
// being dropped disconnects the channel, which is the way
|
||||
// out that matters.
|
||||
if blocks.send(out).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
/// Linear interpolation from the file's rate to the device's.
|
||||
///
|
||||
/// Kept as a plain function over plain slices so the test harness can run the
|
||||
/// same arithmetic: this is the one part of playback that can be wrong in a
|
||||
/// way you'd hear rather than a way that fails outright.
|
||||
///
|
||||
/// `carry` holds the last source frame from the previous call, so a block
|
||||
/// boundary interpolates across itself rather than restarting; `position` is
|
||||
/// where we are between frames, in source frames.
|
||||
pub fn resample(
|
||||
input: &[f32],
|
||||
channels: usize,
|
||||
ratio: f64,
|
||||
carry: &mut Vec<f32>,
|
||||
position: &mut f64,
|
||||
out: &mut Vec<f32>,
|
||||
) {
|
||||
if channels == 0 {
|
||||
return;
|
||||
}
|
||||
let mut work: Vec<f32> = Vec::with_capacity(carry.len() + input.len());
|
||||
work.extend_from_slice(carry);
|
||||
work.extend_from_slice(input);
|
||||
let frames = work.len() / channels;
|
||||
if frames < 2 {
|
||||
*carry = work;
|
||||
return;
|
||||
}
|
||||
|
||||
// `position` was left relative to the frame that is now work[0], so it
|
||||
// needs no rebasing here.
|
||||
let mut at = *position;
|
||||
while (at.floor() as usize) + 1 < frames {
|
||||
let index = at.floor() as usize;
|
||||
let fraction = (at - index as f64) as f32;
|
||||
let here = index * channels;
|
||||
let next = here + channels;
|
||||
for channel in 0..channels {
|
||||
let a = work[here + channel];
|
||||
let b = work[next + channel];
|
||||
out.push(a + (b - a) * fraction);
|
||||
}
|
||||
at += ratio;
|
||||
}
|
||||
|
||||
let keep = (at.floor() as usize).min(frames - 1);
|
||||
*carry = work[keep * channels..(keep + 1) * channels].to_vec();
|
||||
*position = at - keep as f64;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* The audio callback */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/// How many channels a meter covers. Past this, audio still plays.
|
||||
const METER_CHANNELS: usize = 64;
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_stream(
|
||||
device: &cpal::Device,
|
||||
config: &cpal::StreamConfig,
|
||||
format: cpal::SampleFormat,
|
||||
channels: usize,
|
||||
out_channels: usize,
|
||||
blocks: Receiver<Vec<f32>>,
|
||||
spare: SyncSender<Vec<f32>>,
|
||||
shared: Arc<Shared>,
|
||||
) -> Result<cpal::Stream, String> {
|
||||
let faulted = Arc::clone(&shared);
|
||||
let on_error = move |_| {
|
||||
faulted.broken.store(true, Ordering::Relaxed);
|
||||
};
|
||||
|
||||
let mut pump = Pump {
|
||||
channels,
|
||||
out_channels,
|
||||
blocks,
|
||||
spare,
|
||||
shared: Arc::clone(&shared),
|
||||
current: None,
|
||||
cursor: 0,
|
||||
};
|
||||
|
||||
let stream = match format {
|
||||
cpal::SampleFormat::F32 => device.build_output_stream(
|
||||
config,
|
||||
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| pump.fill(data, |v| v),
|
||||
on_error,
|
||||
None,
|
||||
),
|
||||
cpal::SampleFormat::I16 => device.build_output_stream(
|
||||
config,
|
||||
move |data: &mut [i16], _: &cpal::OutputCallbackInfo| {
|
||||
pump.fill(data, |v| (v * 32767.0) as i16)
|
||||
},
|
||||
on_error,
|
||||
None,
|
||||
),
|
||||
other => {
|
||||
return Err(format!("this output wants {:?} samples, which we don't write", other))
|
||||
}
|
||||
};
|
||||
|
||||
stream.map_err(|e| format!("could not open the audio output: {}", e))
|
||||
}
|
||||
|
||||
/// Feeds the device: takes blocks from the reader, sums the channels the
|
||||
/// person has left switched on, and writes the result to every output.
|
||||
struct Pump {
|
||||
channels: usize,
|
||||
out_channels: usize,
|
||||
blocks: Receiver<Vec<f32>>,
|
||||
spare: SyncSender<Vec<f32>>,
|
||||
shared: Arc<Shared>,
|
||||
current: Option<Vec<f32>>,
|
||||
cursor: usize,
|
||||
}
|
||||
|
||||
impl Pump {
|
||||
fn fill<S: Copy, F: Fn(f32) -> S>(&mut self, data: &mut [S], convert: F) {
|
||||
if self.out_channels == 0 {
|
||||
return;
|
||||
}
|
||||
let frames = data.len() / self.out_channels;
|
||||
let mut written = 0u64;
|
||||
// On the stack, so the callback allocates nothing. A file with more
|
||||
// channels than this still plays; only the channels past the end go
|
||||
// unmetered, and no field recorder writes 64 tracks to one file.
|
||||
let mut peaks = [0f32; METER_CHANNELS];
|
||||
|
||||
for frame in 0..frames {
|
||||
if !self.ensure() {
|
||||
// Nothing to play: silence, and say so if the file is done.
|
||||
for channel in 0..self.out_channels {
|
||||
data[frame * self.out_channels + channel] = convert(0.0);
|
||||
}
|
||||
if self.shared.finished.load(Ordering::Relaxed) {
|
||||
self.shared.drained.store(true, Ordering::Relaxed);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let block = self.current.as_ref().unwrap();
|
||||
let base = self.cursor * self.channels;
|
||||
let mut sum = 0f32;
|
||||
for channel in 0..self.channels {
|
||||
let raw = block[base + channel];
|
||||
if channel < METER_CHANNELS {
|
||||
let magnitude = raw.abs();
|
||||
if magnitude > peaks[channel] {
|
||||
peaks[channel] = magnitude;
|
||||
}
|
||||
}
|
||||
sum += raw * self.shared.gain(channel);
|
||||
}
|
||||
// Summing several channels can pass full scale, exactly as the
|
||||
// old Web Audio graph could. Clamped rather than wrapped: this is
|
||||
// a monitor path, and a wrap sounds like the file is broken.
|
||||
let sample = convert(sum.clamp(-1.0, 1.0));
|
||||
for channel in 0..self.out_channels {
|
||||
data[frame * self.out_channels + channel] = sample;
|
||||
}
|
||||
self.cursor += 1;
|
||||
written += 1;
|
||||
}
|
||||
|
||||
self.shared.played.fetch_add(written, Ordering::Relaxed);
|
||||
|
||||
// One atomic per channel for the whole callback. Doing this per sample
|
||||
// would put a compare-exchange loop in the hot path for no benefit: a
|
||||
// meter reads at tens of hertz, not at forty-eight thousand.
|
||||
for channel in 0..self.channels.min(METER_CHANNELS) {
|
||||
if peaks[channel] > 0.0 {
|
||||
self.shared.raise(channel, peaks[channel]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes sure there's a block with something left in it.
|
||||
fn ensure(&mut self) -> bool {
|
||||
loop {
|
||||
if let Some(block) = self.current.as_ref() {
|
||||
// A whole frame, not one sample: the loop below reads every
|
||||
// channel of it, and an index past the end here is a panic
|
||||
// unwinding out of a C callback rather than an error.
|
||||
if (self.cursor + 1) * self.channels <= block.len() {
|
||||
return true;
|
||||
}
|
||||
let spent = self.current.take().unwrap();
|
||||
let _ = self.spare.try_send(spent);
|
||||
self.cursor = 0;
|
||||
}
|
||||
match self.blocks.try_recv() {
|
||||
Ok(block) => {
|
||||
self.current = Some(block);
|
||||
self.cursor = 0;
|
||||
}
|
||||
Err(TryRecvError::Empty) | Err(TryRecvError::Disconnected) => return false,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "BWF Analyser",
|
||||
"version": "1.5.1",
|
||||
"identifier": "com.vincentrozenberg.bwf-analyser",
|
||||
"build": {
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "BWF Analyser",
|
||||
"width": 1360,
|
||||
"height": 900,
|
||||
"minWidth": 1040,
|
||||
"minHeight": 560,
|
||||
"resizable": true,
|
||||
"dragDropEnabled": true,
|
||||
"hiddenTitle": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": [
|
||||
"app"
|
||||
],
|
||||
"category": "public.app-category.music",
|
||||
"copyright": "MIT licensed",
|
||||
"shortDescription": "Broadcast Wave metadata analyser",
|
||||
"longDescription": "Reads scene, take, timecode and iXML metadata from a folder of BWF/WAV recordings, and writes edits back to the original files. Everything stays on this machine.",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns"
|
||||
],
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "10.15"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user