diff --git a/Cargo.lock b/Cargo.lock
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3987,6 +3987,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-24 · **Last updated:** 2026-10-25 · **Change:** trivial (side panels in dock columns, Auto Pan stereo balance, Clip List Auto Rename, Strip Silence rectangles, Strip Silence Rename, Strip Silence Extract and Separate, Clip Looping dialog, AudioSuite Vari-Fi speed ramps, Auto Pan plugin, Subharmonic plugin, Ring Modulator plugin, Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1
+> **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** minor (audio import progress, cancellation and faster rate conversion) · **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,8 @@ mutated and truncated movies. Field reports from the first public week are track
 
 ## Progress log
 
+- **2026-10-26:** Native audio imports show cumulative progress while preparing a batch in the background, with cancellation during reading, decoding and rate conversion. Successful placement completes the bar and forms one Undo step; a failed or cancelled batch leaves no partial tracks. A ten-minute 44.1 → 48 kHz release CLI import benchmark improves from 5.56 s to 1.19 s. Browser byte imports remain synchronous. No readiness percentage change.
+
 - **2026-10-25:** Tracks and Groups and the Clip List sit in dock columns that run the Edit window's full height at its left and right edges, with the MIDI Editor under the session view between them; Mix docks Tracks and Groups at its left edge. A panel dragged onto a dock group joins it as a tab, stacks above or below it, or makes a new column; one dragged to an empty window edge makes a dock there. A list with no saved place reopens in its dock, and makes the dock again when it is gone. Preferences and window configurations saved with the earlier split-frame layouts load with the new default layouts and keep their other settings. No readiness percentage change.
 - **2026-10-25:** Auto Pan moves the balance of a stereo track: the left channel's level falls as the position moves right and the right channel's as it moves left, at constant power, and each channel stays on its own side. At the center the track passes unchanged, so a wide or out-of-phase stereo image keeps its width, and the envelope follower follows the louder channel. No readiness percentage change.
 - **2026-10-24:** Clip List › Auto Rename (`clip.auto_rename`) sets the names that editing gives the clips it separates from the selected clips: a name, a starting number, a number of places (zeros before the number) and a suffix. The piece after each cut takes the next name, the piece before it keeps its name, and all the pieces count up one number sequence. Pieces that Strip Silence names with its own Rename fields, or removes, take no Auto Rename name. Copied clips keep their scheme when they are pasted, saved sessions hold the schemes, and Import Session Data brings them along. No readiness percentage change.
@@ -176,6 +178,7 @@ mutated and truncated movies. Field reports from the first public week are track
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-26 | minor | Native audio imports prepare an atomic batch with cumulative progress, block-wise read/resample cancellation and one-step Undo; ten-minute 44.1 → 48 kHz import improves from 5.56 s to 1.19 s on the development Mac; readiness estimates unchanged |
 | 2026-10-25 | trivial | Progress log: side panels in dock columns; readiness estimates unchanged |
 | 2026-10-25 | trivial | Progress log: Auto Pan stereo balance; readiness estimates unchanged |
 | 2026-10-24 | trivial | Progress log: Clip List Auto Rename; 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
@@ -30,6 +30,9 @@ winit = { version = "0.30", default-features = false, features = ["x11"] }
 [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
@@ -55,9 +55,7 @@ impl eframe::App for App {
         // the sampler; otherwise sessions open and audio/MIDI import.
         let dropped: Vec<String> =
             self.0.take_dropped_files().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!({})) {
@@ -109,20 +107,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 {
-        // Decoded in the background so a long file doesn't freeze the window (#597).
+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;
-        soundcraft_ui_egui::import::start(app, serde_json::json!({"path": p, "at": at}));
-        Ok(serde_json::Value::Null)
-    };
-    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}");
+        }
     }
 }
 
@@ -301,9 +306,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) {
@@ -335,6 +338,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/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),
@@ -230,34 +233,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].
@@ -382,3 +395,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
@@ -490,7 +490,8 @@ fn append_interleaved(planar: &mut [Vec<f32>], interleaved: &[f32], ch: usize) {
 }
 
 /// 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) => {
@@ -500,6 +501,7 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
     };
     let (track_id, mut info) = info_of(reader.as_ref(), detected)?;
     let track = reader.tracks().iter().find(|t| t.id == track_id).ok_or_else(|| AudioError::Malformed("track vanished".into()))?;
+    let total_frames = track.num_frames.unwrap_or(0);
     if let Some(n) = track.num_frames {
         let total = n.saturating_mul(u64::from(info.channels.max(1)));
         if !count_only && total > MAX_DECODE_SAMPLES {
@@ -552,6 +554,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()));
         }
@@ -591,12 +594,12 @@ 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))
 }
 
 #[cfg(test)]
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
@@ -298,10 +298,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/dsp/src/offline.rs b/crates/dsp/src/offline.rs
--- a/crates/dsp/src/offline.rs
+++ b/crates/dsp/src/offline.rs
@@ -193,27 +193,58 @@ impl SincTable {
     }
 }
 
+/// Dot product of the taps `x` with their weights `w`, in eight partial sums so the compiler can
+/// keep several multiply-adds in flight: one f32 accumulator serializes the whole loop. Only the
+/// order of the additions changes, so the result differs from a plain left-to-right sum by
+/// rounding alone.
+#[inline]
+fn dot(x: &[f32], w: &[f32]) -> f32 {
+    let (x8, x_rest) = x.as_chunks::<8>();
+    let (w8, w_rest) = w.as_chunks::<8>();
+    let mut sums = [0.0f32; 8];
+    for (a, b) in x8.iter().zip(w8) {
+        for (s, (v, k)) in sums.iter_mut().zip(a.iter().zip(b)) {
+            *s += v * k;
+        }
+    }
+    let mut sum = ((sums[0] + sums[1]) + (sums[2] + sums[3])) + ((sums[4] + sums[5]) + (sums[6] + sums[7]));
+    for (v, k) in x_rest.iter().zip(w_rest) {
+        sum += v * k;
+    }
+    sum
+}
+
 /// Resamples one channel to exactly `out_len` samples, where output sample `n` is read at input
 /// time `n / ratio`.
 fn resample_channel(x: &[f32], ratio: f64, out_len: usize, table: &SincTable) -> Vec<f32> {
+    resample_channel_reported(x, ratio, out_len, table, &mut |_| true).unwrap_or_default()
+}
+
+fn resample_channel_reported(x: &[f32], ratio: f64, out_len: usize, table: &SincTable, report: &mut dyn FnMut(f32) -> bool) -> Option<Vec<f32>> {
+    if !report(0.0) {
+        return None;
+    }
     let len = x.len() as i64;
     let hw = table.half_width;
-    (0..out_len)
-        .map(|n| {
-            let t = n as f64 / ratio;
-            let lo = (t - hw).ceil().max(0.0) as i64;
-            let hi = ((t + hw).floor() as i64).min(len - 1);
-            let mut acc = 0.0f32;
-            let mut j = lo;
-            while j <= hi {
-                if let Some(&v) = x.get(j as usize) {
-                    acc += v * table.at(t - j as f64);
-                }
-                j += 1;
+    let mut out = Vec::with_capacity(out_len);
+    for n in 0..out_len {
+        if n > 0 && n.is_multiple_of(PROGRESS_BLOCK) && !report(n as f32 / out_len as f32) {
+            return None;
+        }
+        let t = n as f64 / ratio;
+        let lo = (t - hw).ceil().max(0.0) as i64;
+        let hi = ((t + hw).floor() as i64).min(len - 1);
+        let mut acc = 0.0f32;
+        let mut j = lo;
+        while j <= hi {
+            if let Some(&v) = x.get(j as usize) {
+                acc += v * table.at(t - j as f64);
             }
-            acc
-        })
-        .collect()
+            j += 1;
+        }
+        out.push(acc);
+    }
+    report(1.0).then_some(out)
 }
 
 fn resample_ratio(ch: &[Vec<f32>], ratio: f64, out_len: usize) -> Vec<Vec<f32>> {
@@ -234,7 +265,7 @@ const MAX_POLYPHASE_WEIGHTS: usize = 1 << 22;
 /// `n * step / phases`: whole part `i`, phase `p`. Its taps are input samples `i + first ..`, one
 /// weight each, so every output is one dot product over contiguous memory instead of a table
 /// lookup and interpolation per tap. The weights are the ones `resample_channel` computes, taps
-/// beyond the kernel's half width weigh 0, and taps are summed in the same order.
+/// beyond the kernel's half width weigh 0. The dot product changes only the additions' rounding.
 struct Polyphase {
     phases: u64,
     step: u64,
@@ -269,17 +300,26 @@ impl Polyphase {
         Some(Polyphase { phases, step, first, taps, weights })
     }
 
-    fn run(&self, x: &[f32], out_len: usize) -> Vec<f32> {
+    /// Resamples `x` to `out_len` samples, calling `report` with the fraction of the output it has
+    /// written and stopping (`None`) as soon as `report` returns false. Progress is checked every
+    /// [`PROGRESS_BLOCK`] samples, so cancelling costs at most one block of work.
+    fn run_reported(&self, x: &[f32], out_len: usize, report: &mut dyn FnMut(f32) -> bool) -> Option<Vec<f32>> {
+        if !report(0.0) {
+            return None;
+        }
         let len = x.len() as i64;
         let mut out = Vec::with_capacity(out_len);
         for n in 0..out_len as u64 {
+            if n > 0 && n.is_multiple_of(PROGRESS_BLOCK as u64) && !report(n as f32 / out_len as f32) {
+                return None;
+            }
             let Some(pos) = n.checked_mul(self.step) else { break };
             let (i, p) = ((pos / self.phases) as i64, (pos % self.phases) as usize);
             let w = self.weights.get(p * self.taps..(p + 1) * self.taps).unwrap_or_default();
             let start = i + self.first;
             let acc = match usize::try_from(start).ok().and_then(|s| x.get(s..s + self.taps)) {
                 // Inside the signal: a plain dot product.
-                Some(xs) => xs.iter().zip(w).fold(0.0f32, |a, (v, k)| a + v * k),
+                Some(xs) => dot(xs, w),
                 // At the edges, taps outside the signal are skipped, as `resample_channel` does.
                 None => (0..self.taps as i64)
                     .filter(|k| (0..len).contains(&(start + k)))
@@ -288,10 +328,13 @@ impl Polyphase {
             out.push(acc);
         }
         out.resize(out_len, 0.0);
-        out
+        report(1.0).then_some(out)
     }
 }
 
+/// Output samples between progress reports while resampling.
+const PROGRESS_BLOCK: usize = 8192;
+
 fn gcd(mut a: u32, mut b: u32) -> u32 {
     while b != 0 {
         (a, b) = (b, a % b);
@@ -301,22 +344,38 @@ fn gcd(mut a: u32, mut b: u32) -> u32 {
 
 /// High-quality windowed-sinc sample-rate conversion from `from` Hz to `to` Hz.
 pub fn resample(ch: &[Vec<f32>], from: u32, to: u32) -> Vec<Vec<f32>> {
+    resample_reported(ch, from, to, &mut |_| true).unwrap_or_default()
+}
+
+/// [`resample`], calling `report` with the fraction of the work done and stopping (`None`) as soon
+/// as `report` returns false: a UI can then show a long conversion's progress and cancel it,
+/// dropping the partial output. Channels are reported in turn, so the fraction rises evenly until
+/// the last one finishes.
+pub fn resample_reported(ch: &[Vec<f32>], from: u32, to: u32, report: &mut dyn FnMut(f32) -> bool) -> Option<Vec<Vec<f32>>> {
+    // Check before building a kernel or allocating output, including no-op and empty inputs.
+    if !report(0.0) {
+        return None;
+    }
     if from == 0 || to == 0 || from == to {
-        return ch.to_vec();
+        let out = ch.to_vec();
+        return report(1.0).then_some(out);
     }
     let ratio = f64::from(to) / f64::from(from);
     let cutoff = if ratio < 1.0 { ratio * 0.94 } else { 0.97 };
     let table = SincTable::new(cutoff);
     let bank = Polyphase::new(from, to, &table);
-    ch.iter()
-        .map(|c| {
-            let out_len = ((c.len() as f64 * ratio).round() as usize).min(MAX_OFFLINE_SAMPLES);
-            match &bank {
-                Some(b) => b.run(c, out_len),
-                None => resample_channel(c, ratio, out_len, &table),
-            }
-        })
-        .collect()
+    let channels = ch.len().max(1) as f64;
+    let mut out = Vec::with_capacity(ch.len());
+    for (index, c) in ch.iter().enumerate() {
+        let mut local = |f: f32| report(((index as f64 + f64::from(f)) / channels) as f32);
+        let out_len = ((c.len() as f64 * ratio).round() as usize).min(MAX_OFFLINE_SAMPLES);
+        let channel = match &bank {
+            Some(b) => b.run_reported(c, out_len, &mut local),
+            None => resample_channel_reported(c, ratio, out_len, &table, &mut local),
+        };
+        out.push(channel?);
+    }
+    report(1.0).then_some(out)
 }
 
 // ---------------------------------------------------------------------------------------------
@@ -837,7 +896,7 @@ mod tests {
             let table = SincTable::new(cutoff);
             let reference = resample_channel(&x, ratio, out_len, &table);
             let bank = Polyphase::new(from, to, &table).expect("small common ratio");
-            let fast = bank.run(&x, out_len);
+            let fast = bank.run_reported(&x, out_len, &mut |_| true).expect("nothing stops the bank");
             assert_eq!(fast.len(), reference.len(), "{from}->{to}");
             let worst = fast.iter().zip(&reference).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
             assert!(worst < 1e-5, "{from}->{to}: worst difference {worst}");
@@ -847,13 +906,95 @@ mod tests {
         let short = [0.5f32, -0.25, 0.75];
         let table = SincTable::new(0.97);
         let reference = resample_channel(&short, 48_000.0 / 44_100.0, 3, &table);
-        let fast = Polyphase::new(44_100, 48_000, &table).expect("bank").run(&short, 3);
+        let fast = Polyphase::new(44_100, 48_000, &table).expect("bank").run_reported(&short, 3, &mut |_| true).expect("short bank");
         assert!(fast.iter().zip(&reference).all(|(a, b)| (a - b).abs() < 1e-6), "{fast:?} {reference:?}");
         // Rates with no small common ratio fall back to the per-tap kernel.
         assert!(Polyphase::new(44_100, 96_001, &table).is_none());
         assert_eq!(resample(&[short.to_vec()], 44_100, 96_001)[0].len(), 7);
     }
 
+    /// A long conversion moves its progress forward block by block and stops when its caller
+    /// cancels, dropping the partial output.
+    #[test]
+    fn resampling_reports_progress_and_stops_when_asked() {
+        let x: Vec<f32> = (0..20_000).map(|i| (i as f32 * 0.01).sin()).collect();
+        let channels = vec![x.clone(); 7];
+        let mut fractions = Vec::new();
+        let out = resample_reported(&channels, 44_100, 48_000, &mut |f| {
+            fractions.push(f);
+            true
+        })
+        .expect("nothing stops it");
+        assert_eq!(out[0].len(), (20_000.0 * 48_000.0 / 44_100.0_f64).round() as usize);
+        assert!(fractions.len() > 2, "a 20 000-sample channel is converted in blocks");
+        assert!(fractions.iter().any(|&f| f > 0.0 && f < 1.0 / 7.0), "the first channel must report partial progress: {fractions:?}");
+        assert!(fractions.windows(2).all(|w| w[0] <= w[1]), "{fractions:?}");
+        assert!(fractions.iter().all(|f| (0.0..=1.0).contains(f)), "{fractions:?}");
+        assert_eq!(fractions.last().copied(), Some(1.0));
+
+        let mut stopped_at = 0.0;
+        let cancelled = resample_reported(std::slice::from_ref(&x), 44_100, 48_000, &mut |f| {
+            stopped_at = f;
+            f == 0.0
+        });
+        assert!(cancelled.is_none(), "the first completed block cancels it");
+        assert!(stopped_at > 0.0 && stopped_at < 1.0, "cancellation must happen during conversion: {stopped_at}");
+    }
+
+    #[test]
+    fn fallback_resampling_reports_and_cancels_between_blocks() {
+        let input = vec![vec![0.25; 20_000]; 2];
+        let mut fractions = Vec::new();
+        let out = resample_reported(&input, 44_100, 96_001, &mut |f| {
+            fractions.push(f);
+            true
+        })
+        .unwrap();
+        assert_eq!(out.len(), 2);
+        assert!(fractions.windows(2).all(|w| w[0] <= w[1]), "{fractions:?}");
+        assert!(fractions.iter().any(|&f| f > 0.0 && f < 0.5), "the first channel must report partial progress: {fractions:?}");
+        assert_eq!(fractions.last().copied(), Some(1.0));
+
+        let mut stopped_at = 0.0;
+        let stopped = resample_reported(&input, 44_100, 96_001, &mut |f| {
+            stopped_at = f;
+            f == 0.0
+        });
+        assert!(stopped.is_none());
+        assert!(stopped_at > 0.0 && stopped_at < 0.5, "cancellation must happen before the first channel finishes: {stopped_at}");
+    }
+
+    #[test]
+    fn resampling_honours_cancellation_at_every_report() {
+        for (channels, from, to) in [
+            (vec![vec![0.25; 32]], 44_100, 48_000),
+            (vec![vec![0.25; 32]], 44_100, 96_001),
+            (vec![vec![0.25; 32]], 48_000, 48_000),
+            (vec![vec![0.25; 32]], 0, 48_000),
+            (vec![vec![0.25; 32]], 48_000, 0),
+            (vec![Vec::new()], 44_100, 48_000),
+            (Vec::new(), 44_100, 48_000),
+        ] {
+            let mut reports = 0;
+            assert!(
+                resample_reported(&channels, from, to, &mut |_| {
+                    reports += 1;
+                    true
+                })
+                .is_some()
+            );
+            for stop_at in 1..=reports {
+                let mut calls = 0;
+                let out = resample_reported(&channels, from, to, &mut |_| {
+                    calls += 1;
+                    calls != stop_at
+                });
+                assert!(out.is_none(), "{from}->{to}, {} channels, stop at {stop_at}/{reports}", channels.len());
+                assert_eq!(calls, stop_at, "there must be no reports after cancellation");
+            }
+        }
+    }
+
     #[test]
     fn fade_shapes_endpoints() {
         for s in FadeShape::ALL {
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
@@ -228,6 +228,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();
@@ -242,9 +261,13 @@ fn import_audio(e: &mut Engine, p: &Value) -> Result<Value> {
     let mut out = Vec::new();
     for path in &paths {
         let name = std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("Audio").to_string();
-        // Audio the app decoded on a worker thread (`Engine::offer_import`) is only placed here;
-        // the path is checked the same way either way.
         crate::file_access::check_read(std::path::Path::new(path)).map_err(crate::EngineError::Io)?;
+        // 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 r = match e.take_import(std::path::Path::new(path)) {
             Some(d) => crate::io::place_decoded(e, &name, d, Some(path.as_str()), target, at)?,
             None => {
@@ -468,6 +491,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
@@ -60,7 +60,7 @@ mod panic_probe {
     }
 }
 
-pub use file::bounce_job;
+pub use file::{audio_import_params, bounce_job};
 
 pub(crate) use edit::remember_selection;
 pub use edit::strip_silence_scan;
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
@@ -6,6 +6,7 @@ use soundcraft_audio_io::{AudioBuffer, EncodeOptions, FileFormat};
 use soundcraft_model::{BusId, ChannelFormat, Clip, ClipContent, Session, Source, SourceAudio, SourceId, TrackId, TrackKind};
 use soundcraft_time::{Range, Samples};
 use std::ffi::OsStr;
+use std::io::Read;
 use std::path::{Path, PathBuf};
 use std::sync::Arc;
 use std::sync::atomic::{AtomicBool, Ordering};
@@ -15,16 +16,25 @@ pub(crate) const MAX_IMPORT_SAMPLES: usize = 1 << 30;
 
 /// `buf` converted to `rate`, unless it would then hold more than `limit` samples: a file with a
 /// tiny sample rate grows by orders of magnitude.
-pub(crate) fn at_rate(mut buf: AudioBuffer, rate: u32, limit: usize) -> Option<AudioBuffer> {
+pub(crate) fn at_rate(buf: AudioBuffer, rate: u32, limit: usize) -> Option<AudioBuffer> {
+    at_rate_reported(buf, rate, limit, &mut |_| true)
+}
+
+/// [`at_rate`] reporting the conversion's progress and stopping (`None`) when `report` returns
+/// false: a cancelled import must not finish resampling a long file first.
+pub(crate) fn at_rate_reported(mut buf: AudioBuffer, rate: u32, limit: usize, report: &mut dyn FnMut(f32) -> bool) -> Option<AudioBuffer> {
+    if !report(0.0) {
+        return None;
+    }
     let from = buf.sample_rate.max(1);
     if buf.frames() as f64 * buf.num_channels() as f64 * f64::from(rate) / f64::from(from) > limit as f64 {
         return None;
     }
     if from != rate {
-        buf.channels = soundcraft_dsp::offline::resample(&buf.channels, from, rate);
+        buf.channels = soundcraft_dsp::offline::resample_reported(&buf.channels, from, rate, report)?;
         buf.sample_rate = rate;
     }
-    Some(buf)
+    report(1.0).then_some(buf)
 }
 
 // ─── Checked file access ──────────────────────────────────────────────────────────────────────
@@ -49,6 +59,65 @@ pub fn read_file(path: impl AsRef<Path>) -> Result<Vec<u8>> {
     std::fs::read(path).map_err(err)
 }
 
+/// [`read_file`] reporting how much of the file it has read, and stopping as soon as `cancelled`
+/// is set: a big file on a slow disk otherwise leaves an import at 0% with a Cancel button that
+/// does nothing until the whole read finishes. The bytes are grown with `try_reserve`, so a
+/// failed allocation can be returned as an error.
+pub fn read_file_cancellable(path: impl AsRef<Path>, cancelled: &AtomicBool, mut report: impl FnMut(f32)) -> Result<Vec<u8>> {
+    let path = path.as_ref();
+    crate::file_access::check_read(path).map_err(EngineError::Io)?;
+    let err = |err: std::io::Error| EngineError::Io(format!("{}: {err}", path.display()));
+    let cancelled_err = || EngineError::Io(format!("{}: cancelled", path.display()));
+    if cancelled.load(Ordering::Relaxed) {
+        return Err(cancelled_err());
+    }
+    // Opening a FIFO can block before the handle's metadata can be checked.
+    if !std::fs::metadata(path).map_err(err)?.is_file() {
+        return Err(EngineError::Io(format!("{}: not a regular file", path.display())));
+    }
+    let mut file = std::fs::File::open(path).map_err(err)?;
+    let meta = file.metadata().map_err(err)?;
+    if !meta.is_file() {
+        return Err(EngineError::Io(format!("{}: not a regular file", path.display())));
+    }
+    let size = meta.len();
+    if cancelled.load(Ordering::Relaxed) {
+        return Err(cancelled_err());
+    }
+    let mut bytes = Vec::new();
+    bytes
+        .try_reserve_exact(usize::try_from(size).map_err(|_| EngineError::Io(format!("{}: file is too large to read", path.display())))?)
+        .map_err(|_| EngineError::Io(format!("{}: not enough memory to read {size} bytes", path.display())))?;
+    let mut chunk = [0u8; 1 << 16];
+    let mut read = 0u64;
+    loop {
+        if cancelled.load(Ordering::Relaxed) {
+            return Err(cancelled_err());
+        }
+        let n = match file.read(&mut chunk) {
+            Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
+            result => result.map_err(err)?,
+        };
+        if n == 0 {
+            break;
+        }
+        bytes.try_reserve(n).map_err(|_| EngineError::Io(format!("{}: not enough memory to read the file", path.display())))?;
+        bytes.extend_from_slice(chunk.get(..n).unwrap_or_default());
+        read = read.saturating_add(n as u64);
+        if size > 0 {
+            report((read.min(size) as f64 / size as f64) as f32);
+        }
+    }
+    if cancelled.load(Ordering::Relaxed) {
+        return Err(cancelled_err());
+    }
+    report(1.0);
+    if cancelled.load(Ordering::Relaxed) {
+        return Err(cancelled_err());
+    }
+    Ok(bytes)
+}
+
 /// [`read_file`] as UTF-8 text.
 pub fn read_text(path: impl AsRef<Path>) -> Result<String> {
     let path = path.as_ref();
@@ -233,6 +302,94 @@ fn place_import(
     Ok(json!({"track": track, "clip": cid, "source": src, "frames": frames, "channels": channels}))
 }
 
+/// 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> {
+    prepare_import_buffer_cancellable(name, buf, sample_rate, &AtomicBool::new(false), |_| {})
+}
+
+/// [`prepare_import_buffer`] reporting the conversion's progress and stopping as soon as
+/// `cancelled` is set, so cancelling resamples no more of a long file.
+pub fn prepare_import_buffer_cancellable(
+    name: &str,
+    buf: AudioBuffer,
+    sample_rate: u32,
+    cancelled: &AtomicBool,
+    mut report: impl FnMut(f32),
+) -> Result<AudioBuffer> {
+    let mut stopped = false;
+    let converted = at_rate_reported(buf, sample_rate, MAX_IMPORT_SAMPLES, &mut |fraction| {
+        if cancelled.load(Ordering::Relaxed) {
+            stopped = true;
+            return false;
+        }
+        report(fraction);
+        if cancelled.load(Ordering::Relaxed) {
+            stopped = true;
+            return false;
+        }
+        true
+    });
+    let buf = match converted {
+        Some(buf) => buf,
+        None if stopped => return Err(EngineError::Io(format!("{name}: cancelled"))),
+        None => return Err(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> {
+    if audio.buffer.sample_rate != e.session().sample_rate.hz() {
+        return Err(EngineError::Io("session sample rate changed during import; please import again".into()));
+    }
+    place_import(e, name, &info, audio, path, target, at)
+}
+
+/// 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.prepared_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.prepared_imports = imports;
+    let result = e.execute("file.import_audio", params);
+    e.prepared_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.prepared_imports.iter().position(|p| p.path == path)?;
+    Some(e.prepared_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.
@@ -1904,6 +2061,116 @@ mod record_tests {
     }
 }
 
+#[cfg(test)]
+mod import_read_tests {
+    use super::*;
+
+    /// A file of `bytes` bytes in its own temporary folder.
+    fn temp_file(name: &str, bytes: usize) -> PathBuf {
+        let dir = std::env::temp_dir().join(format!("soundcraft-read-{name}-{}", std::process::id()));
+        let _ = std::fs::remove_dir_all(&dir);
+        std::fs::create_dir_all(&dir).unwrap();
+        let path = dir.join("take.bin");
+        std::fs::write(&path, vec![7u8; bytes]).unwrap();
+        path
+    }
+
+    #[test]
+    fn reading_a_file_reports_its_bytes_and_stops_when_cancelled() {
+        let path = temp_file("bytes", 300_000);
+        let mut fractions = Vec::new();
+        let bytes = read_file_cancellable(&path, &AtomicBool::new(false), |f| fractions.push(f)).unwrap();
+        assert_eq!(bytes.len(), 300_000);
+        assert!(fractions.len() > 1, "the read reports block by block");
+        assert!(fractions.windows(2).all(|w| w[0] <= w[1]), "{fractions:?}");
+        assert_eq!(fractions.last().copied(), Some(1.0));
+
+        let cancelled = AtomicBool::new(true);
+        let error = read_file_cancellable(&path, &cancelled, |_| {}).unwrap_err();
+        assert!(error.to_string().contains("cancelled"), "{error}");
+        let _ = std::fs::remove_dir_all(path.parent().unwrap());
+    }
+
+    #[test]
+    fn reading_stops_when_cancelled_during_a_progress_report() {
+        let path = temp_file("cancel-during-read", 300_000);
+        for stop_at_end in [false, true] {
+            let cancelled = AtomicBool::new(false);
+            let mut reports = Vec::new();
+            let result = read_file_cancellable(&path, &cancelled, |f| {
+                reports.push(f);
+                if !stop_at_end || f == 1.0 {
+                    cancelled.store(true, Ordering::Relaxed);
+                }
+            });
+            assert!(result.unwrap_err().to_string().contains("cancelled"));
+            if !stop_at_end {
+                assert_eq!(reports.len(), 1, "stop after one read block");
+                assert!(reports[0] < 1.0);
+            }
+        }
+        // An empty file reports completion without entering the read loop's progress branch.
+        std::fs::write(&path, []).unwrap();
+        let cancelled = AtomicBool::new(false);
+        let error = read_file_cancellable(&path, &cancelled, |_| cancelled.store(true, Ordering::Relaxed)).unwrap_err();
+        assert!(error.to_string().contains("cancelled"), "{error}");
+        let _ = std::fs::remove_dir_all(path.parent().unwrap());
+    }
+
+    #[cfg(unix)]
+    #[test]
+    fn reading_rejects_non_regular_files_before_opening_them() {
+        let path = temp_file("non-regular", 0);
+        std::fs::remove_file(&path).unwrap();
+        let _socket = std::os::unix::net::UnixListener::bind(&path).unwrap();
+        let error = read_file_cancellable(&path, &AtomicBool::new(false), |_| {}).unwrap_err();
+        assert!(error.to_string().contains("not a regular file"), "{error}");
+        let _ = std::fs::remove_dir_all(path.parent().unwrap());
+    }
+
+    #[test]
+    fn converting_reports_progress_and_a_cancel_stops_it_early() {
+        let buf = AudioBuffer { sample_rate: 44_100, channels: vec![vec![0.25; 40_000]; 2] };
+        let mut last = -1.0;
+        let converted = prepare_import_buffer_cancellable("take.wav", buf.clone(), 48_000, &AtomicBool::new(false), |f| last = f).unwrap();
+        assert_eq!(converted.sample_rate, 48_000);
+        assert_eq!(converted.channels[0].len(), (40_000.0 * 48_000.0 / 44_100.0_f64).round() as usize);
+        assert_eq!(last, 1.0, "a finished conversion reports the whole stage");
+
+        let cancel = AtomicBool::new(false);
+        let mut reports = 0;
+        let error = prepare_import_buffer_cancellable("take.wav", buf, 48_000, &cancel, |_| {
+            reports += 1;
+            cancel.store(true, Ordering::Relaxed);
+        })
+        .unwrap_err();
+        assert!(error.to_string().contains("cancelled"), "{error}");
+        assert_eq!(reports, 1, "the resampler stops at the first block after Cancel");
+    }
+
+    #[test]
+    fn an_untouched_rate_needs_no_conversion_and_still_ends_the_stage() {
+        let buf = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 1_000]] };
+        let mut last = -1.0;
+        let converted = prepare_import_buffer_cancellable("take.wav", buf, 48_000, &AtomicBool::new(false), |f| last = f).unwrap();
+        assert_eq!(converted.channels[0].len(), 1_000);
+        assert_eq!(last, 1.0, "an unchanged rate still finishes its stage");
+    }
+
+    #[test]
+    fn an_untouched_rate_still_honours_cancellation() {
+        for cancel_during_report in [false, true] {
+            let buf = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 1_000]] };
+            let cancelled = AtomicBool::new(!cancel_during_report);
+            let error = prepare_import_buffer_cancellable("take.wav", buf, 48_000, &cancelled, |_| {
+                cancelled.store(true, Ordering::Relaxed);
+            })
+            .unwrap_err();
+            assert!(error.to_string().contains("cancelled"), "{error}");
+        }
+    }
+}
+
 #[cfg(test)]
 mod save_tests {
     use super::*;
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
@@ -195,6 +195,8 @@ pub struct Engine {
     pub last_selection: Option<(soundcraft_time::Range, Vec<TrackId>)>,
     /// Audio decoded off the UI thread, waiting for its `file.import_audio` (see `offer_import`).
     imports: Vec<io::DecodedImport>,
+    /// Files prepared by the modal importer before one atomic command.
+    pub(crate) prepared_imports: Vec<io::PreparedImport>,
 }
 
 impl Default for Engine {
@@ -226,6 +228,7 @@ impl Engine {
             messages: Vec::new(),
             last_selection: None,
             imports: Vec::new(),
+            prepared_imports: Vec::new(),
         }
     }
 
@@ -242,6 +245,7 @@ impl Engine {
     /// Drops audio offered for imports that never ran (the command failed or was not sent).
     pub fn drop_offered_imports(&mut self) {
         self.imports.clear();
+        self.prepared_imports.clear();
     }
 
     /// The offered audio of the file at `path`, if it was decoded for the session's current rate.
@@ -283,6 +287,8 @@ impl Engine {
         self.undo.clear();
         self.redo.clear();
         self.last_selection = None;
+        self.imports.clear();
+        self.prepared_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
@@ -267,6 +267,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();
     }
@@ -291,7 +294,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));
@@ -304,7 +307,7 @@ pub fn open_session_bytes(app: &mut SoundApp, name: String, bytes: Vec<u8>) {
 /// marks the document replaced so pending captures cancel and reports any missing media.
 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!({}));
@@ -871,7 +874,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
         keep = false;
         if cmd == "file.import_audio" {
             // Decoded in the background so a long file doesn't freeze the app (#597).
-            crate::import::start(app, params);
+            let _ = crate::import::start(app, &params);
         } else {
             match run_then(app, &cmd, params) {
                 Ok(v) if cmd == "file.export_clips" => {
@@ -900,6 +903,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
--- a/crates/ui-egui/src/import.rs
+++ b/crates/ui-egui/src/import.rs
@@ -1,235 +1,641 @@
-//! Import Audio reads and decodes on a worker thread, so the app keeps drawing and stays usable
-//! while a long file loads (issue #597). When the audio is ready it is handed to the engine
-//! (`Engine::offer_import`) and `file.import_audio` runs as before, now only placing the clip:
-//! undo, the journal, the CLI, MCP and the control channel see the same command, and calls from
-//! the control channel still run synchronously.
+//! 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 std::collections::VecDeque;
+use soundcraft_engine::io::PreparedImport;
+use std::sync::{
+    Arc, Mutex,
+    atomic::{AtomicBool, Ordering},
+    mpsc::Receiver,
+};
 
-/// Imports waiting and the one decoding now. One file decodes at a time: each can take gigabytes.
-#[derive(Default)]
-pub struct Imports {
-    queue: VecDeque<Value>,
-    #[cfg(not(target_arch = "wasm32"))]
-    running: Option<Running>,
-}
-
-#[cfg(not(target_arch = "wasm32"))]
-struct Running {
+pub struct ImportJob {
     params: Value,
-    name: String,
-    /// The session rate the audio is decoded for.
-    rate: u32,
-    rx: std::sync::mpsc::Receiver<Result<soundcraft_engine::io::DecodedImport, String>>,
+    names: Vec<String>,
+    worker: Receiver<Result<Vec<PreparedImport>, String>>,
+    progress: Arc<Mutex<Progress>>,
+    cancelled: Arc<AtomicBool>,
+    shown: bool,
+    displayed_fraction: f32,
+    completed: bool,
 }
 
-impl Imports {
-    /// Whether a file is decoding or waiting.
-    pub fn busy(&self) -> bool {
-        #[cfg(not(target_arch = "wasm32"))]
-        if self.running.is_some() {
-            return true;
-        }
-        !self.queue.is_empty()
+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 the file from disk, and the fraction of its bytes read.
+    Reading(f32),
+    Decoding(f32),
+    /// Resampling to the session rate, and the fraction converted.
+    Converting(f32),
+    Waveform,
+    Ready,
+}
+
+#[derive(Debug, Clone, Copy)]
+struct Progress {
+    file: usize,
+    phase: Phase,
+}
 
-    /// The file decoding now and how many wait after it.
-    fn label(&self) -> Option<(String, usize)> {
-        #[cfg(not(target_arch = "wasm32"))]
-        if let Some(r) = &self.running {
-            return Some((r.name.clone(), self.queue.len()));
+/// A stage's own fraction, safe against NaN and out-of-range values from a decoder.
+fn stage_fraction(f: f32) -> f32 {
+    if f.is_finite() { f.clamp(0.0, 1.0) } else { 0.0 }
+}
+
+impl Progress {
+    /// Four preparation stages per file plus the final atomic placement. This measures work
+    /// stages, not elapsed time. Reading, decoding and converting contribute their measured
+    /// fraction within their stage, so a long read or resample moves the bar.
+    fn fraction(self, files: usize) -> f32 {
+        if files == 0 {
+            return 0.0;
         }
-        None
+        let stage = match self.phase {
+            Phase::Reading(f) => stage_fraction(f),
+            Phase::Decoding(f) => 1.0 + stage_fraction(f),
+            Phase::Converting(f) => 2.0 + stage_fraction(f),
+            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
     }
 }
 
-/// Imports the audio file `params["path"]` names (the `file.import_audio` params) in the
-/// background. Anything else (several `paths`, no path, the browser) runs the command now.
-pub fn start(app: &mut SoundApp, params: Value) {
-    if cfg!(target_arch = "wasm32") || params.get("path").and_then(Value::as_str).is_none() {
-        run_now(app, params);
-        return;
+impl Default for Progress {
+    fn default() -> Self {
+        Self { file: 0, phase: Phase::Reading(0.0) }
     }
-    app.imports.queue.push_back(params);
 }
 
-/// Starts the next import, and places a finished one. Called every frame from `logic`.
-pub fn step(app: &mut SoundApp, ctx: &egui::Context) {
-    #[cfg(not(target_arch = "wasm32"))]
+/// 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")]
     {
-        finish(app);
-        if app.imports.running.is_none()
-            && let Some(params) = app.imports.queue.pop_front()
-        {
-            spawn(app, params, ctx);
-        }
+        app.run("file.import_audio", params.clone())
     }
-    #[cfg(target_arch = "wasm32")]
+    #[cfg(not(target_arch = "wasm32"))]
     {
-        let _ = ctx;
-        while let Some(params) = app.imports.queue.pop_front() {
-            run_now(app, params);
+        let result = start_native(app, params);
+        if let Err(error) = &result {
+            app.ui.status = error.clone();
         }
+        result
     }
 }
 
 #[cfg(not(target_arch = "wasm32"))]
-fn spawn(app: &mut SoundApp, params: Value, ctx: &egui::Context) {
-    let path = params.get("path").and_then(Value::as_str).unwrap_or_default().to_string();
-    let name = std::path::Path::new(&path).file_name().map_or_else(|| path.clone(), |n| n.to_string_lossy().into_owned());
-    let rate = app.engine.session().sample_rate.hz();
-    let (tx, rx) = std::sync::mpsc::channel();
-    let ctx = ctx.clone();
-    let work = move || {
-        let r = soundcraft_engine::io::decode_import(&path, rate).map_err(|e| e.to_string());
-        let _ = tx.send(r);
-        ctx.request_repaint();
-    };
-    match std::thread::Builder::new().name("audio-import".into()).spawn(work) {
-        // No status line here: it may hold the previous file's failure, and the progress note names this file.
-        Ok(_) => app.imports.running = Some(Running { params, name, rate, rx }),
-        // No thread to spare: import the old way rather than not at all.
-        Err(_) => {
-            run_now(app, params);
-        }
+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 finish(app: &mut SoundApp) {
-    let Some(r) = &app.imports.running else { return };
-    let done = match r.rx.try_recv() {
-        Ok(done) => done,
-        Err(std::sync::mpsc::TryRecvError::Empty) => return,
-        Err(std::sync::mpsc::TryRecvError::Disconnected) => Err(format!("{}: the import stopped", r.name)),
-    };
-    let Some(Running { params, name, rate, .. }) = app.imports.running.take() else { return };
-    // The session changed rate meanwhile (another session was opened): decode again for it.
-    if rate != app.engine.session().sample_rate.hz() {
-        app.imports.queue.push_front(params);
+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())
+}
+
+/// Why this worker stops: the person pressed Cancel (the status line says so).
+#[cfg(not(target_arch = "wasm32"))]
+fn cancelled_error() -> soundcraft_audio_io::Result<()> {
+    Err(soundcraft_audio_io::AudioError::Cancelled)
+}
+
+#[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() {
+        // Report a stage without checking for cancellation; the steps that can be interrupted
+        // check it themselves (the reader and the resampler carry the flag).
+        let report = |phase| {
+            if let Ok(mut state) = progress.lock() {
+                *state = Progress { file, phase };
+            }
+        };
+        let stop = || -> soundcraft_audio_io::Result<()> { if cancelled.load(Ordering::Relaxed) { cancelled_error() } else { Ok(()) } };
+        let name = file_name(&path);
+        let error = |e: soundcraft_audio_io::AudioError| format!("{name}: {e}");
+        report(Phase::Reading(0.0));
+        stop().map_err(error)?;
+        let bytes = soundcraft_engine::io::read_file_cancellable(&path, cancelled, |f| report(Phase::Reading(f))).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| {
+            report(Phase::Decoding(f));
+            stop()
+        })
+        .map_err(error)?;
+        drop(bytes);
+        report(Phase::Converting(0.0));
+        stop().map_err(error)?;
+        let buf = soundcraft_engine::io::prepare_import_buffer_cancellable(&name, buf, rate, cancelled, |f| report(Phase::Converting(f)))
+            .map_err(|e| e.to_string())?;
+        report(Phase::Waveform);
+        stop().map_err(error)?;
+        let audio = Arc::new(soundcraft_model::SourceAudio::new(buf));
+        // Check again after building the waveform, which cannot be interrupted. Keep ownership of
+        // this worker until it exits, so repeated Cancel/Import cannot accumulate workers.
+        stop().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;
     }
-    match done {
-        Ok(decoded) => {
-            app.engine.offer_import(decoded);
-            if run_now(app, params) {
-                app.ui.status = format!("Imported {name}");
+    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())
+                }
             }
-            app.engine.drop_offered_imports();
+            Err(error) => format!("Import failed: {error}"),
         }
-        Err(e) => app.ui.status = e,
+    };
+    if !job.completed {
+        app.import = None;
     }
+    app.ui.status = status;
 }
 
-/// Runs `file.import_audio` on this thread; the status line shows a failure. True on success.
-fn run_now(app: &mut SoundApp, params: Value) -> bool {
-    match app.run("file.import_audio", params) {
-        Ok(_) => true,
-        Err(e) => {
-            app.ui.status = e;
-            false
-        }
+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()
 }
 
-/// A small, non-blocking note while files import: the rest of the app stays usable.
-pub fn show(app: &SoundApp, ctx: &egui::Context) {
-    let Some((name, waiting)) = app.imports.label() else { return };
-    egui::Area::new(egui::Id::unique("audio_import_progress")).anchor(egui::Align2::RIGHT_BOTTOM, egui::vec2(-16.0, -40.0)).interactable(false).show(
-        ctx,
-        |ui| {
-            egui::Frame::popup(ui.style()).show(ui, |ui| {
-                ui.horizontal(|ui| {
-                    ui.add(egui::Spinner::new());
-                    let more = if waiting > 0 { format!(" ({waiting} more)") } else { String::new() };
-                    ui.label(format!("Importing {name}{more}"));
-                });
+/// One accumulating bar for the whole batch, filled by completed stages and measured progress
+/// within them. Interpolation softens 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::unique("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(test)]
+#[cfg(all(test, not(target_arch = "wasm32")))]
 mod tests {
     use super::*;
-    use crate::{Services, SoundApp};
+    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;
 
-    fn tone_wav(name: &str) -> String {
-        let dir = std::env::temp_dir().join(format!("sc-ui-import-{}", std::process::id()));
-        std::fs::create_dir_all(&dir).unwrap();
-        let tone: Vec<f32> = (0..9_600).map(|i| (i as f32 * 0.05).sin() * 0.2).collect();
-        let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![tone.clone(), tone] };
-        let bytes = soundcraft_audio_io::encode(&buf, &soundcraft_audio_io::EncodeOptions::default()).unwrap();
-        let path = dir.join(name);
-        std::fs::write(&path, bytes).unwrap();
-        path.to_string_lossy().into_owned()
-    }
-
-    fn settle(app: &mut SoundApp, ctx: &egui::Context) {
-        for _ in 0..2_000 {
-            step(app, ctx);
-            if !app.imports.busy() {
-                return;
+    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;
             }
-            std::thread::sleep(std::time::Duration::from_millis(5));
+            step(app, &egui::Context::default());
+            assert!(std::time::Instant::now() < deadline, "import must terminate");
+            std::thread::sleep(std::time::Duration::from_millis(1));
         }
-        panic!("import never finished");
     }
 
-    /// Issue #597: starting an import returns at once; the clip lands, as one undo step, later.
     #[test]
-    fn import_runs_in_the_background_and_places_one_undoable_clip() {
-        let ctx = egui::Context::default();
-        let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
-        let wav = tone_wav("bg.wav");
-        start(&mut app, json!({"path": wav, "at": 480}));
-        assert!(app.imports.busy());
-        assert!(app.engine.session().tracks.is_empty(), "nothing is placed before the audio is ready");
-        settle(&mut app, &ctx);
+    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(), 1);
-        assert_eq!(s.tracks[0].clips()[0].start, 480);
-        assert_eq!(s.sources[0].frames, 9_600);
-        assert!(app.ui.status.starts_with("Imported bg.wav"), "{}", app.ui.status);
-        app.run("edit.undo", json!({})).unwrap();
+        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 imports_queue_and_a_bad_file_reports_without_stopping_the_rest() {
-        let ctx = egui::Context::default();
-        let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
-        let junk = tone_wav("junk.wav");
-        std::fs::write(&junk, b"not audio").unwrap();
-        let good = tone_wav("good.wav");
-        start(&mut app, json!({"path": junk}));
-        start(&mut app, json!({"path": good}));
-        step(&mut app, &ctx);
-        assert_eq!(app.imports.label().map(|l| l.1), Some(1), "the second waits for the first");
-        settle(&mut app, &ctx);
-        assert_eq!(app.engine.session().tracks.len(), 1, "the good file still imports");
+    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(&[&allowed], &[]).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 a_failed_import_stays_on_the_status_line_while_the_next_one_starts() {
-        let ctx = egui::Context::default();
-        let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
-        let junk = tone_wav("junk-status.wav");
-        std::fs::write(&junk, b"not audio").unwrap();
-        start(&mut app, json!({"path": junk}));
-        start(&mut app, json!({"path": tone_wav("next-status.wav")}));
-        for _ in 0..2_000 {
-            step(&mut app, &ctx);
-            if app.imports.label().is_some_and(|(name, _)| name == "next-status.wav") {
-                break;
+    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(0.0),
+                Phase::Reading(0.5),
+                Phase::Reading(1.0),
+                Phase::Decoding(0.0),
+                Phase::Decoding(0.25),
+                Phase::Decoding(1.0),
+                Phase::Converting(0.0),
+                Phase::Converting(0.5),
+                Phase::Converting(1.0),
+                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);
+        // A long read or resample moves the bar within its own stage.
+        assert!(Progress { file: 0, phase: Phase::Reading(0.5) }.fraction(1) > 0.0);
+        assert!(Progress { file: 0, phase: Phase::Converting(0.5) }.fraction(1) > Progress { file: 0, phase: Phase::Converting(0.0) }.fraction(1));
+        for f in [f32::NAN, f32::INFINITY, -1.0, 2.0] {
+            for phase in [Phase::Reading(f), Phase::Decoding(f), Phase::Converting(f)] {
+                let fraction = Progress { file: usize::MAX, phase }.fraction(usize::MAX);
+                assert!(fraction.is_finite() && (0.0..1.0).contains(&fraction), "{phase:?}: {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()];
+        // Filenames can use scripts outside the installed fonts. Match the application's
+        // default fallback policy while exercising layout and cancellation on minimal systems.
+        let mut h = Harness::builder().with_size(egui::vec2(900.0, 600.0)).allow_missing_glyphs().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(0.42)),
+            ("decoding", Phase::Decoding(0.63)),
+            ("converting", Phase::Converting(0.55)),
+            ("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();
             }
-            std::thread::sleep(std::time::Duration::from_millis(5));
         }
-        assert!(app.imports.label().is_some_and(|(name, _)| name == "next-status.wav"), "the second import never started");
-        assert!(!app.ui.status.is_empty() && !app.ui.status.starts_with("Importing"), "the failure was overwritten: {}", app.ui.status);
-        settle(&mut app, &ctx);
+        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
@@ -409,8 +409,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>,
-    /// Audio files importing in the background (issue #597).
-    pub imports: import::Imports,
+
+    /// 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)]
@@ -559,7 +560,7 @@ impl SoundApp {
             last_meter_poll: f64::NEG_INFINITY,
             video: video_track::VideoPool::default(),
             bounce: None,
-            imports: import::Imports::default(),
+            import: None,
             #[cfg(test)]
             _palette_lock: theme::lock_test_palette(),
             #[cfg(test)]
@@ -574,7 +575,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) {
@@ -1824,7 +1828,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
@@ -469,6 +469,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
@@ -59,7 +59,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;
     }
     // A scripted press is identified by its exact position in this pass's events, not by matching
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
@@ -365,7 +365,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;
-                        crate::import::start(app, 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-19 · **Last updated:** 2026-10-25 · **Change:** trivial (G-04: Clip Looping crossfade and loop count; previously trivial: G-08 and G-20: built-in plugin count, the sampler shipped) · **Target:** Avid Pro Tools Ultimate 2026.4.1
+> **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** minor (audio import progress, cancellation and faster rate conversion) · **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**
@@ -204,6 +204,10 @@ settings command only. Needs an openly licensed local speech model. ≈ 30–50
 
 ### G-19. UI feel
 
+**Fixed (2026-10-26):** The audio import's bar now moves within the reading and rate-conversion stages instead of holding at 0 % through a long read. Cancel/Esc is checked between 64 KiB reads and 8192-output-sample resample blocks, including the fallback for uncommon rate pairs; same-rate and completion callbacks also honour cancellation. The reader rejects non-regular files before opening them, so a pipe cannot block the worker on open. The polyphase resampler sums taps in eight partial sums. A synthetic ten-minute, 24-bit stereo WAV imported from 44.1 kHz into a 48 kHz demo session took 1.19 s (median of five release CLI runs on the development Mac), compared with the earlier 5.56 s baseline, about 4.7 times faster. Other UI fidelity work and estimates unchanged.
+
+**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
@@ -225,6 +229,7 @@ missing. Out of scope unless the owner decides otherwise.
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-26 | minor | Native audio imports prepare an atomic batch with cumulative progress, block-wise read/resample cancellation and one-step Undo; ten-minute 44.1 → 48 kHz import improves from 5.56 s to 1.19 s on the development Mac; readiness estimates unchanged |
 | 2026-10-25 | trivial | G-04: Clip Looping (#1030) lacks the loop crossfade option, and Number of Loops stops at 512 while length and fill make up to 1000 loops |
 | 2026-10-24 | trivial | G-08: 27 processors + 3 instruments, as the plugin registry holds; G-20: the sampler shipped (#108), so it is no longer listed as missing |
 | 2026-10-19 | trivial | G-04: Float32 encoding retains finite headroom (#791); integer clipping and non-finite handling retained |
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-24 · **Change:** trivial (built-in plugin count; the sampler shipped) · **Target:** Avid Pro Tools Ultimate 2026.4.1
+> **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** minor (audio import progress, cancellation and faster rate conversion) · **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-26 | minor | Native audio imports prepare an atomic batch with cumulative progress, block-wise read/resample cancellation and one-step Undo; ten-minute 44.1 → 48 kHz import improves from 5.56 s to 1.19 s on the development Mac; readiness estimates unchanged |
 | 2026-10-24 | trivial | 27 built-in processors, as the plugin registry holds; the sampler (#108) is no longer listed under plugin essentials |
 | 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) |
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-25 · **Last updated:** 2026-10-25 · **Change:** minor (Tracks and Groups and Clips in dock columns) · **Target:** Avid Pro Tools Ultimate 2026.4.1
+> **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** minor (audio import progress, cancellation and faster rate conversion) · **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 filling left to right across the batch. Each file contributes reading, decoding, conversion and waveform stages; reading, decoding and conversion supply measured fractional progress, and final atomic placement reserves the last step. Reading in 64 KiB blocks and resampling in 8192-output-sample blocks means a long file moves the bar instead of holding at 0 %. Cancel is checked between blocks for both the polyphase and fallback resamplers, and honoured for same-rate audio and completion callbacks. This is work-stage progress rather than a time estimate. The fill never resets between files; cancellation freezes it. 100% is painted after successful placement before the popup closes. Cancel/Esc, atomic batch failure and one-step Undo covered. Browser byte imports remain synchronous; no claim of full Pro Tools Task Manager parity |
 | 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-26 | minor | Native audio imports prepare an atomic batch with cumulative progress, block-wise read/resample cancellation and one-step Undo; ten-minute 44.1 → 48 kHz import improves from 5.56 s to 1.19 s on the development Mac; readiness estimates unchanged |
 | 2026-10-25 | minor | Edit and Mix windows: Tracks and Groups and Clips in full-height dock columns. Readiness estimate unchanged |
 | 2026-10-16 | minor | Track name menu: Scroll Into View brings the track to the top of the Edit window and the left of the Mix. Readiness estimate unchanged |
 | 2026-10-13 | minor | A plugin opens when it's inserted (#486) |
