diff --git a/Cargo.lock b/Cargo.lock
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3616,6 +3616,7 @@ dependencies = [
  "objc2-app-kit 0.3.2",
  "rfd",
  "serde_json",
+ "soundcraft-audio-io",
  "soundcraft-engine",
  "soundcraft-playback",
  "soundcraft-ui-egui",
diff --git a/ROADMAP.md b/ROADMAP.md
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,7 +2,7 @@
 
 **Stage: pre-alpha** · next: alpha, ~5% and ~110–180 h away (the alpha gate's three failing workflows)
 
-> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** trivial (mono device playback fold-down) · **Target:** Avid Pro Tools Ultimate 2026.4.1
+> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** minor (native audio import loader and cancellation) · **Target:** Avid Pro Tools Ultimate 2026.4.1
 
 SoundCraft is a clean-room, pure-Rust digital audio workstation that aims at full parity with
 Avid Pro Tools, and then beyond it: faster, open, scriptable and agent-controllable. This page is
@@ -122,6 +122,7 @@ mutated and truncated movies. Field reports from the first public week are track
 
 ## Progress log
 
+- **2026-10-24:** Native Import Audio, Workspace imports and audio drops show a cumulative loading bar with filename, file count and preparation stages. Sample processing runs off the UI thread; Cancel/Esc discards the batch, failures leave no partial tracks, and Undo restores the whole batch. The bar reaches 100% after successful placement. CLI/control imports retain synchronous results. Browser byte imports remain synchronous. No readiness percentage change.
 - **2026-10-18:** Mono audio devices receive the average of the stereo channels before output clipping, retaining right-only audio. Surround mixes use the same average after their ITU stereo fold-down. Regression tests cover channel balance, polarity cancellation, clipping, centered mono tracks, each 5.1 channel and several callback sizes. No readiness percentage change.
 - **2026-10-16:** Master fader volume automation uses each render block's actual last sample, matching track faders. Breakpoints in the next block no longer pull the preceding block's level toward them, and ramps reach their last-sample value. Regression tests cover nonzero starts, one-sample callbacks and several block sizes. No readiness percentage change.
 - **2026-10-16:** Aux, folder and master latency calculations exclude inserts already taken out for producing NaN or infinity, matching audio tracks. Subsequent blocks no longer compensate for an insert that is bypassed after failure. Regression tests check impulse timing, healthy insert latency and reset/retry at 64- and 512-sample blocks. No readiness percentage change.
@@ -161,6 +162,7 @@ mutated and truncated movies. Field reports from the first public week are track
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-24 | minor | Native audio import prepares decoding, rate conversion and waveforms in the background; cumulative progress, cancellation, atomic batch import and one-step Undo; readiness estimates unchanged |
 | 2026-10-18 | trivial | Mono device playback retains both stereo sides and surround fold-down channels; readiness estimates unchanged |
 | 2026-10-16 | trivial | Master volume automation block boundaries corrected; readiness estimates unchanged |
 | 2026-10-16 | trivial | Failed inserts no longer contribute aux, folder or master latency after containment; readiness estimates unchanged |
diff --git a/apps/soundcraft/Cargo.toml b/apps/soundcraft/Cargo.toml
--- a/apps/soundcraft/Cargo.toml
+++ b/apps/soundcraft/Cargo.toml
@@ -27,6 +27,9 @@ chrono.workspace = true
 [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
 rfd.workspace = true
 
+[dev-dependencies]
+soundcraft-audio-io.workspace = true
+
 [build-dependencies]
 # Icon + VERSIONINFO for soundcraft.exe; build.rs only uses it when the target is Windows.
 winresource = "0.1"
diff --git a/apps/soundcraft/src/main.rs b/apps/soundcraft/src/main.rs
--- a/apps/soundcraft/src/main.rs
+++ b/apps/soundcraft/src/main.rs
@@ -53,9 +53,7 @@ impl eframe::App for App {
         // the sampler; otherwise sessions open and audio/MIDI import.
         let dropped: Vec<String> =
             self.0.take_dropped_files(ctx).iter().map(|f| f.path().to_string_lossy().into_owned()).filter(|s| !s.is_empty()).collect();
-        for p in dropped {
-            open_path(&mut self.0, &p);
-        }
+        open_paths(&mut self.0, &dropped);
         if self.0.quit_requested {
             ctx.send_viewport_cmd(egui::ViewportCommand::Close);
         } else if ctx.input(|i| i.viewport().close_requested()) && dialogs::ask_to_save(&mut self.0, "app.quit", &serde_json::json!({})) {
@@ -105,18 +103,27 @@ impl eframe::App for App {
     }
 }
 
-fn open_path(app: &mut SoundApp, p: &str) {
-    let lower = p.to_ascii_lowercase();
-    let r = if lower.ends_with(".scraft") {
-        dialogs::run_or_ask(app, "session.open", serde_json::json!({"path": p}))
-    } else if lower.ends_with(".mid") || lower.ends_with(".midi") || lower.ends_with(".smf") {
-        app.run("file.import_midi", serde_json::json!({"path": p}))
-    } else {
+fn open_paths(app: &mut SoundApp, paths: &[String]) {
+    let mut audio = Vec::new();
+    for p in paths {
+        let lower = p.to_ascii_lowercase();
+        let r = if lower.ends_with(".scraft") {
+            dialogs::run_or_ask(app, "session.open", serde_json::json!({"path": p}))
+        } else if lower.ends_with(".mid") || lower.ends_with(".midi") || lower.ends_with(".smf") {
+            app.run("file.import_midi", serde_json::json!({"path": p}))
+        } else {
+            audio.push(p);
+            continue;
+        };
+        if let Err(e) = r {
+            log::warn!("{p}: {e}");
+        }
+    }
+    if !audio.is_empty() {
         let at = app.engine.session().edit.selection.start;
-        app.run("file.import_audio", serde_json::json!({"path": p, "at": at}))
-    };
-    if let Err(e) = r {
-        log::warn!("{p}: {e}");
+        if let Err(e) = soundcraft_ui_egui::import::start(app, &serde_json::json!({"paths": audio, "at": at})) {
+            log::warn!("Import Audio: {e}");
+        }
     }
 }
 
@@ -279,9 +286,7 @@ fn main() -> eframe::Result {
                 let rx = control_server::start(port, cc.egui_ctx.clone());
                 app = app.with_control(rx);
             }
-            for f in &files {
-                open_path(&mut app, f);
-            }
+            open_paths(&mut app, &files);
             // If the system menu bar cannot be built, keep the egui menu bar rather than fail to start.
             #[cfg(target_os = "macos")]
             let menu = match native_menu::NativeMenu::new(&app, &cc.egui_ctx) {
@@ -313,6 +318,34 @@ fn sane_window_size([w, h]: [f32; 2]) -> [f32; 2] {
 mod tests {
     use super::set_title;
 
+    #[test]
+    fn dropping_two_audio_files_imports_both_in_one_batch() {
+        let dir = std::env::temp_dir().join(format!("sc-native-drop-{}", std::process::id()));
+        std::fs::create_dir_all(&dir).unwrap();
+        let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.2; 480]] };
+        let bytes = soundcraft_audio_io::encode(&buf, &soundcraft_audio_io::EncodeOptions::default()).unwrap();
+        let files: Vec<_> = ["one.wav", "two.wav"].into_iter().map(|name| dir.join(name).to_string_lossy().into_owned()).collect();
+        for path in &files {
+            std::fs::write(path, &bytes).unwrap();
+        }
+        let mut app = super::SoundApp::new(soundcraft_engine::Engine::default(), None, soundcraft_ui_egui::Services::default());
+        super::open_paths(&mut app, &files);
+        let ctx = egui::Context::default();
+        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
+        while app.engine.session().tracks.len() < 2 {
+            let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
+                app.logic(ui.ctx());
+                app.ui(ui);
+            });
+            output.textures_delta.clear();
+            assert!(std::time::Instant::now() < deadline, "{}", app.ui.status);
+            std::thread::sleep(std::time::Duration::from_millis(1));
+        }
+        assert_eq!(app.engine.undo_history().len(), 1);
+        assert_eq!(app.ui.status, "Imported 2 files");
+        let _ = std::fs::remove_dir_all(dir);
+    }
+
     #[test]
     fn the_title_is_sent_only_when_it_changes() {
         let ctx = egui::Context::default();
diff --git a/crates/au-host/src/lib.rs b/crates/au-host/src/lib.rs
--- a/crates/au-host/src/lib.rs
+++ b/crates/au-host/src/lib.rs
@@ -192,7 +192,7 @@ pub enum AuError {
 
 #[cfg(target_os = "macos")]
 mod api {
-    use super::{AuDescriptor, AuError, Plugin, PluginInfo};
+    use super::{AuDescriptor, AuError, Plugin, PluginInfo, registry};
 
     pub fn scan() -> Vec<AuDescriptor> {
         registry::scan()
diff --git a/crates/audio-io/src/aiff.rs b/crates/audio-io/src/aiff.rs
--- a/crates/audio-io/src/aiff.rs
+++ b/crates/audio-io/src/aiff.rs
@@ -144,10 +144,10 @@ pub(crate) fn parse(b: &[u8]) -> Result<AiffParse> {
     }))
 }
 
-pub(crate) fn decode(b: &[u8], layout: &AiffLayout) -> Result<AudioBuffer> {
+pub(crate) fn decode(b: &[u8], layout: &AiffLayout, progress: &mut dyn FnMut(f32) -> Result<()>) -> Result<AudioBuffer> {
     let end = layout.data_start.saturating_add(layout.data_len).min(b.len());
     let data = b.get(layout.data_start..end).unwrap_or_default();
-    let channels = deinterleave(data, layout.kind, usize::from(layout.info.channels), layout.info.frames)?;
+    let channels = deinterleave(data, layout.kind, usize::from(layout.info.channels), layout.info.frames, progress)?;
     Ok(AudioBuffer { sample_rate: layout.info.sample_rate, channels })
 }
 
diff --git a/crates/audio-io/src/lib.rs b/crates/audio-io/src/lib.rs
--- a/crates/audio-io/src/lib.rs
+++ b/crates/audio-io/src/lib.rs
@@ -42,6 +42,9 @@ pub enum AudioError {
     /// Encoding failed (e.g. an empty buffer).
     #[error("audio encode failed: {0}")]
     Encode(String),
+    /// Decoding was cancelled by its caller.
+    #[error("audio decode cancelled")]
+    Cancelled,
     /// Writing the output failed.
     #[error("audio write failed: {0}")]
     Io(#[from] std::io::Error),
@@ -226,34 +229,44 @@ pub fn probe(bytes: &[u8], ext_hint: Option<&str>) -> Result<AudioInfo> {
     }
 }
 
-/// Decode a whole file to planar `f32`.
-pub fn decode(bytes: &[u8], ext_hint: Option<&str>) -> Result<(AudioInfo, AudioBuffer)> {
+/// Decode to planar `f32`, reporting progress per PCM block or codec packet.
+/// A callback error stops decoding. Unknown lengths report zero until completion.
+/// Progress measures frames, independent of channel count; one means decoding has finished.
+pub fn decode_with(bytes: &[u8], ext_hint: Option<&str>, mut progress: impl FnMut(f32) -> Result<()>) -> Result<(AudioInfo, AudioBuffer)> {
+    progress(0.0)?;
     let format = detect_format(bytes, ext_hint);
-    match format {
+    let decoded = match format {
         FileFormat::Wav if wav::is_wav(bytes) => match wav::parse(bytes)? {
             wav::WavParse::Native(l) => {
-                let buf = wav::decode(bytes, &l)?;
+                let buf = wav::decode(bytes, &l, &mut progress)?;
                 let mut info = l.info;
                 info.frames = buf.frames() as u64;
                 Ok((info, buf))
             }
             wav::WavParse::Foreign { bwf } => {
-                let (mut info, buf) = symph::decode(bytes, format)?;
+                let (mut info, buf) = symph::decode(bytes, format, &mut progress)?;
                 info.bwf = bwf;
                 Ok((info, buf))
             }
         },
         FileFormat::Aiff if aiff::is_aiff(bytes) => match aiff::parse(bytes)? {
             aiff::AiffParse::Native(l) => {
-                let buf = aiff::decode(bytes, &l)?;
+                let buf = aiff::decode(bytes, &l, &mut progress)?;
                 let mut info = l.info;
                 info.frames = buf.frames() as u64;
                 Ok((info, buf))
             }
-            aiff::AiffParse::Foreign => symph::decode(bytes, format),
+            aiff::AiffParse::Foreign => symph::decode(bytes, format, &mut progress),
         },
-        _ => symph::decode(bytes, format),
-    }
+        _ => symph::decode(bytes, format, &mut progress),
+    }?;
+    progress(1.0)?;
+    Ok(decoded)
+}
+
+/// Decode a whole file to planar `f32`.
+pub fn decode(bytes: &[u8], ext_hint: Option<&str>) -> Result<(AudioInfo, AudioBuffer)> {
+    decode_with(bytes, ext_hint, |_| Ok(()))
 }
 
 /// Encode `buf` as WAV/BWF, AIFF/AIFF-C or FLAC. Samples are clamped to [-1, 1].
@@ -371,3 +384,52 @@ pub fn wav_float_header(channels: usize, sample_rate: u32, frames: u64) -> Resul
     }
     wav::header(channels, sample_rate, (32, true), None, None, frames, false)
 }
+
+#[cfg(test)]
+mod decode_progress_tests {
+    use super::*;
+
+    fn encoded(format: FileFormat, channels: usize) -> Vec<u8> {
+        let tone: Vec<f32> = (0..24_000).map(|i| (i as f32 * 0.1).sin() * 0.2).collect();
+        encode(&AudioBuffer { sample_rate: 48_000, channels: vec![tone; channels] }, &EncodeOptions { format, ..Default::default() }).unwrap()
+    }
+
+    #[test]
+    fn progress_counts_frames_for_mono_and_stereo_in_all_decoders() {
+        for format in [FileFormat::Wav, FileFormat::Aiff, FileFormat::Flac] {
+            let mut sequences = Vec::new();
+            for channels in [1, 2] {
+                let mut progress = Vec::new();
+                let (_, decoded) = decode_with(&encoded(format, channels), None, |f| {
+                    progress.push(f);
+                    Ok(())
+                })
+                .unwrap();
+                assert_eq!(decoded.frames(), 24_000);
+                assert_eq!(decoded.num_channels(), channels);
+                assert_eq!(progress.first(), Some(&0.0));
+                assert_eq!(progress.last(), Some(&1.0));
+                assert!(progress.iter().any(|f| *f > 0.5 && *f < 1.0), "must report intermediate progress: {format:?}");
+                assert!(progress.windows(2).all(|p| p[0] <= p[1]));
+                sequences.push(progress);
+            }
+            assert_eq!(sequences[0], sequences[1], "channel count must not change progress: {format:?}");
+        }
+    }
+
+    #[test]
+    fn cancellation_interrupts_pcm_and_compressed_decoding() {
+        for format in [FileFormat::Wav, FileFormat::Aiff, FileFormat::Flac] {
+            let mut calls = 0;
+            let error = decode_with(&encoded(format, 2), None, |f| {
+                calls += 1;
+                if f > 0.0 { Err(AudioError::Cancelled) } else { Ok(()) }
+            })
+            .unwrap_err();
+            assert!(matches!(error, AudioError::Cancelled));
+            assert!(calls > 1);
+        }
+        let error = decode_with(&[], None, |_| Err(AudioError::Cancelled)).unwrap_err();
+        assert!(matches!(error, AudioError::Cancelled), "cancellation precedes parsing");
+    }
+}
diff --git a/crates/audio-io/src/pcm.rs b/crates/audio-io/src/pcm.rs
--- a/crates/audio-io/src/pcm.rs
+++ b/crates/audio-io/src/pcm.rs
@@ -102,7 +102,13 @@ fn decode_sample(s: &[u8], kind: PcmKind) -> f32 {
 }
 
 /// Deinterleave `frames` frames of `channels`-channel PCM from `data` into planar `f32`.
-pub(crate) fn deinterleave(data: &[u8], kind: PcmKind, channels: usize, frames: u64) -> Result<Vec<Vec<f32>>> {
+pub(crate) fn deinterleave(
+    data: &[u8],
+    kind: PcmKind,
+    channels: usize,
+    frames: u64,
+    progress: &mut dyn FnMut(f32) -> Result<()>,
+) -> Result<Vec<Vec<f32>>> {
     let bytes = kind.bytes();
     let block = bytes.checked_mul(channels).filter(|&b| b > 0).ok_or_else(|| AudioError::Malformed("zero-sized sample frame".into()))?;
     let available = (data.len() / block) as u64;
@@ -113,7 +119,10 @@ pub(crate) fn deinterleave(data: &[u8], kind: PcmKind, channels: usize, frames:
     }
     let frames = usize::try_from(frames).map_err(|_| AudioError::TooLarge("frame count exceeds address space".into()))?;
     let mut out: Vec<Vec<f32>> = (0..channels).map(|_| Vec::with_capacity(frames)).collect();
-    for frame in data.chunks_exact(block).take(frames) {
+    for (index, frame) in data.chunks_exact(block).take(frames).enumerate() {
+        if index % 4096 == 0 {
+            progress(index as f32 / frames.max(1) as f32)?;
+        }
         for (ch, s) in out.iter_mut().zip(frame.chunks_exact(bytes)) {
             ch.push(decode_sample(s, kind));
         }
diff --git a/crates/audio-io/src/symph.rs b/crates/audio-io/src/symph.rs
--- a/crates/audio-io/src/symph.rs
+++ b/crates/audio-io/src/symph.rs
@@ -240,7 +240,8 @@ fn info_of(reader: &dyn FormatReader, detected: FileFormat) -> Result<(u32, Audi
 }
 
 /// Decode the whole first audio track. When `count_only`, samples are counted but not stored.
-fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInfo, AudioBuffer)> {
+/// `progress` hears `0..=1` as packets decode when the header states a frame count.
+fn run(bytes: &[u8], detected: FileFormat, count_only: bool, progress: &mut dyn FnMut(f32) -> Result<()>) -> Result<(AudioInfo, AudioBuffer)> {
     let mut reader = match open(bytes, detected)? {
         Opened::Reader(reader) => reader,
         Opened::Empty(info) => {
@@ -249,11 +250,10 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
         }
     };
     let (track_id, mut info) = info_of(reader.as_ref(), detected)?;
-    if let Some(n) = reader.tracks().iter().find(|t| t.id == track_id).and_then(|t| t.num_frames) {
-        let total = n.saturating_mul(u64::from(info.channels.max(1)));
-        if !count_only && total > MAX_DECODE_SAMPLES {
-            return Err(AudioError::TooLarge(format!("{total} samples exceeds the decode limit")));
-        }
+    let total_frames = reader.tracks().iter().find(|t| t.id == track_id).and_then(|t| t.num_frames).unwrap_or(0);
+    let total = total_frames.saturating_mul(u64::from(info.channels.max(1)));
+    if !count_only && total > MAX_DECODE_SAMPLES {
+        return Err(AudioError::TooLarge(format!("{total} samples exceeds the decode limit")));
     }
     let params = reader
         .tracks()
@@ -315,6 +315,7 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
         }
         let n = decoded.frames() as u64;
         frames = frames.saturating_add(n);
+        progress(if total_frames > 0 { (frames as f32 / total_frames as f32).min(1.0) } else { 0.0 })?;
         if frames.saturating_mul(ch as u64) > MAX_DECODE_SAMPLES {
             return Err(AudioError::TooLarge("decoded audio exceeds the 4 GiB limit".into()));
         }
@@ -348,10 +349,10 @@ pub(crate) fn probe(bytes: &[u8], detected: FileFormat) -> Result<AudioInfo> {
             return Ok(info);
         }
         // Length not in the header (e.g. MP3 without a Xing frame): count by decoding.
-        run(bytes, detected, true).map(|(i, _)| i)
+        run(bytes, detected, true, &mut |_| Ok(())).map(|(i, _)| i)
     })
 }
 
-pub(crate) fn decode(bytes: &[u8], detected: FileFormat) -> Result<(AudioInfo, AudioBuffer)> {
-    guarded(|| run(bytes, detected, false))
+pub(crate) fn decode(bytes: &[u8], detected: FileFormat, progress: &mut dyn FnMut(f32) -> Result<()>) -> Result<(AudioInfo, AudioBuffer)> {
+    guarded(|| run(bytes, detected, false, progress))
 }
diff --git a/crates/audio-io/src/wav.rs b/crates/audio-io/src/wav.rs
--- a/crates/audio-io/src/wav.rs
+++ b/crates/audio-io/src/wav.rs
@@ -264,10 +264,10 @@ pub(crate) fn parse(b: &[u8]) -> Result<WavParse> {
     }))
 }
 
-pub(crate) fn decode(b: &[u8], layout: &WavLayout) -> Result<AudioBuffer> {
+pub(crate) fn decode(b: &[u8], layout: &WavLayout, progress: &mut dyn FnMut(f32) -> Result<()>) -> Result<AudioBuffer> {
     let end = layout.data_start.saturating_add(layout.data_len).min(b.len());
     let data = b.get(layout.data_start..end).unwrap_or_default();
-    let channels = deinterleave(data, layout.kind, usize::from(layout.info.channels), layout.info.frames)?;
+    let channels = deinterleave(data, layout.kind, usize::from(layout.info.channels), layout.info.frames, progress)?;
     Ok(AudioBuffer { sample_rate: layout.info.sample_rate, channels })
 }
 
diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs
--- a/crates/engine/src/cmd/file.rs
+++ b/crates/engine/src/cmd/file.rs
@@ -227,6 +227,25 @@ fn new_session(e: &mut Engine, p: &Value) -> Result<Value> {
     Ok(json!({"name": e.session().name, "sample_rate": e.session().sample_rate.hz()}))
 }
 
+/// Validate and freeze a UI import's destination before background work starts.
+pub fn audio_import_params(e: &Engine, p: &Value) -> Result<Value> {
+    let mut paths: Vec<String> =
+        p.get("paths").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default();
+    if let Some(one) = str_param(p, "path") {
+        paths.push(one.to_string());
+    }
+    if paths.is_empty() {
+        return Err(bad("file.import_audio", "`path` or `paths` required"));
+    }
+    let at = position_param(e, "file.import_audio", p, "at")?.unwrap_or(0).max(0);
+    let target = import_audio_target(e, p)?;
+    let mut params = json!({"paths": paths, "at": at, "new_tracks": true});
+    if let Some(track) = target {
+        params["track"] = json!(track);
+    }
+    Ok(params)
+}
+
 fn import_audio(e: &mut Engine, p: &Value) -> Result<Value> {
     let mut paths: Vec<String> =
         p.get("paths").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default();
@@ -240,8 +259,14 @@ fn import_audio(e: &mut Engine, p: &Value) -> Result<Value> {
     let target = import_audio_target(e, p)?;
     let mut out = Vec::new();
     for path in &paths {
-        let bytes = crate::io::read_file(path)?;
         let name = std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("Audio").to_string();
+        // The UI importer may already have read and decoded this file in the background; then
+        // placing it is all that is left, and the bytes on disk are not opened again.
+        if let Some(pre) = crate::io::take_import(e, path) {
+            out.push(crate::io::import_prepared(e, &pre.name, Some(&pre.path), pre.info, pre.audio, target, at)?);
+            continue;
+        }
+        let bytes = crate::io::read_file(path)?;
         let r = crate::io::import_audio_bytes(e, &name, &bytes, Some(path.as_str()), target, at)?;
         out.push(r);
     }
@@ -401,6 +426,67 @@ mod tests {
         let _ = std::fs::remove_dir_all(dir);
     }
 
+    /// A decoded import offered to the engine stands in for re-reading the file: the command
+    /// places exactly this audio and consumes the entry, once.
+    #[test]
+    fn import_audio_places_a_prepared_import_and_consumes_it() {
+        let dir = std::env::temp_dir().join(format!("sc-import-prepared-{}", std::process::id()));
+        let _ = std::fs::remove_dir_all(&dir);
+        std::fs::create_dir_all(&dir).unwrap();
+        let bytes = {
+            let tone: Vec<f32> = (0..4_800).map(|i| (i as f32 * 0.1).sin() * 0.2).collect();
+            soundcraft_audio_io::encode(&AudioBuffer { sample_rate: 48_000, channels: vec![tone] }, &EncodeOptions::default()).unwrap()
+        };
+        // The path never gets a file: a successful import here can only be the prepared one.
+        let missing = dir.join("prepared.wav");
+        let (info, buf) = soundcraft_audio_io::decode(&bytes, Some("wav")).unwrap();
+        let mut e = Engine::default();
+        crate::io::offer_import(
+            &mut e,
+            crate::io::PreparedImport {
+                path: missing.to_string_lossy().into_owned(),
+                name: "prepared.wav".into(),
+                info,
+                audio: std::sync::Arc::new(soundcraft_model::SourceAudio::new(buf)),
+            },
+        );
+        let r = e.execute("file.import_audio", &json!({"path": missing, "at": 96_000})).unwrap();
+        assert_eq!(e.session().tracks.len(), 1);
+        let clip = &e.session().tracks[0].clips()[0];
+        assert_eq!(clip.start, 96_000);
+        assert_eq!(clip.length, 4_800);
+        assert_eq!(r["imported"][0]["frames"], json!(4_800));
+        // Second import of the same path reverts to reading the file, which is not there.
+        let err = e.execute("file.import_audio", &json!({"path": missing})).unwrap_err();
+        assert!(err.to_string().contains("prepared.wav"), "{err}");
+        let _ = std::fs::remove_dir_all(dir);
+    }
+
+    /// A prepared import lives until its command runs; replacing the session clears what was
+    /// waiting, so nothing from the old session lands in a new one.
+    #[test]
+    fn replacing_the_session_clears_prepared_imports() {
+        let tone: Vec<f32> = (0..4_800).map(|i| (i as f32 * 0.1).sin() * 0.2).collect();
+        let bytes = soundcraft_audio_io::encode(&AudioBuffer { sample_rate: 48_000, channels: vec![tone] }, &EncodeOptions::default()).unwrap();
+        let (info, buf) = soundcraft_audio_io::decode(&bytes, Some("wav")).unwrap();
+        let mut e = Engine::default();
+        // Never a file at this path: the entry is the only way this import could succeed.
+        let missing = "/nowhere/at-all.wav".to_string();
+        crate::io::offer_import(
+            &mut e,
+            crate::io::PreparedImport {
+                path: missing.clone(),
+                name: "at-all.wav".into(),
+                info,
+                audio: std::sync::Arc::new(soundcraft_model::SourceAudio::new(buf)),
+            },
+        );
+        e.execute("session.new", &json!({"name": "Later"})).unwrap();
+        let err = e.execute("file.import_audio", &json!({"path": missing})).unwrap_err();
+        assert!(err.to_string().contains("at-all.wav"), "{err}");
+        assert_eq!(e.session().tracks.len(), 0);
+    }
+
     #[test]
     fn bounce_refuses_formats_it_cannot_write() {
         let mut e = Engine::default();
diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs
--- a/crates/engine/src/cmd/mod.rs
+++ b/crates/engine/src/cmd/mod.rs
@@ -38,7 +38,7 @@ mod view_more;
 mod vst3;
 mod zoom_toggle;
 
-pub use file::bounce_job;
+pub use file::{audio_import_params, bounce_job};
 
 pub(crate) use edit::remember_selection;
 
diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs
--- a/crates/engine/src/io.rs
+++ b/crates/engine/src/io.rs
@@ -114,6 +114,12 @@ pub fn add_source(s: &mut Session, name: &str, mut buf: AudioBuffer, path: Optio
         buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, sr);
         buf.sample_rate = sr;
     }
+    add_source_audio(s, name, Arc::new(SourceAudio::new(buf)), path, format)
+}
+
+fn add_source_audio(s: &mut Session, name: &str, audio: Arc<SourceAudio>, path: Option<&str>, format: FileFormat) -> SourceId {
+    let buf = &audio.buffer;
+    let sr = s.sample_rate.hz();
     let id = SourceId(s.alloc());
     let stem = Path::new(name).file_stem().and_then(OsStr::to_str).unwrap_or(name).to_string();
     let stored = match path {
@@ -131,7 +137,7 @@ pub fn add_source(s: &mut Session, name: &str, mut buf: AudioBuffer, path: Optio
         time_reference: 0,
         unsaved: true,
     });
-    s.pool.insert(id, Arc::new(SourceAudio::new(buf)));
+    s.pool.insert(id, audio);
     id
 }
 
@@ -148,16 +154,39 @@ fn stamp_at_rate(stamp: u64, file_rate: u32, session_rate: u32) -> u64 {
 pub fn import_audio_bytes(e: &mut Engine, name: &str, bytes: &[u8], path: Option<&str>, target: Option<TrackId>, at: Samples) -> Result<Value> {
     let ext = Path::new(name).extension().and_then(OsStr::to_str).map(str::to_ascii_lowercase);
     let (info, buf) = soundcraft_audio_io::decode(bytes, ext.as_deref()).map_err(|err| EngineError::Io(format!("{name}: {err}")))?;
-    let buf = at_rate(buf, e.session().sample_rate.hz(), MAX_IMPORT_SAMPLES)
-        .ok_or_else(|| EngineError::Io(format!("{name}: file is too large to import")))?;
+    let buf = prepare_import_buffer(name, buf, e.session().sample_rate.hz())?;
+    let audio = Arc::new(SourceAudio::new(buf));
+    import_prepared(e, name, path, info, audio, target, at)
+}
+
+/// Resample decoded audio before constructing its waveform. May run on a worker thread.
+pub fn prepare_import_buffer(name: &str, buf: AudioBuffer, sample_rate: u32) -> Result<AudioBuffer> {
+    let buf = at_rate(buf, sample_rate, MAX_IMPORT_SAMPLES).ok_or_else(|| EngineError::Io(format!("{name}: file is too large to import")))?;
     if buf.frames() == 0 {
         return Err(EngineError::Io(format!("{name}: no audio")));
     }
+    Ok(buf)
+}
+
+/// Place already converted audio and its waveform without doing sample processing on the caller.
+pub fn import_prepared(
+    e: &mut Engine,
+    name: &str,
+    path: Option<&str>,
+    info: soundcraft_audio_io::AudioInfo,
+    audio: Arc<SourceAudio>,
+    target: Option<TrackId>,
+    at: Samples,
+) -> Result<Value> {
+    let buf = &audio.buffer;
+    if buf.sample_rate != e.session().sample_rate.hz() {
+        return Err(EngineError::Io("session sample rate changed during import; please import again".into()));
+    }
     let channels = buf.num_channels();
     let file_rate = info.sample_rate;
     let s = e.session_mut();
     let session_rate = s.sample_rate.hz();
-    let src = add_source(s, name, buf, path, info.format);
+    let src = add_source_audio(s, name, audio, path, info.format);
     if let (Some(bwf), Some(sx)) = (info.bwf.as_ref(), s.sources.last_mut()) {
         sx.time_reference = stamp_at_rate(bwf.time_reference, file_rate, session_rate);
     }
@@ -175,6 +204,40 @@ pub fn import_audio_bytes(e: &mut Engine, name: &str, bytes: &[u8], path: Option
     Ok(json!({"track": track, "clip": cid, "source": src, "frames": frames, "channels": channels}))
 }
 
+/// A file that has been read and decoded for `file.import_audio` to place: the UI's importer
+/// produces this off the UI thread and offers it to the engine, so a long decode never blocks.
+#[derive(Debug)]
+pub struct PreparedImport {
+    /// The file's path; the command takes the entry naming it.
+    pub path: String,
+    /// The file's own name, which names the source.
+    pub name: String,
+    /// Header information (`bwf` stamps the source's time reference).
+    pub info: soundcraft_audio_io::AudioInfo,
+    /// Audio at the session sample rate with its waveform already built.
+    pub audio: Arc<SourceAudio>,
+}
+
+/// Stores a decoded file until `file.import_audio` names its path. One session's imports
+/// are cleared when the session is replaced.
+pub fn offer_import(e: &mut Engine, p: PreparedImport) {
+    e.imports.push(p);
+}
+
+/// Dispatch one atomic, undoable import, and discard unused prepared files even on failure.
+pub fn execute_prepared_imports(e: &mut Engine, params: &Value, imports: Vec<PreparedImport>) -> Result<Value> {
+    e.imports = imports;
+    let result = e.execute("file.import_audio", params);
+    e.imports.clear();
+    result
+}
+
+/// Takes the prepared import for `path`, if one is waiting.
+pub(crate) fn take_import(e: &mut Engine, path: &str) -> Option<PreparedImport> {
+    let at = e.imports.iter().position(|p| p.path == path)?;
+    Some(e.imports.remove(at))
+}
+
 /// Loads an audio file's bytes into an instrument track's Sampler as one undo step ("Load
 /// Sampler Sample"): for files that have no path, such as one dropped into the browser. The same
 /// checks as `mix.sampler_load`; not journaled, since the bytes can be large.
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -139,6 +139,8 @@ pub struct Engine {
     /// The edit selection (range, tracks) before the last change, for Restore Last Selection.
     /// Kept out of the document so selecting never marks the session modified.
     pub last_selection: Option<(soundcraft_time::Range, Vec<TrackId>)>,
+    /// Files the UI importer decoded ahead of `file.import_audio` (see `io::PreparedImport`).
+    pub(crate) imports: Vec<crate::io::PreparedImport>,
 }
 
 impl Default for Engine {
@@ -169,6 +171,7 @@ impl Engine {
             merge_hold: None,
             messages: Vec::new(),
             last_selection: None,
+            imports: Vec::new(),
         }
     }
 
@@ -203,6 +206,7 @@ impl Engine {
         self.undo.clear();
         self.redo.clear();
         self.last_selection = None;
+        self.imports.clear();
         self.revision = self.revision.wrapping_add(1);
         self.dirty = false;
     }
diff --git a/crates/ui-egui/src/bounce.rs b/crates/ui-egui/src/bounce.rs
--- a/crates/ui-egui/src/bounce.rs
+++ b/crates/ui-egui/src/bounce.rs
@@ -7,6 +7,9 @@ use serde_json::Value;
 
 /// Starts the bounce `file.bounce_mix` runs for `params`; [`step`] renders it.
 pub fn start(app: &mut SoundApp, params: &Value) {
+    if crate::import::refuses_session_switch(app) {
+        return;
+    }
     if app.bounce.is_some() {
         app.ui.status = "A bounce is already running".into();
         return;
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -174,6 +174,9 @@ pub fn ask_to_save(app: &mut SoundApp, id: &str, params: &Value) -> bool {
     {
         return false;
     }
+    if crate::import::refuses_session_switch(app) {
+        return true;
+    }
     if app.engine.transport.recording {
         app.stop_play();
     }
@@ -198,7 +201,7 @@ const OPEN_PENDING: &str = "ui.open_pending_session";
 /// Opens a session the browser read as bytes (picked or dropped), asking first when the open one
 /// has changes. The read finishes later than the pick, so the question is asked only now.
 pub fn open_session_bytes(app: &mut SoundApp, name: String, bytes: Vec<u8>) {
-    if crate::bounce::refuses_session_switch(app) {
+    if crate::bounce::refuses_session_switch(app) || crate::import::refuses_session_switch(app) {
         return;
     }
     app.dialogs.pending_session = Some((name, bytes));
@@ -209,7 +212,7 @@ pub fn open_session_bytes(app: &mut SoundApp, name: String, bytes: Vec<u8>) {
 
 fn open_pending_session(app: &mut SoundApp) {
     let Some((name, bytes)) = app.dialogs.pending_session.take() else { return };
-    if crate::bounce::refuses_session_switch(app) {
+    if crate::bounce::refuses_session_switch(app) || crate::import::refuses_session_switch(app) {
         return;
     }
     let _ = app.run("transport.stop", json!({}));
@@ -307,6 +310,7 @@ fn default_dir() -> String {
 pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
     audiosuite_window(app, ctx);
     crate::bounce::show(app, ctx);
+    crate::import::show(app, ctx);
     let Some(mut d) = app.dialogs.open.take() else { return };
     let mut keep = true;
     let mut action: Option<(String, Value)> = None;
@@ -692,6 +696,9 @@ fn run_then(app: &mut SoundApp, cmd: &str, params: Value) -> Result<Value, Strin
         open_pending_session(app);
         return Ok(Value::Null);
     }
+    if cmd == "file.import_audio" {
+        return crate::import::start(app, &params);
+    }
     app.run(cmd, params)
 }
 
diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui-egui/src/import.rs
@@ -0,0 +1,599 @@
+//! Native audio imports prepare samples and waveforms off the UI thread. The engine commits
+//! the complete batch through `file.import_audio`, preserving atomic failure and one-step Undo.
+
+use crate::SoundApp;
+use crate::theme::bold;
+use serde_json::Value;
+use soundcraft_engine::io::PreparedImport;
+use std::sync::{
+    Arc, Mutex,
+    atomic::{AtomicBool, Ordering},
+    mpsc::Receiver,
+};
+
+pub struct ImportJob {
+    params: Value,
+    names: Vec<String>,
+    worker: Receiver<Result<Vec<PreparedImport>, String>>,
+    progress: Arc<Mutex<Progress>>,
+    cancelled: Arc<AtomicBool>,
+    shown: bool,
+    displayed_fraction: f32,
+    completed: bool,
+}
+
+impl Drop for ImportJob {
+    fn drop(&mut self) {
+        self.cancelled.store(true, Ordering::Relaxed);
+    }
+}
+
+// The browser has no background import worker to construct these processing stages.
+#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
+#[derive(Debug, Clone, Copy)]
+enum Phase {
+    Reading,
+    Decoding(f32),
+    Converting,
+    Waveform,
+    Ready,
+}
+
+#[derive(Debug, Clone, Copy)]
+struct Progress {
+    file: usize,
+    phase: Phase,
+}
+
+impl Progress {
+    /// Four preparation stages per file plus the final atomic placement. This measures work
+    /// stages, not elapsed time. Decoding contributes its measured fraction within its stage.
+    fn fraction(self, files: usize) -> f32 {
+        if files == 0 {
+            return 0.0;
+        }
+        let stage = match self.phase {
+            Phase::Reading => 0.0,
+            Phase::Decoding(f) => 1.0 + if f.is_finite() { f.clamp(0.0, 1.0) } else { 0.0 },
+            Phase::Converting => 2.0,
+            Phase::Waveform => 3.0,
+            Phase::Ready => 4.0,
+        };
+        let done = self.file.min(files.saturating_sub(1)) as f64 * 4.0 + f64::from(stage);
+        // Only a successful engine commit may display 100%, including for huge file counts.
+        (done / (files as f64 * 4.0 + 1.0)).clamp(0.0, 0.99) as f32
+    }
+}
+
+impl Default for Progress {
+    fn default() -> Self {
+        Self { file: 0, phase: Phase::Reading }
+    }
+}
+
+/// Start a UI import. Programmatic `SoundApp::run` keeps its synchronous result contract.
+/// Browser byte imports continue to use the browser shell's import path.
+pub fn start(app: &mut SoundApp, params: &Value) -> Result<Value, String> {
+    #[cfg(target_arch = "wasm32")]
+    {
+        app.run("file.import_audio", params.clone())
+    }
+    #[cfg(not(target_arch = "wasm32"))]
+    {
+        let result = start_native(app, params);
+        if let Err(error) = &result {
+            app.ui.status = error.clone();
+        }
+        result
+    }
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+fn start_native(app: &mut SoundApp, params: &Value) -> Result<Value, String> {
+    if refuses_session_switch(app) {
+        return Err("Finish or cancel the import first".into());
+    }
+    if app.bounce.is_some() {
+        return Err("Finish or cancel the bounce first".into());
+    }
+    let params = soundcraft_engine::cmd::audio_import_params(&app.engine, params).map_err(|e| e.to_string())?;
+    let paths: Vec<String> =
+        params.get("paths").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default();
+    let names = paths.iter().map(|path| file_name(path)).collect();
+    let progress = Arc::new(Mutex::new(Progress::default()));
+    let cancelled = Arc::new(AtomicBool::new(false));
+    let rate = app.engine.session().sample_rate.hz();
+    let roots = soundcraft_engine::file_access::current();
+    let (tx, worker) = std::sync::mpsc::channel();
+    let (worker_progress, worker_cancelled) = (Arc::clone(&progress), Arc::clone(&cancelled));
+    std::thread::Builder::new()
+        .name("soundcraft-import".into())
+        .spawn(move || {
+            let result = soundcraft_engine::file_access::confine(roots.as_ref(), || prepare_files(paths, rate, &worker_progress, &worker_cancelled));
+            let _ = tx.send(result);
+        })
+        .map_err(|e| format!("Could not start audio import: {e}"))?;
+    app.import = Some(ImportJob { params, names, worker, progress, cancelled, shown: false, displayed_fraction: 0.0, completed: false });
+    app.ui.status = "Importing audio…".into();
+    Ok(Value::Null)
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+fn file_name(path: &str) -> String {
+    std::path::Path::new(path).file_name().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "Audio".into())
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+fn prepare_files(paths: Vec<String>, rate: u32, progress: &Mutex<Progress>, cancelled: &AtomicBool) -> Result<Vec<PreparedImport>, String> {
+    let mut prepared = Vec::new();
+    for (file, path) in paths.into_iter().enumerate() {
+        let update = |phase| -> soundcraft_audio_io::Result<()> {
+            if cancelled.load(Ordering::Relaxed) {
+                return Err(soundcraft_audio_io::AudioError::Cancelled);
+            }
+            if let Ok(mut state) = progress.lock() {
+                *state = Progress { file, phase };
+            }
+            Ok(())
+        };
+        let name = file_name(&path);
+        let error = |e: soundcraft_audio_io::AudioError| format!("{name}: {e}");
+        update(Phase::Reading).map_err(error)?;
+        let bytes = soundcraft_engine::io::read_file(&path).map_err(|e| e.to_string())?;
+        let ext = std::path::Path::new(&path).extension().and_then(|x| x.to_str()).map(str::to_ascii_lowercase);
+        let (info, buf) = soundcraft_audio_io::decode_with(&bytes, ext.as_deref(), |f| update(Phase::Decoding(f))).map_err(error)?;
+        drop(bytes);
+        update(Phase::Converting).map_err(error)?;
+        let buf = soundcraft_engine::io::prepare_import_buffer(&name, buf, rate).map_err(|e| e.to_string())?;
+        update(Phase::Waveform).map_err(error)?;
+        let audio = Arc::new(soundcraft_model::SourceAudio::new(buf));
+        // Check again after stages that cannot be interrupted. Keep ownership of this worker
+        // until it exits, so repeated Cancel/Import cannot accumulate background workers.
+        update(Phase::Waveform).map_err(error)?;
+        prepared.push(PreparedImport { path, name, info, audio });
+    }
+    if let Ok(mut state) = progress.lock() {
+        state.phase = Phase::Ready;
+    }
+    Ok(prepared)
+}
+
+/// Poll without blocking; commit only after the loading window has had a chance to draw.
+pub fn step(app: &mut SoundApp, ctx: &egui::Context) {
+    let Some(job) = &mut app.import else { return };
+    ctx.request_repaint_after(std::time::Duration::from_millis(16));
+    if job.completed {
+        if job.shown {
+            app.import = None;
+        }
+        return;
+    }
+    if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
+        job.cancelled.store(true, Ordering::Relaxed);
+    }
+    if !job.shown {
+        return;
+    }
+    let result = match job.worker.try_recv() {
+        Ok(result) => result,
+        Err(std::sync::mpsc::TryRecvError::Empty) => return,
+        Err(std::sync::mpsc::TryRecvError::Disconnected) => Err("Audio import worker stopped unexpectedly; please try again".into()),
+    };
+    let status = if job.cancelled.load(Ordering::Relaxed) {
+        "Import cancelled".into()
+    } else {
+        match result.and_then(|files| soundcraft_engine::io::execute_prepared_imports(&mut app.engine, &job.params, files).map_err(|e| e.to_string()))
+        {
+            Ok(_) => {
+                // Paint the full bar once after the engine has committed, then close next frame.
+                job.completed = true;
+                job.shown = false;
+                if job.names.len() == 1 {
+                    format!("Imported {}", job.names.first().map(String::as_str).unwrap_or("audio"))
+                } else {
+                    format!("Imported {} files", job.names.len())
+                }
+            }
+            Err(error) => format!("Import failed: {error}"),
+        }
+    };
+    if !job.completed {
+        app.import = None;
+    }
+    app.ui.status = status;
+}
+
+pub fn refuses_session_switch(app: &mut SoundApp) -> bool {
+    // A hidden window may not have painted its final 100% frame. Completed work must not
+    // prevent quitting, replacing the session, or starting another job in that case.
+    if app.import.as_ref().is_some_and(|job| job.completed) {
+        app.import = None;
+    }
+    if app.import.is_some() {
+        app.ui.status = "Finish or cancel the import first".into();
+    }
+    app.import.is_some()
+}
+
+/// One accumulating bar for the whole batch, filled only by completed work and decoding
+/// progress. Interpolation softens stage transitions without guessing progress from elapsed time.
+pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
+    let Some(job) = &mut app.import else { return };
+    job.shown = true;
+    let progress = job.progress.lock().map(|p| *p).unwrap_or_default();
+    let cancelling = job.cancelled.load(Ordering::Relaxed);
+    if job.completed {
+        job.displayed_fraction = 1.0;
+    } else if !cancelling {
+        let target = progress.fraction(job.names.len()).max(job.displayed_fraction);
+        let dt = ctx.input(|i| i.stable_dt).clamp(0.0, 0.1);
+        job.displayed_fraction = (job.displayed_fraction + dt * 1.5).min(target);
+    }
+    let name = job.names.get(progress.file).map(String::as_str).unwrap_or("Audio");
+    let mut cancel = false;
+    egui::Modal::new(egui::Id::new("import_progress")).show(ctx, |ui| {
+        ui.spacing_mut().item_spacing = egui::vec2(8.0, 10.0);
+        ui.set_width(360.0_f32.min((ctx.content_rect().width() - 48.0).max(160.0)));
+        ui.label(
+            egui::RichText::new(if job.completed {
+                "Import complete"
+            } else if cancelling {
+                "Cancelling import…"
+            } else {
+                "Importing Audio"
+            })
+            .font(bold(15.0)),
+        );
+        ui.add(egui::Label::new(name).truncate()).on_hover_text(name);
+        let stage: String = if job.completed {
+            "Audio imported successfully".into()
+        } else if cancelling {
+            "Stopping audio processing…".into()
+        } else {
+            match progress.phase {
+                Phase::Reading => "Reading audio…".into(),
+                Phase::Decoding(_) => "Decoding audio…".into(),
+                Phase::Converting => "Converting sample rate…".into(),
+                Phase::Waveform => "Building waveform…".into(),
+                Phase::Ready => "Adding audio to session…".into(),
+            }
+        };
+        ui.add(egui::ProgressBar::new(job.displayed_fraction).desired_height(18.0).corner_radius(9.0));
+        ui.horizontal(|ui| {
+            ui.label(&stage);
+            ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+                ui.label(format!("{:.0}%", (job.displayed_fraction * 100.0).floor()));
+            });
+        });
+        ui.horizontal(|ui| {
+            ui.label(format!("File {} of {}", progress.file.saturating_add(1), job.names.len()));
+            ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+                cancel = ui.add_enabled(!cancelling && !job.completed, egui::Button::new("Cancel")).clicked();
+            });
+        });
+    });
+    if cancel {
+        job.cancelled.store(true, Ordering::Relaxed);
+    }
+}
+
+#[cfg(all(test, not(target_arch = "wasm32")))]
+mod tests {
+    use super::*;
+    use crate::Services;
+    use egui_kittest::{Harness, kittest::Queryable};
+    use serde_json::json;
+    use soundcraft_audio_io::{AudioBuffer, EncodeOptions};
+    use soundcraft_engine::Engine;
+    use std::sync::mpsc;
+
+    struct Fixture(std::path::PathBuf);
+    #[allow(clippy::unwrap_used)]
+    impl Fixture {
+        fn new(name: &str) -> Self {
+            let dir = std::env::temp_dir().join(format!("sc-import-{name}-{}", std::process::id()));
+            std::fs::create_dir_all(&dir).unwrap();
+            Self(dir)
+        }
+        fn wav(&self, name: &str, rate: u32) -> String {
+            let buf = AudioBuffer { sample_rate: rate, channels: vec![vec![0.25; rate as usize / 10]; 2] };
+            let bytes = soundcraft_audio_io::encode(&buf, &EncodeOptions::default()).unwrap();
+            let path = self.0.join(name);
+            std::fs::write(&path, bytes).unwrap();
+            path.to_string_lossy().into_owned()
+        }
+    }
+    impl Drop for Fixture {
+        fn drop(&mut self) {
+            let _ = std::fs::remove_dir_all(&self.0);
+        }
+    }
+    fn app() -> SoundApp {
+        SoundApp::new(Engine::default(), None, Services::default())
+    }
+    fn finish(app: &mut SoundApp) {
+        if let Some(job) = &mut app.import {
+            job.shown = true;
+        }
+        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
+        while app.import.is_some() {
+            if let Some(job) = &mut app.import {
+                job.shown = true;
+            }
+            step(app, &egui::Context::default());
+            assert!(std::time::Instant::now() < deadline, "import must terminate");
+            std::thread::sleep(std::time::Duration::from_millis(1));
+        }
+    }
+
+    #[test]
+    fn batch_import_resamples_and_builds_waveforms_with_one_undo() {
+        let f = Fixture::new("batch");
+        let (a, b) = (f.wav("first.wav", 44_100), f.wav("second.wav", 48_000));
+        let mut app = app();
+        // `path` is appended to `paths`, matching the engine command.
+        start(&mut app, &json!({"paths": [a], "path": b, "at": 9600})).unwrap();
+        assert!(app.engine.session().tracks.is_empty());
+        assert!(start(&mut app, &json!({"path": "ignored.wav"})).is_err());
+        assert!(app.run("session.new", json!({})).is_err());
+        assert!(app.run("app.quit", json!({})).is_err());
+        finish(&mut app);
+        assert_eq!(app.ui.status, "Imported 2 files");
+        let s = app.engine.session();
+        assert_eq!(s.tracks.len(), 2);
+        for track in &s.tracks {
+            assert_eq!(track.clips()[0].start, 9600);
+            assert_eq!(track.clips()[0].length, 4800);
+        }
+        for source in &s.sources {
+            let audio = s.pool.get(source.id).unwrap();
+            assert_eq!(audio.buffer.sample_rate, 48_000);
+            assert_eq!(audio.peaks.len(), 2);
+            assert!((audio.channel(0).unwrap()[100] - 0.25).abs() < 0.01);
+        }
+        assert_eq!(app.engine.undo_history().len(), 1);
+        assert!(app.engine.undo());
+        assert!(app.engine.session().tracks.is_empty());
+        assert!(app.engine.redo());
+        assert_eq!(app.engine.session().tracks.len(), 2);
+    }
+
+    #[test]
+    fn a_later_file_failure_leaves_no_partial_import() {
+        let f = Fixture::new("failure");
+        let good = f.wav("good.wav", 48_000);
+        let missing = f.0.join("missing.wav");
+        let mut app = app();
+        start(&mut app, &json!({"paths": [good, missing]})).unwrap();
+        finish(&mut app);
+        assert!(app.ui.status.contains("missing.wav"), "{}", app.ui.status);
+        assert!(app.engine.session().tracks.is_empty());
+        assert!(app.engine.session().sources.is_empty());
+        assert!(!app.engine.can_undo());
+    }
+
+    #[test]
+    fn worker_preserves_the_callers_read_authority() {
+        let f = Fixture::new("authority");
+        let outside = f.wav("outside.wav", 48_000);
+        let allowed = f.0.join("allowed");
+        std::fs::create_dir_all(&allowed).unwrap();
+        let roots = soundcraft_engine::file_access::AutomationRoots::new(Some(&allowed), None).unwrap();
+        let mut app = app();
+        soundcraft_engine::file_access::confine(roots.as_ref(), || start(&mut app, &json!({"path": outside}))).unwrap();
+        finish(&mut app);
+        assert!(app.ui.status.contains("outside the read root"), "{}", app.ui.status);
+        assert!(app.engine.session().tracks.is_empty());
+    }
+
+    #[test]
+    fn invalid_destination_fails_before_starting_a_worker() {
+        let mut app = app();
+        for params in [json!({}), json!({"path": "anything.wav", "new_tracks": false}), json!({"path": "anything.wav", "track": "Missing"})] {
+            assert!(start(&mut app, &params).is_err());
+            assert!(app.import.is_none());
+        }
+    }
+
+    #[test]
+    fn selected_destination_is_frozen_when_import_starts() {
+        let f = Fixture::new("destination");
+        let path = f.wav("audio.wav", 48_000);
+        let mut app = app();
+        app.run("track.new", json!({"name": "Target"})).unwrap();
+        let id = app.engine.session().tracks[0].id;
+        app.engine.session_mut().edit.selected_tracks = vec![id];
+        start(&mut app, &json!({"path": path, "new_tracks": false})).unwrap();
+        app.engine.session_mut().edit.selected_tracks.clear();
+        finish(&mut app);
+        assert_eq!(app.engine.session().tracks.len(), 1);
+        assert_eq!(app.engine.session().tracks[0].clips().len(), 1);
+    }
+
+    #[test]
+    fn programmatic_import_remains_synchronous_and_does_not_open_a_modal() {
+        let f = Fixture::new("command");
+        let path = f.wav("audio.wav", 48_000);
+        let mut app = app();
+        let result = app.run("file.import_audio", json!({"path": path})).unwrap();
+        assert_eq!(result["imported"][0]["frames"], 4800);
+        assert!(app.import.is_none());
+        assert_eq!(app.engine.session().tracks.len(), 1);
+    }
+
+    fn pending(app: &mut SoundApp) -> mpsc::Sender<Result<Vec<PreparedImport>, String>> {
+        let (tx, worker) = mpsc::channel();
+        app.import = Some(ImportJob {
+            params: json!({"path": "pending.wav"}),
+            names: vec!["pending.wav".into()],
+            worker,
+            progress: Arc::new(Mutex::new(Progress::default())),
+            cancelled: Arc::new(AtomicBool::new(false)),
+            shown: false,
+            displayed_fraction: 0.0,
+            completed: false,
+        });
+        tx
+    }
+
+    #[test]
+    fn cancellation_discards_even_a_completed_worker_result_and_waits_for_exit() {
+        let mut app = app();
+        let tx = pending(&mut app);
+        let token = Arc::clone(&app.import.as_ref().unwrap().cancelled);
+        token.store(true, Ordering::Relaxed);
+        app.import.as_mut().unwrap().shown = true;
+        step(&mut app, &egui::Context::default());
+        assert!(app.import.is_some(), "keep worker owned until it exits");
+        tx.send(Ok(Vec::new())).unwrap();
+        step(&mut app, &egui::Context::default());
+        assert!(app.import.is_none());
+        assert_eq!(app.ui.status, "Import cancelled");
+        assert!(!app.engine.is_dirty());
+    }
+
+    #[test]
+    fn overall_progress_never_resets_between_stages_or_files() {
+        let mut previous = 0.0;
+        for file in 0..3 {
+            for phase in [Phase::Reading, Phase::Decoding(0.0), Phase::Decoding(0.25), Phase::Decoding(1.0), Phase::Converting, Phase::Waveform] {
+                let fraction = Progress { file, phase }.fraction(3);
+                assert!(fraction >= previous, "progress went backwards at {file}: {phase:?}");
+                assert!(fraction < 1.0, "only successful placement may complete the bar");
+                previous = fraction;
+            }
+        }
+        let ready = Progress { file: 2, phase: Phase::Ready }.fraction(3);
+        assert!(ready > previous && ready < 1.0);
+        assert_eq!(Progress::default().fraction(3), 0.0);
+        assert_eq!(Progress::default().fraction(0), 0.0);
+        for f in [f32::NAN, f32::INFINITY, -1.0, 2.0] {
+            let fraction = Progress { file: usize::MAX, phase: Phase::Decoding(f) }.fraction(usize::MAX);
+            assert!(fraction.is_finite() && (0.0..1.0).contains(&fraction));
+        }
+    }
+
+    #[test]
+    fn a_completed_import_does_not_block_quit_while_the_window_is_hidden() {
+        let mut app = app();
+        let _tx = pending(&mut app);
+        app.import.as_mut().unwrap().completed = true;
+        app.run("app.quit", json!({})).unwrap();
+        assert!(app.quit_requested);
+        assert!(app.import.is_none());
+    }
+
+    #[test]
+    fn full_bar_is_painted_after_success_before_the_popup_closes() {
+        let mut app = app();
+        let tx = pending(&mut app);
+        let mut h = Harness::builder().with_size(egui::vec2(900.0, 600.0)).wgpu().build_ui_state(
+            |ui, app: &mut SoundApp| {
+                app.logic(ui.ctx());
+                app.ui(ui);
+            },
+            app,
+        );
+        h.input_mut().max_texture_side = Some(8192);
+        for _ in 0..4 {
+            h.step();
+        }
+        assert_eq!(h.state().import.as_ref().unwrap().displayed_fraction, 0.0);
+        let buf = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.25; 480]] };
+        let bytes = soundcraft_audio_io::encode(&buf, &EncodeOptions::default()).unwrap();
+        let (info, buf) = soundcraft_audio_io::decode(&bytes, Some("wav")).unwrap();
+        tx.send(Ok(vec![PreparedImport {
+            path: "pending.wav".into(),
+            name: "pending.wav".into(),
+            info,
+            audio: Arc::new(soundcraft_model::SourceAudio::new(buf)),
+        }]))
+        .unwrap();
+        h.step();
+        assert_eq!(h.state().engine.session().tracks.len(), 1);
+        assert!(h.state().import.as_ref().unwrap().completed);
+        assert_eq!(h.state().import.as_ref().unwrap().displayed_fraction, 1.0);
+        assert!(h.query_by_label("Import complete").is_some());
+        if let Some(dir) = std::env::var_os("SOUNDCRAFT_IMPORT_SHOTS") {
+            let dir = std::path::PathBuf::from(dir);
+            std::fs::create_dir_all(&dir).unwrap();
+            h.render().unwrap().save(dir.join("complete.png")).unwrap();
+        }
+        h.step();
+        assert!(h.state().import.is_none());
+        assert_eq!(h.state().ui.status, "Imported pending.wav");
+    }
+
+    #[test]
+    fn disconnected_worker_closes_the_popup_with_an_error() {
+        let mut app = app();
+        drop(pending(&mut app));
+        finish(&mut app);
+        assert!(app.ui.status.contains("worker stopped unexpectedly"));
+        assert!(!app.engine.is_dirty());
+    }
+
+    #[test]
+    fn cancel_button_and_escape_work_and_long_names_fit_the_modal() {
+        let mut app = app();
+        let tx = pending(&mut app);
+        let name = format!("เสียงร้อง_{}final.wav", "long-session-name_".repeat(20));
+        app.import.as_mut().unwrap().names = vec![name, "second.wav".into()];
+        let mut h = Harness::builder().with_size(egui::vec2(900.0, 600.0)).wgpu().build_ui_state(
+            |ui, app: &mut SoundApp| {
+                app.logic(ui.ctx());
+                app.ui(ui);
+            },
+            app,
+        );
+        h.input_mut().max_texture_side = Some(8192);
+        for _ in 0..4 {
+            h.step();
+        }
+        assert!(h.get_by_label("Importing Audio").rect().width() < 400.0);
+        let shots = std::env::var_os("SOUNDCRAFT_IMPORT_SHOTS").map(std::path::PathBuf::from);
+        if let Some(dir) = &shots {
+            std::fs::create_dir_all(dir).unwrap();
+        }
+        for (name, phase) in
+            [("reading", Phase::Reading), ("decoding", Phase::Decoding(0.63)), ("converting", Phase::Converting), ("waveform", Phase::Waveform)]
+        {
+            h.state().import.as_ref().unwrap().progress.lock().unwrap().phase = phase;
+            for _ in 0..15 {
+                h.step();
+            }
+            if let Some(dir) = &shots {
+                h.render().unwrap().save(dir.join(format!("{name}.png"))).unwrap();
+            }
+        }
+        h.state_mut().ui.theme = crate::theme::ThemeMode::Light;
+        h.step();
+        h.step();
+        if let Some(dir) = &shots {
+            h.render().unwrap().save(dir.join("light-waveform.png")).unwrap();
+        }
+        h.state_mut().ui.theme = crate::theme::ThemeMode::Dark;
+        h.step();
+        h.get_by_label("Cancel").click();
+        h.step();
+        assert!(h.state().import.as_ref().unwrap().cancelled.load(Ordering::Relaxed));
+        h.step();
+        assert!(h.query_by_label("Cancelling import…").is_some());
+        if let Some(dir) = &shots {
+            h.render().unwrap().save(dir.join("cancelling.png")).unwrap();
+        }
+        tx.send(Err("cancelled".into())).unwrap();
+        h.step();
+        assert!(h.state().import.is_none());
+        assert_eq!(h.state().ui.status, "Import cancelled");
+
+        let tx = pending(h.state_mut());
+        h.step();
+        h.key_press(egui::Key::Escape);
+        h.step();
+        assert!(h.state().import.as_ref().unwrap().cancelled.load(Ordering::Relaxed));
+        drop(tx);
+        h.step();
+        assert!(h.state().import.is_none());
+    }
+}
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -13,6 +13,7 @@ pub mod edit_window;
 pub mod extra_windows;
 pub mod fonts;
 pub mod icons;
+pub mod import;
 pub mod menus;
 pub mod midi_editor;
 pub mod mix_window;
@@ -329,6 +330,9 @@ pub struct SoundApp {
     pub video: video_track::VideoPool,
     /// The Bounce Mix in progress, rendered a slice per frame.
     pub bounce: Option<soundcraft_engine::io::Bounce>,
+
+    /// The import in progress: the worker prepares a batch before one atomic engine command.
+    pub(crate) import: Option<import::ImportJob>,
     /// Test-only: owns the process-wide palette lock while this app exists, so frames painted
     /// and colour assertions in one test can never straddle another test's palette change.
     #[cfg(test)]
@@ -404,6 +408,7 @@ impl SoundApp {
             last_meter_poll: f64::NEG_INFINITY,
             video: video_track::VideoPool::default(),
             bounce: None,
+            import: None,
             #[cfg(test)]
             _palette_lock: theme::lock_test_palette(),
         }
@@ -416,7 +421,10 @@ impl SoundApp {
 
     /// Run a command; errors go to the status line. Returns the result.
     pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> {
-        if matches!(id, "session.new" | "session.open" | "session.close" | "session.revert") && bounce::refuses_session_switch(self) {
+        let replacing_session = matches!(id, "session.new" | "session.open" | "session.close" | "session.revert");
+        if (replacing_session && bounce::refuses_session_switch(self))
+            || ((replacing_session || id == "app.quit") && import::refuses_session_switch(self))
+        {
             return Err(self.ui.status.clone());
         }
         if let Some(r) = menus::run_ui_command(self, id, &params) {
@@ -1159,6 +1167,7 @@ impl SoundApp {
         self.note_recent_session();
         self.apply_editor_edits();
         bounce::step(self, ctx);
+        import::step(self, ctx);
         if !ctx.input(|i| i.pointer.any_down()) {
             if matches!(self.gesture, Some(Gesture::Fader { .. })) {
                 self.gesture = None;
@@ -1269,7 +1278,7 @@ impl SoundApp {
             ctx.request_repaint_after(std::time::Duration::from_millis(1000 / hz));
         } else if self.meters.values().any(|m| m.level[0] > 0.0001) {
             ctx.request_repaint_after(std::time::Duration::from_millis(80));
-        } else if !self.pending_shots.is_empty() || !self.synthetic.is_empty() {
+        } else if !self.pending_shots.is_empty() || !self.synthetic.is_empty() || self.import.is_some() {
             ctx.request_repaint();
         }
     }
diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs
--- a/crates/ui-egui/src/menus.rs
+++ b/crates/ui-egui/src/menus.rs
@@ -462,6 +462,9 @@ pub fn live_label(app: &SoundApp, id: Option<&str>) -> Option<String> {
 
 /// Menu-style invocation: may open a dialog.
 pub fn invoke_menu(app: &mut SoundApp, id: &str, path: &str) {
+    if crate::import::refuses_session_switch(app) {
+        return;
+    }
     if let Some((_, w)) = MENU_WINDOWS.iter().find(|(p, _)| *p == path)
         && w.starts_with("window.")
     {
diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs
--- a/crates/ui-egui/src/shortcuts.rs
+++ b/crates/ui-egui/src/shortcuts.rs
@@ -57,7 +57,7 @@ fn search_chord(m: Modifiers, mac: bool) -> bool {
 pub fn handle(app: &mut SoundApp, ctx: &egui::Context) {
     // Cmd+= and Cmd+0 are Mix/Edit and Quantize to Grid, not egui's built-in interface zoom.
     ctx.options_mut(|o| o.zoom_with_keyboard = false);
-    if ctx.egui_wants_keyboard_input() || app.dialogs.open.is_some() {
+    if ctx.egui_wants_keyboard_input() || app.dialogs.open.is_some() || app.import.is_some() {
         return;
     }
     let events: Vec<(Key, Modifiers)> = ctx.input(|i| {
diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs
--- a/crates/ui-egui/src/windows.rs
+++ b/crates/ui-egui/src/windows.rs
@@ -363,7 +363,7 @@ fn workspace(app: &mut SoundApp, ctx: &egui::Context) {
                         let _ = app.run("file.import_midi", json!({"path": path}));
                     } else {
                         let at = app.engine.session().edit.selection.start;
-                        let _ = app.run("file.import_audio", json!({"path": path, "at": at}));
+                        let _ = crate::import::start(app, &json!({"path": path, "at": at}));
                     }
                 }
             }
diff --git a/docs/gaps.md b/docs/gaps.md
--- a/docs/gaps.md
+++ b/docs/gaps.md
@@ -1,6 +1,6 @@
 # Where SoundCraft falls short of Pro Tools
 
-> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** trivial (G-04: mono device playback fold-down) · **Target:** Avid Pro Tools Ultimate 2026.4.1
+> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** minor (native audio import loader and cancellation) · **Target:** Avid Pro Tools Ultimate 2026.4.1
 
 Every known shortfall, one entry each, ranked by how much it stops a Pro Tools user from doing
 real work in SoundCraft. This is the work list: pick from the top. Rows marked **alpha blocker**
@@ -193,6 +193,8 @@ settings command only. Needs an openly licensed local speech model. ≈ 30–50
 
 ### G-19. UI feel
 
+**Fixed (2026-10-24):** Native audio imports report reading, decoding, rate conversion and waveform preparation in a modal while a worker does the sample processing. One cumulative bar covers the batch and reaches 100% after atomic placement. Cancel/Esc discards the batch; a failed file leaves the session unchanged; completed batches use one Undo step. Multi-file drops are grouped. Browser byte imports still process synchronously; full Task Manager parity remains outside this change. Estimates unchanged.
+
 See [`ui-parity.md`](ui-parity.md). ≈ 60–100 h.
 
 ### G-20. MIDI and instrument depth
@@ -214,6 +216,7 @@ missing. Out of scope unless the owner decides otherwise.
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-24 | minor | Native audio import prepares decoding, rate conversion and waveforms in the background; cumulative progress, cancellation, atomic batch import and one-step Undo; readiness estimates unchanged |
 | 2026-10-18 | trivial | G-04: mono device stereo/surround fold-down fixed and regression-tested; estimates unchanged |
 | 2026-10-16 | trivial | G-04: master volume automation block boundaries fixed and regression-tested; estimates unchanged |
 | 2026-10-16 | trivial | G-04: failed aux/folder/master inserts excluded from latency compensation after containment; estimates unchanged |
diff --git a/docs/roadmap.md b/docs/roadmap.md
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -1,6 +1,6 @@
 # SoundCraft roadmap: milestones and what's next
 
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (M8 batch limit validation) · **Target:** Avid Pro Tools Ultimate 2026.4.1
+> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** minor (native audio import loader and cancellation) · **Target:** Avid Pro Tools Ultimate 2026.4.1
 
 Forward-looking plan. Status and numbers: [`../ROADMAP.md`](../ROADMAP.md) and
 [`target-app-parity.md`](target-app-parity.md); the ranked work list: [`gaps.md`](gaps.md).
@@ -8,6 +8,8 @@ Hours are Opus 5.5 agent-hours.
 
 ## Current focus
 
+**Delivered 2026-10-24:** Native audio import progress and background sample processing, with a cumulative filling bar, Cancel/Esc, atomic multi-file placement and one-step Undo. Browser import workers and broader Task Manager behavior remain future work; milestone/readiness estimates unchanged.
+
 1. **Merge or close the open bug-fix PRs** (#123–#134, #146, #148, #75) and fix the rest of the
    correctness backlog (G-04). ≈ 15–25 h.
 2. **Audio interface basics** (G-01): device selection, buffer size, duplex stream, ASIO. ≈ 25–40 h.
@@ -89,6 +91,7 @@ There is no `cargo xtask scorecard` in this repo; the measured number is `cargo
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-24 | minor | Native audio import prepares decoding, rate conversion and waveforms in the background; cumulative progress, cancellation, atomic batch import and one-step Undo; readiness estimates unchanged |
 | 2026-10-14 | trivial | M8: reject oversized MCP batches before execution (#460) |
 | 2026-10-10 | minor | Added the alpha gate (3 of 6 core workflows fail → pre-alpha) |
 | 2026-10-10 | major | Created from ROADMAP.md's milestones and alpha list; added M12–M17 and the beta plan with estimates |
diff --git a/docs/ui-parity.md b/docs/ui-parity.md
--- a/docs/ui-parity.md
+++ b/docs/ui-parity.md
@@ -1,6 +1,6 @@
 # UI parity
 
-> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (drag across checkboxes, #399; waveforms preview fades during drag; a plugin opens when inserted, #486) · **Target:** Avid Pro Tools Ultimate 2026.4.1
+> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** minor (native audio import loader and cancellation) · **Target:** Avid Pro Tools Ultimate 2026.4.1
 
 How SoundCraft feels in the hands of a Pro Tools user: tools, modes, modifiers, shortcuts,
 windows, meters. Measured from `crates/model/src/session.rs` (tools, modes), the command
@@ -48,6 +48,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing.
 | Transport, Big Counter, Memory Locations, Automation, Video, Universe, Renderer, UI Customization | ✅ present | |
 | Workspace / sound browser with previews | ❌ | G-16 |
 | I/O Setup | 🟡 | Busses and paths; no hardware channel matrix (G-01) |
+| Audio import progress | 🟡 | Native menu/Workspace imports and file drops show filename, file count and one cumulative bar across reading, decoding, conversion and waveform stages. Fill never resets between files; 100% appears after atomic placement succeeds. Cancel/Esc, failure rollback and one-step Undo covered. Browser byte imports remain synchronous; broader Task Manager parity remains |
 | Preferences | 🟡 | Many settings stored, fewer honoured |
 | Window configurations, Tile/Cascade | 🟡 | Configurations ✅; Tile/Cascade in PR #151 |
 | Plugin windows | 🟡 | Generic parameter sliders; Pro Tools plugins have bespoke GUIs (#138 asks for skeuomorphic design); multi-page plugin views (#6) |
@@ -69,6 +70,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing.
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-24 | minor | Native audio import prepares decoding, rate conversion and waveforms in the background; cumulative progress, cancellation, atomic batch import and one-step Undo; readiness estimates unchanged |
 | 2026-10-13 | minor | A plugin opens when it's inserted (#486) |
 | 2026-10-13 | minor | Drag across checkboxes (#399) |
 | 2026-10-13 | minor | Waveforms and fade curves share temporary envelopes during drag, without rebuilding media or editing the document before release. Readiness estimate unchanged |
