Skip to main content

max / audiofiles

v0.3.0: Beta-ready milestone — OTA updater, 7 new themes, observability (115 instruments), analysis and sync improvements Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-17 02:58 UTC
Commit: 89b484b4a999dd5f67114c57308ae0068ff21ccd
Parent: f1a51af
70 files changed, +732 insertions, -34 deletions
M Cargo.lock +9 -2
@@ -426,7 +426,12 @@ dependencies = [
426 426 "cpal",
427 427 "dirs",
428 428 "eframe",
429 + "open",
429 430 "parking_lot",
431 + "reqwest",
432 + "semver",
433 + "serde",
434 + "serde_json",
430 435 "thiserror 2.0.18",
431 436 "tokio",
432 437 "tracing",
@@ -472,6 +477,7 @@ dependencies = [
472 477 "symphonia",
473 478 "tempfile",
474 479 "thiserror 2.0.18",
480 + "tracing",
475 481 ]
476 482
477 483 [[package]]
@@ -508,6 +514,7 @@ dependencies = [
508 514 "tempfile",
509 515 "thiserror 2.0.18",
510 516 "toml 0.8.23",
517 + "tracing",
511 518 ]
512 519
513 520 [[package]]
@@ -4983,7 +4990,7 @@ dependencies = [
4983 4990
4984 4991 [[package]]
4985 4992 name = "synckit-client"
4986 - version = "0.2.1"
4993 + version = "0.2.2"
4987 4994 dependencies = [
4988 4995 "argon2",
4989 4996 "base64",
@@ -4996,13 +5003,13 @@ dependencies = [
4996 5003 "reqwest",
4997 5004 "serde",
4998 5005 "serde_json",
4999 - "sha2",
5000 5006 "thiserror 2.0.18",
5001 5007 "tokio",
5002 5008 "tracing",
5003 5009 "unicode-normalization",
5004 5010 "urlencoding",
5005 5011 "uuid 1.21.0",
5012 + "zeroize",
5006 5013 ]
5007 5014
5008 5015 [[package]]
M Cargo.toml +3
@@ -43,3 +43,6 @@ base64 = "0.22"
43 43 chrono = "0.4"
44 44 rand = "0.8"
45 45 smallvec = "1.13"
46 + reqwest = { version = "0.12", default-features = false, features = ["json", "native-tls"] }
47 + semver = "1"
48 + open = "5"
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "audiofiles-app"
3 - version = "0.2.1"
3 + version = "0.3.0"
4 4 edition.workspace = true
5 5
6 6 [dependencies]
@@ -16,3 +16,8 @@ thiserror = { workspace = true }
16 16 tracing-subscriber = { workspace = true }
17 17 tray-icon = { workspace = true }
18 18 tokio = { workspace = true }
19 + reqwest = { workspace = true }
20 + semver = { workspace = true }
21 + serde = { workspace = true }
22 + serde_json = { workspace = true }
23 + open = { workspace = true }
@@ -5,6 +5,7 @@
5 5
6 6 mod audio;
7 7 mod tray;
8 + pub mod updater;
8 9
9 10 use std::path::{Path, PathBuf};
10 11 use std::sync::Arc;
@@ -42,6 +43,9 @@ fn main() -> eframe::Result<()> {
42 43 // SyncManager (optional, configured via env vars)
43 44 let sync_manager = create_sync_manager(&data_dir, runtime.handle());
44 45
46 + // OTA update checker (runs in background on the tokio runtime)
47 + let update_checker = updater::UpdateChecker::new(runtime.handle());
48 +
45 49 let shared = Arc::new(SharedState::new());
46 50
47 51 // Start cpal audio output stream
@@ -76,7 +80,7 @@ fn main() -> eframe::Result<()> {
76 80 options,
77 81 Box::new(move |_cc| {
78 82 Ok(Box::new(AudioFilesApp::new(
79 - data_dir, shared, app_tray, sync_manager, runtime,
83 + data_dir, shared, app_tray, sync_manager, update_checker, runtime,
80 84 )))
81 85 }),
82 86 )
@@ -106,6 +110,7 @@ struct AudioFilesApp {
106 110 error: Option<String>,
107 111 tray: Option<tray::AppTray>,
108 112 sync_manager: Option<SyncManager>,
113 + update_checker: updater::UpdateChecker,
109 114 _runtime: tokio::runtime::Runtime,
110 115 }
111 116
@@ -115,6 +120,7 @@ impl AudioFilesApp {
115 120 shared: Arc<SharedState>,
116 121 tray: Option<tray::AppTray>,
117 122 sync_manager: Option<SyncManager>,
123 + update_checker: updater::UpdateChecker,
118 124 runtime: tokio::runtime::Runtime,
119 125 ) -> Self {
120 126 let sample_rate = 44100.0;
@@ -133,6 +139,7 @@ impl AudioFilesApp {
133 139 error: None,
134 140 tray,
135 141 sync_manager,
142 + update_checker,
136 143 _runtime: runtime,
137 144 }
138 145 }
@@ -143,6 +150,7 @@ impl AudioFilesApp {
143 150 error: Some(format!("{e}")),
144 151 tray,
145 152 sync_manager,
153 + update_checker,
146 154 _runtime: runtime,
147 155 }
148 156 }
@@ -225,5 +233,35 @@ impl eframe::App for AudioFilesApp {
225 233 }
226 234 });
227 235 }
236 +
237 + // Show update notification overlay (bottom-right) — user must consent
238 + if self.update_checker.should_show() {
239 + let status = self.update_checker.status.lock().clone();
240 + egui::Area::new(egui::Id::new("update-banner"))
241 + .anchor(egui::Align2::RIGHT_BOTTOM, egui::vec2(-12.0, -12.0))
242 + .order(egui::Order::Foreground)
243 + .show(ctx, |ui| {
244 + egui::Frame::popup(ui.style())
245 + .inner_margin(12.0)
246 + .show(ui, |ui| {
247 + ui.set_max_width(280.0);
248 + ui.strong(format!("Update Available: v{}", status.version));
249 + if !status.notes.is_empty() {
250 + ui.label(&status.notes);
251 + }
252 + ui.add_space(4.0);
253 + ui.horizontal(|ui| {
254 + if ui.button("Download").clicked() {
255 + if !status.download_url.is_empty() {
256 + let _ = open::that(&status.download_url);
257 + }
258 + }
259 + if ui.button("Not Now").clicked() {
260 + self.update_checker.dismiss();
261 + }
262 + });
263 + });
264 + });
265 + }
228 266 }
229 267 }
@@ -0,0 +1,159 @@
1 + //! OTA update checker for AudioFiles standalone app.
2 + //!
3 + //! Checks the MNW OTA endpoint on startup and periodically. Stores the result
4 + //! in shared state so the egui UI can display a notification.
5 +
6 + use std::sync::Arc;
7 +
8 + use parking_lot::Mutex;
9 + use semver::Version;
10 +
11 + /// OTA updater endpoint base URL.
12 + const OTA_BASE_URL: &str = "https://makenot.work/api/sync/ota/audiofiles";
13 +
14 + /// How long to wait after startup before first check (seconds).
15 + const INITIAL_DELAY_SECS: u64 = 10;
16 +
17 + /// How often to re-check for updates (seconds). 6 hours.
18 + const CHECK_INTERVAL_SECS: u64 = 6 * 60 * 60;
19 +
20 + /// Current app version (from Cargo.toml at compile time).
21 + const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
22 +
23 + /// The response format from the MNW OTA updater endpoint.
24 + #[derive(serde::Deserialize)]
25 + struct UpdateResponse {
26 + version: String,
27 + url: String,
28 + notes: String,
29 + }
30 +
31 + /// Shared update status, polled by the UI each frame.
32 + #[derive(Clone)]
33 + pub struct UpdateStatus {
34 + pub available: bool,
35 + pub version: String,
36 + pub notes: String,
37 + pub download_url: String,
38 + pub dismissed: bool,
39 + }
40 +
41 + impl Default for UpdateStatus {
42 + fn default() -> Self {
43 + Self {
44 + available: false,
45 + version: String::new(),
46 + notes: String::new(),
47 + download_url: String::new(),
48 + dismissed: false,
49 + }
50 + }
51 + }
52 +
53 + /// Handle to the update checker. Clone-cheap (Arc-wrapped).
54 + #[derive(Clone)]
55 + pub struct UpdateChecker {
56 + pub status: Arc<Mutex<UpdateStatus>>,
57 + }
58 +
59 + impl UpdateChecker {
60 + /// Create a new checker and spawn the background check loop on the given runtime.
61 + pub fn new(runtime: &tokio::runtime::Handle) -> Self {
62 + let status = Arc::new(Mutex::new(UpdateStatus::default()));
63 + let checker = Self { status: status.clone() };
64 +
65 + runtime.spawn(async move {
66 + tokio::time::sleep(std::time::Duration::from_secs(INITIAL_DELAY_SECS)).await;
67 + loop {
68 + check_once(&status).await;
69 + tokio::time::sleep(std::time::Duration::from_secs(CHECK_INTERVAL_SECS)).await;
70 + }
71 + });
72 +
73 + checker
74 + }
75 +
76 + /// Dismiss the update notification (user clicked dismiss).
77 + pub fn dismiss(&self) {
78 + self.status.lock().dismissed = true;
79 + }
80 +
81 + /// Whether to show the update banner.
82 + pub fn should_show(&self) -> bool {
83 + let s = self.status.lock();
84 + s.available && !s.dismissed
85 + }
86 + }
87 +
88 + /// Check the MNW OTA endpoint once.
89 + async fn check_once(status: &Arc<Mutex<UpdateStatus>>) {
90 + let current = match Version::parse(CURRENT_VERSION) {
91 + Ok(v) => v,
92 + Err(e) => {
93 + tracing::warn!("Failed to parse current version {CURRENT_VERSION}: {e}");
94 + return;
95 + }
96 + };
97 +
98 + let target = if cfg!(target_os = "macos") {
99 + "darwin"
100 + } else if cfg!(target_os = "linux") {
101 + "linux"
102 + } else if cfg!(target_os = "windows") {
103 + "windows"
104 + } else {
105 + return;
106 + };
107 +
108 + let arch = if cfg!(target_arch = "x86_64") {
109 + "x86_64"
110 + } else if cfg!(target_arch = "aarch64") {
111 + "aarch64"
112 + } else {
113 + return;
114 + };
115 +
116 + let url = format!("{OTA_BASE_URL}/{target}/{arch}/{CURRENT_VERSION}");
117 +
118 + let client = match reqwest::Client::builder()
119 + .timeout(std::time::Duration::from_secs(15))
120 + .build()
121 + {
122 + Ok(c) => c,
123 + Err(e) => {
124 + tracing::warn!("Failed to build HTTP client for update check: {e}");
125 + return;
126 + }
127 + };
128 +
129 + match client.get(&url).send().await {
130 + Ok(resp) if resp.status().as_u16() == 204 => {
131 + tracing::info!("AudioFiles is up to date (v{CURRENT_VERSION})");
132 + }
133 + Ok(resp) if resp.status().is_success() => {
134 + match resp.json::<UpdateResponse>().await {
135 + Ok(update) => {
136 + if let Ok(remote) = Version::parse(&update.version) {
137 + if remote > current {
138 + tracing::info!("Update available: v{}", update.version);
139 + let mut s = status.lock();
140 + s.available = true;
141 + s.version = update.version;
142 + s.notes = update.notes;
143 + s.download_url = update.url;
144 + }
145 + }
146 + }
147 + Err(e) => {
148 + tracing::warn!("Failed to parse update response: {e}");
149 + }
150 + }
151 + }
152 + Ok(resp) => {
153 + tracing::debug!("Update check returned status {}", resp.status());
154 + }
155 + Err(e) => {
156 + tracing::warn!("Update check request failed: {e}");
157 + }
158 + }
159 + }
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "audiofiles-browser"
3 - version = "0.2.1"
3 + version = "0.3.0"
4 4 edition.workspace = true
5 5
6 6 [features]
@@ -6,6 +6,8 @@
6 6
7 7 use std::path::{Path, PathBuf};
8 8
9 + use tracing::instrument;
10 +
9 11 use audiofiles_core::analysis::config::AnalysisConfig;
10 12 use audiofiles_core::analysis::waveform::WaveformData;
11 13 use audiofiles_core::analysis::AnalysisResult;
@@ -75,6 +77,7 @@ impl DirectBackend {
75 77 /// Called before spawning the export worker so profile resolution happens
76 78 /// on the main thread (where PluginRegistry is accessible).
77 79 #[cfg(feature = "device-profiles")]
80 + #[instrument(skip_all)]
78 81 fn resolve_device_profile(
79 82 &self,
80 83 config: &mut audiofiles_core::export::ExportConfig,
@@ -93,6 +93,7 @@ pub fn spawn_export_worker(store_root: PathBuf) -> ExportHandle {
93 93 }
94 94 }
95 95
96 + #[instrument(skip_all)]
96 97 fn worker_loop(
97 98 cmd_rx: mpsc::Receiver<ExportCommand>,
98 99 event_tx: mpsc::Sender<ExportEvent>,
@@ -137,6 +137,7 @@ pub fn spawn_import_worker(db_path: PathBuf, store_root: PathBuf) -> ImportHandl
137 137
138 138 /// Recursively count audio files under `dir`. Checks for cancellation between entries.
139 139 /// Returns `None` if cancelled.
140 + #[instrument(skip_all)]
140 141 fn count_audio_files(dir: &Path, cmd_rx: &mpsc::Receiver<ImportCommand>) -> Option<usize> {
141 142 let mut count = 0;
142 143 let mut stack = vec![dir.to_path_buf()];
@@ -5,6 +5,8 @@
5 5
6 6 use std::path::Path;
7 7
8 + use tracing::instrument;
9 +
8 10 use symphonia::core::audio::SampleBuffer;
9 11 use symphonia::core::codecs::DecoderOptions;
10 12 use symphonia::core::formats::FormatOptions;
@@ -45,6 +47,7 @@ impl PreviewPlayback {
45 47 /// Decode an audio file to interleaved stereo f32.
46 48 /// Mono files are doubled to stereo. Multi-channel files are mixed down to stereo.
47 49 /// No resampling — pitch may shift if sample rate != host rate (known Phase 2 limitation).
50 + #[instrument(skip_all)]
48 51 pub fn decode_to_f32(path: &Path) -> Result<PreviewBuffer, PreviewError> {
49 52 let file = std::fs::File::open(path).map_err(|e| PreviewError::Open {
50 53 path: path.to_path_buf(),
@@ -163,11 +163,15 @@ pub fn draw_filter_panel(ui: &mut egui::Ui, state: &mut BrowserState) {
163 163 if state.search_filter.is_active() {
164 164 ui.add_space(8.0);
165 165 ui.separator();
166 - ui.label("Save as Smart Folder");
166 + ui.label(egui::RichText::new("Save as Smart Folder").strong().color(theme::text_secondary()));
167 + ui.label(egui::RichText::new("Save current filters as a reusable preset")
168 + .small()
169 + .color(theme::text_muted()));
170 + ui.add_space(4.0);
167 171 ui.horizontal(|ui| {
168 172 ui.add(
169 173 egui::TextEdit::singleline(&mut state.smart_folder_name_input)
170 - .hint_text("Folder name...")
174 + .hint_text("e.g. Kicks Under 120 BPM")
171 175 .desired_width(ui.available_width() - 50.0),
172 176 );
173 177 let name = state.smart_folder_name_input.trim().to_string();
@@ -152,11 +152,11 @@ fn draw_piano_keyboard(ui: &mut egui::Ui, state: &mut BrowserState) {
152 152 let fill = if is_active {
153 153 theme::accent_blue()
154 154 } else {
155 - egui::Color32::from_gray(240)
155 + theme::piano_white_key()
156 156 };
157 157
158 158 painter.rect_filled(key_rect, 2.0, fill);
159 - painter.rect_stroke(key_rect, 2.0, egui::Stroke::new(1.0, egui::Color32::from_gray(160)), egui::StrokeKind::Outside);
159 + painter.rect_stroke(key_rect, 2.0, egui::Stroke::new(1.0, theme::border_default()), egui::StrokeKind::Outside);
160 160
161 161 if is_root {
162 162 let dot_center = key_rect.center_bottom() - egui::vec2(0.0, 8.0);
@@ -198,7 +198,7 @@ fn draw_piano_keyboard(ui: &mut egui::Ui, state: &mut BrowserState) {
198 198 let fill = if is_active {
199 199 theme::accent_blue()
200 200 } else {
201 - egui::Color32::from_gray(40)
201 + theme::piano_black_key()
202 202 };
203 203
204 204 painter.rect_filled(key_rect, 2.0, fill);
@@ -387,6 +387,10 @@ pub fn draw_vfs_create_modal(ctx: &egui::Context, state: &mut BrowserState) {
387 387 .resizable(false)
388 388 .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
389 389 .show(ctx, |ui| {
390 + ui.label(egui::RichText::new("Libraries are top-level collections. Right-click inside a library to create folders.")
391 + .small()
392 + .color(super::theme::text_muted()));
393 + ui.add_space(4.0);
390 394 ui.label("Library name:");
391 395 let resp = ui.text_edit_singleline(&mut state.vfs_create_input);
392 396 if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
@@ -41,7 +41,7 @@ pub fn draw_sidebar(ui: &mut egui::Ui, state: &mut BrowserState) {
41 41 });
42 42 }
43 43
44 - if ui.small_button("+").on_hover_text("New library").clicked() {
44 + if ui.button("+ New Library").on_hover_text("Create a new library to organize samples").clicked() {
45 45 state.show_vfs_create = true;
46 46 state.vfs_create_input.clear();
47 47 }
@@ -51,8 +51,12 @@ pub fn draw_sidebar(ui: &mut egui::Ui, state: &mut BrowserState) {
51 51
52 52 // Smart Folders section
53 53 ui.collapsing("Smart Folders", |ui| {
54 + ui.label(egui::RichText::new("Saved filter presets. Set filters in the right panel, then save.")
55 + .small()
56 + .color(theme::text_muted()));
57 + ui.add_space(4.0);
54 58 if state.smart_folders.is_empty() {
55 - ui.label(egui::RichText::new("No smart folders").color(theme::text_muted()));
59 + ui.label(egui::RichText::new("No smart folders yet").color(theme::text_muted()));
56 60 } else {
57 61 let folders = state.smart_folders.clone();
58 62 let mut delete_idx = None;
@@ -6,6 +6,7 @@ use tracing::{debug, error, warn};
6 6 use audiofiles_sync::{SyncManager, SyncState, SyncStatus};
7 7
8 8 use crate::state::BrowserState;
9 + use crate::ui::theme;
9 10
10 11 /// Draw the sync settings panel as a floating window.
11 12 pub fn draw_sync_panel(
@@ -41,7 +42,7 @@ pub fn draw_sync_panel(
41 42 // Show last error if any
42 43 if let Some(ref err) = status.last_error {
43 44 ui.separator();
44 - ui.colored_label(egui::Color32::from_rgb(200, 80, 80), err);
45 + ui.colored_label(theme::accent_red(), err);
45 46 }
46 47 });
47 48 state.show_sync_panel = open;
@@ -259,7 +260,7 @@ fn draw_ready(
259 260
260 261 // Disconnect button
261 262 if ui
262 - .button(egui::RichText::new("Disconnect").color(egui::Color32::from_rgb(200, 80, 80)))
263 + .button(egui::RichText::new("Disconnect").color(theme::accent_red()))
263 264 .clicked()
264 265 {
265 266 sync.disconnect();
@@ -27,6 +27,13 @@ static BUNDLED_THEMES: &[(&str, &str)] = &[
27 27 ("flatwhite", include_str!("../../themes/flatwhite.toml")),
28 28 ("neobrute", include_str!("../../themes/neobrute.toml")),
29 29 ("high-contrast", include_str!("../../themes/high-contrast.toml")),
30 + ("gruvbox-dark", include_str!("../../themes/gruvbox-dark.toml")),
31 + ("gruvbox-light", include_str!("../../themes/gruvbox-light.toml")),
32 + ("rosepine", include_str!("../../themes/rosepine.toml")),
33 + ("rosepine-dawn", include_str!("../../themes/rosepine-dawn.toml")),
34 + ("everforest", include_str!("../../themes/everforest.toml")),
35 + ("solarized-dark", include_str!("../../themes/solarized-dark.toml")),
36 + ("kanagawa", include_str!("../../themes/kanagawa.toml")),
30 37 ];
31 38
32 39 /// The 15-slot universal theme palette.
@@ -146,6 +153,15 @@ pub fn accent_cyan() -> Color32 { THEME.read().accent_cyan }
146 153 /// Default border/separator color.
147 154 pub fn border_default() -> Color32 { THEME.read().border_default }
148 155
156 + /// Piano white key — always a light shade regardless of theme variant.
157 + pub fn piano_white_key() -> Color32 {
158 + lerp_color(THEME.read().bg_surface, Color32::WHITE, 0.7)
159 + }
160 + /// Piano black key — always a dark shade regardless of theme variant.
161 + pub fn piano_black_key() -> Color32 {
162 + lerp_color(THEME.read().bg_surface, Color32::BLACK, 0.7)
163 + }
164 +
149 165 // --- Theme discovery ---
150 166
151 167 /// Return the custom themes directory (`<config>/audiofiles/themes/`).
@@ -66,7 +66,7 @@ pub fn draw_waveform(
66 66 let x = rect.left() + pos.clamp(0.0, 1.0) * rect.width();
67 67 painter.line_segment(
68 68 [egui::pos2(x, rect.top()), egui::pos2(x, rect.bottom())],
69 - egui::Stroke::new(1.5, egui::Color32::WHITE),
69 + egui::Stroke::new(1.5, theme::text_primary()),
70 70 );
71 71 }
72 72
@@ -1,17 +1,20 @@
1 + # Based on Ayu by Konstantin Pschera — MIT License
2 + # https://github.com/ayu-theme
3 +
1 4 [meta]
2 5 name = "Ayu Light"
3 6 variant = "light"
4 7
5 8 [background]
6 9 primary = "#e7eaed"
7 - secondary = "#e7eaed"
8 - tertiary = "#d8d8d7"
9 - surface = "#fafafa"
10 + secondary = "#dde1e5"
11 + tertiary = "#d0d4d8"
12 + surface = "#f2f4f6"
10 13
11 14 [foreground]
12 15 primary = "#5c6166"
13 16 secondary = "#6b7580"
14 - muted = "#6b7580"
17 + muted = "#8b9199"
15 18
16 19 [accent]
17 20 red = "#f07171"
@@ -1,3 +1,6 @@
1 + # Based on Catppuccin by Catppuccin Org — MIT License
2 + # https://github.com/catppuccin/catppuccin
3 +
1 4 [meta]
2 5 name = "Catppuccin Latte"
3 6 variant = "light"
@@ -1,3 +1,6 @@
1 + # Based on Catppuccin by Catppuccin Org — MIT License
2 + # https://github.com/catppuccin/catppuccin
3 +
1 4 [meta]
2 5 name = "Catppuccin Mocha"
3 6 variant = "dark"