diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs
--- a/crates/playback/src/lib.rs
+++ b/crates/playback/src/lib.rs
@@ -36,7 +36,36 @@ use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, Ordering};
 use std::sync::mpsc::{Receiver, Sender, SyncSender, channel, sync_channel};
 use std::sync::{Arc, Mutex, PoisonError};
 
+/// The largest block the mix renders when the device buffer is unknown or smaller, and the
+/// smallest one plugins are prepared for: what playback always used before it followed the
+/// device buffer.
 const BLOCK: usize = 512;
+/// The largest block playback renders in one go (8192, the largest device buffer offered).
+pub const MAX_BLOCK: usize = 8192;
+
+/// True when the session has automation to play: then playback keeps to [`BLOCK`]-frame blocks.
+/// The mix reads volume, trim, VCA, send and plugin-parameter automation once per block (at its
+/// ends, or its start), so larger blocks would coarsen automation from about 10 ms to as much as
+/// 170 ms. Any lane with points counts, whatever the track's automation mode: a few blocks too
+/// many cost nothing audible, a dip that vanishes does.
+fn plays_automation(s: &Session) -> bool {
+    s.tracks.iter().any(|t| t.automation.iter().any(|l| !l.points.is_empty()))
+}
+
+/// The largest block playback renders for a device buffer of `buffer` frames (`None`: the device
+/// default, size unknown), so a callback renders in one block, plugins see the buffer the user
+/// chose, and they are prepared for exactly that. A session faster than the device renders more
+/// frames than it plays, hence the rate ratio. Never below [`BLOCK`] or above [`MAX_BLOCK`];
+/// anything larger (an unknown default, a callback larger than asked for, varispeed above 1x) is
+/// rendered in several blocks, and so is a session with automation ([`plays_automation`]). Some
+/// drivers call back at their own period whatever buffer was asked for (WASAPI shared mode, about
+/// 10 ms): plugins are then prepared for the chosen buffer and see the device's callbacks.
+pub fn block_for(buffer: Option<u32>, device_rate: f64, session_rate: f64) -> usize {
+    let Some(frames) = buffer else { return BLOCK };
+    let ratio = if device_rate > 0.0 && session_rate > device_rate { session_rate / device_rate } else { 1.0 };
+    let n = (f64::from(frames) * ratio).ceil();
+    if n.is_finite() { (n as usize).clamp(BLOCK, MAX_BLOCK) } else { MAX_BLOCK }
+}
 
 /// Room for replaced snapshots on their way back; `update_session` empties it before each send.
 const OLD_SESSIONS: usize = 16;
@@ -171,10 +200,14 @@ struct AudioState {
     /// Retired plugin instances go back to the UI thread to be dropped there.
     garbage: Sender<Vec<Box<dyn Plugin>>>,
     old_sessions: Option<SyncSender<Arc<Session>>>,
+    /// The largest block it renders and its plugins were prepared for ([`block_for`]).
+    block: usize,
+    /// The session has automation ([`plays_automation`]): blocks stay at [`BLOCK`] frames.
+    automated: bool,
 }
 
-fn new_engine(sr: f32) -> MixEngine {
-    let mut m = MixEngine::new(sr, BLOCK);
+fn new_engine(sr: f32, block: usize) -> MixEngine {
+    let mut m = MixEngine::new(sr, block);
     m.set_external_instances(true);
     m.metronome = true;
     m
@@ -182,6 +215,20 @@ fn new_engine(sr: f32) -> MixEngine {
 
 impl AudioState {
     fn new(rx: Receiver<Cmd>, shared: Arc<Shared>, session: Arc<Session>, device_rate: f64, garbage: Sender<Vec<Box<dyn Plugin>>>) -> Self {
+        Self::with_block(rx, shared, session, device_rate, garbage, BLOCK)
+    }
+
+    /// Like [`AudioState::new`], rendering blocks of up to `block` frames.
+    fn with_block(
+        rx: Receiver<Cmd>,
+        shared: Arc<Shared>,
+        session: Arc<Session>,
+        device_rate: f64,
+        garbage: Sender<Vec<Box<dyn Plugin>>>,
+        block: usize,
+    ) -> Self {
+        let block = block.clamp(BLOCK, MAX_BLOCK);
+        let automated = plays_automation(&session);
         let sr = session.sample_rate.as_f64() as f32;
         let nch = main_channels(&session);
         AudioState {
@@ -190,18 +237,20 @@ impl AudioState {
             fold: fold_gains(&session),
             rx,
             shared,
-            mix: new_engine(sr),
+            mix: new_engine(sr, block),
             session,
             pos: 0,
             playing: false,
             end: None,
             looped: None,
-            pending: vec![vec![0.0; BLOCK]; nch],
+            pending: vec![vec![0.0; block]; nch],
             pending_len: 0,
             read: 0.0,
             device_rate,
             input: None,
             recording: false,
+            block,
+            automated,
         }
     }
 
@@ -214,18 +263,23 @@ impl AudioState {
                 Cmd::Session(s, instances) => {
                     changed = true;
                     if (s.sample_rate.as_f64() - self.session.sample_rate.as_f64()).abs() > 0.5 {
-                        let mut old = std::mem::replace(&mut self.mix, new_engine(s.sample_rate.as_f64() as f32));
+                        let mut old = std::mem::replace(&mut self.mix, new_engine(s.sample_rate.as_f64() as f32, self.block));
                         let _ = self.garbage.send(old.drain_plugins());
                     }
                     self.mix.adopt(instances);
+                    let automated = plays_automation(&s);
+                    if automated && !self.automated {
+                        self.rebase_pending();
+                    }
                     // The main format changed (a document edit, not steady state): resize.
                     let nch = main_channels(&s);
                     if self.pending.len() != nch {
-                        self.pending = vec![vec![0.0; BLOCK]; nch];
+                        self.pending = vec![vec![0.0; self.block]; nch];
                         self.pending_len = 0;
                         self.read = 0.0;
                     }
                     self.fold = fold_gains(&s);
+                    self.automated = automated;
                     let old = std::mem::replace(&mut self.session, s);
                     if let Some(tx) = &self.old_sessions {
                         let _ = tx.try_send(old);
@@ -271,16 +325,33 @@ impl AudioState {
         }
     }
 
+    /// Drops the frames rendered but not yet heard and moves the render position back to the
+    /// first of them, so the next block renders from the audible position: nothing is skipped or
+    /// played twice. A session that gains automation calls it, so its 512-frame blocks start now
+    /// rather than after up to a whole long block rendered without the automation.
+    fn rebase_pending(&mut self) {
+        let heard = (self.read.max(0.0).floor() as usize).min(self.pending_len);
+        if self.playing {
+            let ahead = i64::try_from(self.pending_len - heard).unwrap_or(0);
+            self.pos = self.pos.saturating_sub(ahead).max(0);
+            self.shared.position.store(self.pos, Ordering::Relaxed);
+        }
+        self.read -= heard as f64;
+        self.pending_len = 0;
+    }
+
     /// Render the next block at the session rate into `pending`.
     fn render_next(&mut self, quantum: usize) {
-        let mut n = quantum.clamp(1, BLOCK);
+        // Automation is read once per block: keep the blocks it was always read in.
+        let largest = if self.automated { self.block.min(BLOCK) } else { self.block };
+        let mut n = quantum.clamp(1, largest);
         if let Some(l) = self.looped
             && self.pos >= l.end
         {
             self.pos = l.start;
         }
         if let Some(l) = self.looped {
-            n = n.min(usize::try_from((l.end - self.pos).max(1)).unwrap_or(BLOCK));
+            n = n.min(usize::try_from((l.end - self.pos).max(1)).unwrap_or(self.block));
         } else if let Some(e) = self.end {
             if self.pos >= e {
                 self.playing = false;
@@ -288,20 +359,20 @@ impl AudioState {
                 self.shared.playing.store(false, Ordering::Relaxed);
                 n = 0;
             } else {
-                n = n.min(usize::try_from(e - self.pos).unwrap_or(BLOCK));
+                n = n.min(usize::try_from(e - self.pos).unwrap_or(self.block));
             }
         }
         if n == 0 {
             for c in &mut self.pending {
                 c.iter_mut().for_each(|x| *x = 0.0);
             }
-            self.pending_len = BLOCK;
+            self.pending_len = self.block;
             return;
         }
         if let Some(ring) = &self.input {
             let ch = ring.channels.load(Ordering::Relaxed).max(1);
             if self.mix.input.len() != ch {
-                self.mix.input = vec![vec![0.0; BLOCK]; ch];
+                self.mix.input = vec![vec![0.0; self.block]; ch];
             }
             ring.pop_into(&mut self.mix.input, n, self.session.sample_rate.hz());
         }
@@ -343,7 +414,7 @@ impl AudioState {
         let speed = f64::from(self.shared.speed.load(Ordering::Relaxed)) / 1000.0;
         let step = self.session.sample_rate.as_f64() / self.device_rate.max(1.0) * speed.clamp(0.1, 4.0);
         let callback_frames = out.len() / channels;
-        let quantum = ((callback_frames as f64 * step).ceil() as usize).clamp(1, BLOCK);
+        let quantum = ((callback_frames as f64 * step).ceil() as usize).clamp(1, self.block);
         for frame in out.chunks_mut(channels) {
             while self.read >= self.pending_len as f64 {
                 self.read -= self.pending_len as f64;
@@ -362,7 +433,7 @@ impl AudioState {
             let sample = |c: usize| {
                 let ch = self.pending.get(c);
                 let a = ch.and_then(|v| v.get(i0)).copied().unwrap_or(0.0);
-                // `pending` is BLOCK long; only `pending_len` frames are valid. Hold the last one at the edge.
+                // `pending` holds `block` frames; only `pending_len` are valid. Hold the last one at the edge.
                 let b = if i0 + 1 < self.pending_len { ch.and_then(|v| v.get(i0 + 1)).copied().unwrap_or(a) } else { a };
                 a * (1.0 - fr) + b * fr
             };
@@ -409,6 +480,8 @@ struct Side {
     shadow: HashMap<(TrackId, usize), (String, usize, bool)>,
     /// Session sample rate the instances were prepared for.
     rate: f32,
+    /// The largest block they were prepared for (the audio thread's [`block_for`]).
+    block: usize,
     /// Editor handles of the live instances.
     editors: HashMap<(TrackId, usize), Box<dyn PluginEditor>>,
     /// State readers of the live instances.
@@ -421,8 +494,22 @@ struct Side {
 }
 
 impl Side {
+    #[cfg(test)]
     fn new(rate: f32, garbage: Receiver<Vec<Box<dyn Plugin>>>) -> Side {
-        Side { shadow: HashMap::new(), rate, editors: HashMap::new(), states: BTreeMap::new(), refused: HashSet::new(), garbage, old_sessions: None }
+        Side::with_block(rate, BLOCK, garbage)
+    }
+
+    fn with_block(rate: f32, block: usize, garbage: Receiver<Vec<Box<dyn Plugin>>>) -> Side {
+        Side {
+            shadow: HashMap::new(),
+            rate,
+            block: block.clamp(BLOCK, MAX_BLOCK),
+            editors: HashMap::new(),
+            states: BTreeMap::new(),
+            refused: HashSet::new(),
+            garbage,
+            old_sessions: None,
+        }
     }
 
     /// Drops retired instances (closing their editors first, inside the plugin's drop) and
@@ -474,6 +561,8 @@ pub struct Player {
     pub device_channels: u16,
     /// The device buffer in frames when one was chosen (`None`: the device default).
     pub device_buffer: Option<u32>,
+    /// The largest block playback renders and plugins are prepared for ([`block_for`]).
+    pub block: usize,
     /// True when no audio device could be opened (silent clock).
     pub silent: bool,
 }
@@ -521,7 +610,11 @@ impl Player {
         let want = session.sample_rate.hz();
         let want_ch = u16::try_from(main_channels(&session)).unwrap_or(2);
         let side_rate = session.sample_rate.as_f64() as f32;
-        let new_side = |garbage: Receiver<Vec<Box<dyn Plugin>>>| Mutex::new(Side::new(side_rate, garbage));
+        let new_side = |garbage: Receiver<Vec<Box<dyn Plugin>>>, block: usize| Mutex::new(Side::with_block(side_rate, block, garbage));
+        let fixed = |b: cpal::BufferSize| match b {
+            cpal::BufferSize::Fixed(n) => Some(n),
+            cpal::BufferSize::Default => None,
+        };
         match open_device(want, want_ch, cfg) {
             Ok((device, config, format, name)) => {
                 // The chosen buffer size first; if the device refuses it, its default.
@@ -536,27 +629,25 @@ impl Player {
                         let channels = usize::from(config.channels);
                         let (tx, rx) = channel();
                         let (gtx, grx) = channel();
-                        let state = AudioState::new(rx, Arc::clone(&shared), Arc::clone(&session), f64::from(rate), gtx);
+                        let block = block_for(fixed(config.buffer_size), f64::from(rate), session.sample_rate.as_f64());
+                        let state = AudioState::with_block(rx, Arc::clone(&shared), Arc::clone(&session), f64::from(rate), gtx, block);
                         let fault = Arc::new(Fault::default());
                         let stream = match format {
                             cpal::SampleFormat::I32 => build_output::<i32>(&device, config, state, channels, &fault, &shared),
                             cpal::SampleFormat::I16 => build_output::<i16>(&device, config, state, channels, &fault, &shared),
                             _ => build_output::<f32>(&device, config, state, channels, &fault, &shared),
                         }?;
-                        Ok(Opened { stream, tx, grx, fault })
+                        Ok(Opened { stream, block, tx, grx, fault })
                     },
                     |opened| start_stream(&opened.stream),
                 );
                 match opened {
-                    Some((config, Opened { stream, tx, grx, fault })) => {
+                    Some((config, Opened { stream, block, tx, grx, fault })) => {
                         let rate = config.sample_rate;
                         let channels = usize::from(config.channels);
-                        let buffer = match config.buffer_size {
-                            cpal::BufferSize::Fixed(n) => Some(n),
-                            cpal::BufferSize::Default => None,
-                        };
+                        let buffer = fixed(config.buffer_size);
                         log::info!(
-                            "audio device: {name} @ {rate} Hz, {channels} ch, {format}, buffer {}",
+                            "audio device: {name} @ {rate} Hz, {channels} ch, {format}, buffer {}, blocks up to {block}",
                             buffer.map_or("default".to_string(), |n| n.to_string())
                         );
                         Player {
@@ -565,12 +656,13 @@ impl Player {
                             #[cfg(not(target_arch = "wasm32"))]
                             watch: Mutex::new(None),
                             tx,
-                            side: new_side(grx),
+                            side: new_side(grx, block),
                             _stream: Some(stream),
                             device_name: name,
                             device_rate: rate,
                             device_channels: config.channels,
                             device_buffer: buffer,
+                            block,
                             silent: false,
                         }
                     }
@@ -578,7 +670,7 @@ impl Player {
                         log::warn!("no usable audio stream; using a silent clock");
                         let (tx, rx) = channel();
                         let (gtx, grx) = channel();
-                        Player::silent_clock(shared, tx, rx, want, new_side(grx), gtx)
+                        Player::silent_clock(shared, tx, rx, want, new_side(grx, BLOCK), gtx)
                     }
                 }
             }
@@ -586,7 +678,7 @@ impl Player {
                 log::warn!("no audio output: {e}; using a silent clock");
                 let (tx, rx) = channel();
                 let (gtx, grx) = channel();
-                Player::silent_clock_with(shared, tx, rx, Arc::clone(&session), new_side(grx), gtx)
+                Player::silent_clock_with(shared, tx, rx, Arc::clone(&session), new_side(grx, BLOCK), gtx)
             }
         }
     }
@@ -648,6 +740,7 @@ impl Player {
             device_rate: rate,
             device_channels: 0,
             device_buffer: None,
+            block: BLOCK,
             silent: true,
         }
     }
@@ -671,6 +764,7 @@ impl Player {
             device_rate: session.sample_rate.hz(),
             device_channels: 0,
             device_buffer: None,
+            block: BLOCK,
             silent: true,
         }
     }
@@ -875,7 +969,7 @@ impl Side {
                 continue;
             }
             self.forget(key);
-            let made = soundcraft_mix::create_instance(&spec.id, spec.state.as_deref(), sr, BLOCK, spec.ch);
+            let made = soundcraft_mix::create_instance(&spec.id, spec.state.as_deref(), sr, self.block, spec.ch);
             log::debug!("plugin instance for {:?} slot {}: {} ({} ch, created: {})", spec.track, spec.slot, spec.id, spec.ch, made.is_some());
             self.shadow.insert(key, (spec.id.clone(), spec.ch, made.is_some()));
             if let Some(soundcraft_mix::NewInstance { mut plugin, refused_state }) = made {
@@ -926,6 +1020,8 @@ fn start_stream(stream: &impl cpal::traits::StreamTrait) -> bool {
 /// A built output stream and the channels and fault flag its callback shares with the player.
 struct Opened {
     stream: cpal::Stream,
+    /// The largest block its audio state renders ([`block_for`]).
+    block: usize,
     tx: Sender<Cmd>,
     grx: Receiver<Vec<Box<dyn Plugin>>>,
     fault: Arc<Fault>,
@@ -1233,6 +1329,119 @@ mod tests {
         st
     }
 
+    #[test]
+    fn the_block_follows_the_device_buffer() {
+        assert_eq!(block_for(None, 48_000.0, 48_000.0), BLOCK, "unknown default buffer: as before");
+        assert_eq!(block_for(Some(64), 48_000.0, 48_000.0), BLOCK, "small buffers: as before");
+        assert_eq!(block_for(Some(512), 48_000.0, 48_000.0), 512);
+        assert_eq!(block_for(Some(1024), 48_000.0, 48_000.0), 1024);
+        assert_eq!(block_for(Some(4096), 96_000.0, 96_000.0), 4096);
+        assert_eq!(block_for(Some(2048), 44_100.0, 48_000.0), 2230, "a faster session renders more frames per callback (2229.03, rounded up)");
+        assert_eq!(block_for(Some(2048), 96_000.0, 48_000.0), 2048, "a slower session never shrinks it");
+        assert_eq!(block_for(Some(1 << 20), 48_000.0, 48_000.0), MAX_BLOCK);
+        assert_eq!(block_for(Some(1024), 0.0, 48_000.0), 1024);
+        assert_eq!(block_for(Some(u32::MAX), 1.0, f64::MAX), MAX_BLOCK);
+    }
+
+    /// Records the size of every block it processes.
+    struct Probe(Box<dyn Plugin>, Arc<Mutex<Vec<usize>>>);
+    impl Plugin for Probe {
+        fn info(&self) -> &'static soundcraft_dsp::PluginInfo {
+            self.0.info()
+        }
+        fn prepare(&mut self, sr: f32, mb: usize, ch: usize) {
+            self.0.prepare(sr, mb, ch);
+        }
+        fn reset(&mut self) {
+            self.0.reset();
+        }
+        fn set_param(&mut self, id: &str, v: f32) -> bool {
+            self.0.set_param(id, v)
+        }
+        fn param(&self, id: &str) -> Option<f32> {
+            self.0.param(id)
+        }
+        fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
+            self.1.lock().unwrap().push(frames);
+            self.0.process(io, frames);
+        }
+    }
+
+    #[test]
+    fn plugins_see_the_device_buffer_in_one_block() {
+        use soundcraft_model::{ChannelFormat, Insert};
+        // (largest block, device callback frames, automation, what the plugin sees per callback)
+        for (block, callback, automated, want) in [
+            (BLOCK, 2048, false, vec![512, 512, 512, 512]),
+            (2048, 2048, false, vec![2048]),
+            (2048, 1000, false, vec![1000]),
+            (BLOCK, 128, false, vec![128]),
+            (2048, 2048, true, vec![512, 512, 512, 512]),
+        ] {
+            let st = audio_output_state(ChannelFormat::Stereo, vec![vec![0.5; 8192]; 2]);
+            let mut session = (*st.session).clone();
+            let track = session.tracks[0].id;
+            session.track_mut(track).unwrap().mixer.inserts[0] = Some(Insert::new("clap:test.probe"));
+            if automated {
+                // Automation is read once per block: a session with any keeps 512-frame blocks.
+                let mut lane = soundcraft_model::AutomationLane::new(soundcraft_model::AutoParam::Volume);
+                lane.set_point(0, 0.0);
+                session.track_mut(track).unwrap().automation.push(lane);
+            }
+            let session = Arc::new(session);
+            let (tx, rx) = channel();
+            let mut st = AudioState::with_block(rx, Arc::clone(&st.shared), Arc::clone(&session), 48_000.0, channel().0, block);
+            let seen = Arc::new(Mutex::new(Vec::new()));
+            let mut gain = soundcraft_dsp::create("gain").unwrap();
+            gain.prepare(48_000.0, block, 2);
+            let probe = PreparedInstance { track, slot: 0, id: "clap:test.probe".into(), ch: 2, plugin: Box::new(Probe(gain, Arc::clone(&seen))) };
+            tx.send(Cmd::Session(session, vec![probe])).unwrap();
+            tx.send(Cmd::Play { from: 0, end: None, looped: None }).unwrap();
+            assert_eq!(st.mix.max_block(), block, "the engine is sized for the block");
+            let mut out = vec![0.0f32; 2 * callback];
+            st.fill(&mut out, 2);
+            assert_eq!(*seen.lock().unwrap(), want, "block {block}, callback {callback}, automation {automated}");
+            assert!(out.iter().any(|x| x.abs() > 0.1), "block {block}, callback {callback}: it sounds");
+        }
+    }
+
+    #[test]
+    fn gaining_automation_drops_the_long_block_not_yet_heard() {
+        use soundcraft_model::ChannelFormat;
+        // A ramp tells which session frame each output frame came from.
+        let ramp: Vec<f32> = (0..8192).map(|i| i as f32 / 8192.0).collect();
+        let st = audio_output_state(ChannelFormat::Stereo, vec![ramp.clone(), ramp.clone()]);
+        let (tx, rx) = channel();
+        let mut st = AudioState::with_block(rx, Arc::clone(&st.shared), Arc::clone(&st.session), 48_000.0, channel().0, 4096);
+        tx.send(Cmd::Play { from: 0, end: None, looped: None }).unwrap();
+        st.drain();
+        // One long block rendered, 1000 of its frames heard so far.
+        st.render_next(4096);
+        assert_eq!((st.pos, st.pending_len), (4096, 4096));
+        st.read = 1000.0;
+        let mut session = (*st.session).clone();
+        let track = session.tracks[0].id;
+        let mut lane = soundcraft_model::AutomationLane::new(soundcraft_model::AutoParam::Volume);
+        lane.set_point(0, 0.0);
+        session.track_mut(track).unwrap().automation.push(lane);
+        tx.send(Cmd::Session(Arc::new(session), Vec::new())).unwrap();
+        let mut out = vec![0.0f32; 2 * 600];
+        st.fill(&mut out, 2);
+        // The rest of the long block was dropped and rendered again in 512-frame blocks from the
+        // audible position: frame 1000 is next, nothing skipped or repeated.
+        assert_eq!(st.pos, 1000 + 2 * 512, "two fresh 512-frame blocks");
+        assert_eq!(st.pending_len, 512);
+        for (k, f) in out.chunks(2).enumerate() {
+            let want = ramp[1000 + k];
+            assert!((f[0] - want).abs() < 1e-6 && (f[1] - want).abs() < 1e-6, "frame {k}: {f:?}, want {want}");
+        }
+        // A session that already had automation keeps what is pending.
+        let (len, pos) = (st.pending_len, st.pos);
+        tx.send(Cmd::Session(Arc::clone(&st.session), Vec::new())).unwrap();
+        st.drain();
+        assert_eq!((st.pending_len, st.pos), (len, pos));
+    }
+
     #[test]
     fn mono_device_averages_stereo_before_clipping() {
         use soundcraft_model::ChannelFormat;
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
@@ -543,7 +543,14 @@ pub fn playback_engine(app: &mut SoundApp, ctx: &egui::Context) {
             ui.label(format!("{sr} Hz{note}"));
             ui.end_row();
         });
-        ui.label(egui::RichText::new("The mix runs in 512-sample blocks; the buffer size sets the device's own latency.").small().color(t.text_dim));
+        let blocks = app.player.as_ref().map_or(512, |pl| pl.block);
+        ui.label(
+            egui::RichText::new(format!(
+                "The mix renders each callback in blocks of up to {blocks} samples (512 while automation plays); the buffer size sets the device's latency."
+            ))
+            .small()
+            .color(t.text_dim),
+        );
         let mut dc = app.engine.session().edit.delay_compensation;
         if crate::widgets::checkbox(ui, &mut dc, "Delay compensation").changed() {
             let _ = app.run("options.delay_compensation", json!({"value": dc}));
@@ -596,7 +603,7 @@ pub fn audio_device_command(app: &mut SoundApp, p: &serde_json::Value) -> Result
         app.apply_audio_config(cfg)?;
     }
     let device = app.player.as_ref().map(|pl| {
-        json!({"name": pl.device_name, "sample_rate": pl.device_rate, "channels": pl.device_channels, "buffer_size": pl.device_buffer, "silent": pl.silent})
+        json!({"name": pl.device_name, "sample_rate": pl.device_rate, "channels": pl.device_channels, "buffer_size": pl.device_buffer, "block": pl.block, "silent": pl.silent})
     });
     let mut out = json!({"config": app.ui.audio, "device": device});
     if p.get("list").and_then(Value::as_bool).unwrap_or(false) {
diff --git a/docs/architecture.md b/docs/architecture.md
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -1,6 +1,6 @@
 # Architecture
 
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (delay compensation includes the master faders' inserts (#351); previously minor: bounces stream to disk) · **Target:** Avid Pro Tools Ultimate 2026.4.1
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (playback blocks follow the device buffer, up to 8192 samples; previously trivial: delay compensation includes the master faders' inserts (#351); previously minor: bounces stream to disk) · **Target:** Avid Pro Tools Ultimate 2026.4.1
 
 SoundCraft is a Cargo workspace of small crates with enforced layering (`cargo xtask layers`).
 Nothing below the UI knows about egui, so the interface can be replaced.
@@ -53,7 +53,8 @@ bounces offline (`RangeRender`, streamed to the file block by block) and realtim
 snapshots through a channel).
 
 Device I/O today is the system's *default* output and *default* input device, opened as two
-separate cpal streams; the mix renders 512-sample blocks and resamples to the device rate when
+separate cpal streams; the mix renders each device callback in one block of up to 8192 samples (512 while automation
+plays, since automation is read once per block) and resamples to the device rate when
 they differ. There is no device or buffer-size selection, no ASIO, and no MIDI device layer yet
 (see [`hardware-parity.md`](hardware-parity.md)). Sources are decoded whole into the `SourcePool`
 and recordings are captured in memory until stop.
@@ -78,6 +79,7 @@ app as an MCP server. See `control-protocol.md` and `mcp.md`.
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-24 | minor | Playback blocks follow the device buffer, up to 8192 samples |
 | 2026-10-13 | trivial | delay compensation includes the master faders' inserts (#351) |
 | 2026-10-10 | minor | Added video, vst3-host and au-host to the layer diagram; documented device I/O limits and in-memory media |
 | 2026-10-05 | major | First architecture overview |
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-19 · **Change:** trivial (G-04: Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1
+> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-24 · **Change:** minor (G-01: playback blocks follow the device buffer; automation still per block; previously trivial: G-04: Float32 file headroom) · **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**
@@ -49,9 +49,10 @@ When you close a gap, delete its entry (or shrink it) in the same PR, and bump t
 - **Missing:** routing to physical channels (I/O Setup), one duplex stream on one clock (input and
   output are still separate cpal streams), latency reporting and record-offset compensation, ASIO
   in official builds (owner decision: Steinberg's SDK is proprietary or GPLv3), JACK, and
-  verification on macOS and Linux. The mix still runs in fixed 512-sample blocks.
+  verification on macOS and Linux. Playback blocks follow the device buffer (up to 8192 samples), but
+  automation is read once per block, so sessions with automation keep 512-sample blocks.
   `setup.playback_engine` still stores `device`/`buffer_size` in the session, unused.
-- **Evidence:** code above; Playback Engine window says "Mix block size: 512 samples"; issue #42
+- **Evidence:** code above; the Playback Engine window shows the block size; issue #42
   (no input device selection, forced monitoring feedback with virtual routing).
 - **Impact:** blocks tracking with any multichannel interface; latency makes overdubs hard.
 - **Estimate:** ≈ 25–40 h (device enumeration and selection, buffer size, duplex stream with one
@@ -221,6 +222,7 @@ missing. Out of scope unless the owner decides otherwise.
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-24 | minor | G-01: playback blocks follow the device buffer; automation still per block |
 | 2026-10-19 | trivial | G-04: Float32 encoding retains finite headroom (#791); integer clipping and non-finite handling retained |
 | 2026-10-18 | trivial | G-04: mono device stereo/surround fold-down fixed and regression-tested; estimates unchanged |
 | 2026-10-16 | trivial | G-04: master volume automation block boundaries fixed and regression-tested; estimates unchanged |
diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md
--- a/docs/hardware-parity.md
+++ b/docs/hardware-parity.md
@@ -1,6 +1,6 @@
 # Hardware parity
 
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (bounces of a bus fed by an aux compensate its latency (#246); previously minor: audio device, rate and buffer choice; ASIO feature) · **Target:** Avid Pro Tools Ultimate 2026.4.1
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (playback mixes in blocks of the device buffer, up to 8192; previously trivial: bounces of a bus fed by an aux compensate its latency (#246); previously minor: audio device, rate and buffer choice; ASIO feature) · **Target:** Avid Pro Tools Ultimate 2026.4.1
 
 A DAW is judged by how it drives audio interfaces, MIDI gear, control surfaces and sync. This is
 SoundCraft's weakest dimension. Ours is measured from `crates/playback` (cpal) and the absence of
@@ -18,7 +18,7 @@ Legend: ✅ works · 🟡 partial · ❌ missing.
 | Driver API | Core Audio / ASIO | 🟡 Core Audio via cpal | 🟡 WASAPI shared via cpal; ASIO with the opt-in `asio` build feature (verified with an SSL 2+) | 🟡 ALSA/Pulse via cpal; no JACK | 🟡 cpal (ALSA layer) | 🟡 Web Audio |
 | Choose playback device | ✅ Playback Engine | 🟡 Setup › Playback Engine (not yet verified on macOS) | ✅ Setup › Playback Engine (WASAPI, ASIO) | 🟡 (not yet verified) | 🟡 (not yet verified) | ❌ |
 | Choose input device | ✅ | 🟡 Setup › Playback Engine (not yet verified on macOS) | ✅ (WASAPI, ASIO) | 🟡 (not yet verified) | ❌ no recording | ❌ |
-| Buffer size (32–2048) | ✅ | 🟡 device buffer when the driver allows it (not yet verified); mix in 512-sample blocks | 🟡 device buffer 16–8192 (verified 128 on ASIO, 256 on WASAPI); mix in 512-sample blocks | 🟡 (not yet verified) | 🟡 | ❌ |
+| Buffer size (32–2048) | ✅ | 🟡 device buffer when the driver allows it (not yet verified); mix in blocks of the device buffer (512–8192; 512 while automation plays) | 🟡 device buffer 16–8192 (verified 128 on ASIO, 256 on WASAPI); mix in blocks of the device buffer on ASIO (512–8192; 512 while automation plays); WASAPI shared calls back at its own period (about 10 ms) whatever the buffer | 🟡 (not yet verified) | 🟡 | ❌ |
 | Duplex stream on one clock | ✅ | ❌ separate input/output streams | ❌ | ❌ | ❌ | ❌ |
 | Hardware I/O mapping (I/O Setup to physical channels) | ✅ | 🟡 multichannel output in order; no per-channel map | 🟡 | 🟡 | 🟡 | ❌ |
 | Low-latency monitoring | ✅ | ❌ (`options.low_latency_monitoring` is a stored flag) | ❌ | ❌ | ❌ | ❌ |
@@ -72,6 +72,7 @@ Legend: ✅ works · 🟡 partial · ❌ missing.
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-24 | minor | Playback renders each device callback in one block of up to 8192 frames (was split into 512-sample blocks), and plugins are prepared for that size; sessions with automation keep 512-sample blocks; a buffer change reopens the stream and prepares them anew |
 | 2026-10-13 | trivial | bounces of a bus fed by an aux compensate its latency (#246) |
 | 2026-10-12 | minor | Audio device settings: system, output, input, sample rate and buffer size; opt-in ASIO |
 | 2026-10-10 | major | Created: audit of audio interface, MIDI, control-surface, sync and performance support against Pro Tools 2026.4.1 |
diff --git a/docs/roadmap.md b/docs/roadmap.md
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -1,6 +1,6 @@
 # SoundCraft roadmap: milestones and what's next
 
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (M8 batch limit validation) · **Target:** Avid Pro Tools Ultimate 2026.4.1
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (playback blocks follow the device buffer; previously trivial: M8 batch limit validation) · **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).
@@ -23,7 +23,7 @@ main platform). Any **no** keeps SoundCraft **pre-alpha**, and those rows are th
 
 | Workflow | Works end to end? | Evidence | Hours to pass |
 |---|---|---|---:|
-| Record audio from a chosen interface at a chosen buffer size, monitor, punch, keep the takes | **no** | Default output and input devices only. Fixed 512-sample block. Separate input and output streams. `setup.playback_engine` params ignored by playback. Takes held in RAM until stop (PR #84). No input device choice (#42). | 35–55 |
+| Record audio from a chosen interface at a chosen buffer size, monitor, punch, keep the takes | **no** | Default output and input devices only. Blocks follow the device buffer up to 8192 samples (512 while automation plays). Separate input and output streams. `setup.playback_engine` params ignored by playback. Takes held in RAM until stop (PR #84). No input device choice (#42). | 35–55 |
 | Record MIDI from a controller and play it back through an instrument | **no** | Playback of drawn and imported MIDI works. No MIDI device layer at all, so nothing can be recorded and instruments can't be played live (G-02). | 20–35 |
 | Edit: select, trim, fade, comp playlists, Slip/Shuffle/Grid | yes (rough) | Every Edit menu command; property-tested with undo; open bugs #3, #5, #140 don't block the workflow | — |
 | Mix with built-in and third-party plugins, sends, busses, automation | partial (passes on CLAP/AU) | 26 built-in processors; CLAP and AU host on the signed macOS build, but its missing entitlement blocks third-party VST3 (#153). No AU plugin windows yet; VST3 windows on macOS only | 1–2 (entitlement) |
@@ -89,6 +89,7 @@ There is no `cargo xtask scorecard` in this repo; the measured number is `cargo
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-24 | minor | Playback blocks follow the device buffer |
 | 2026-10-14 | trivial | M8: reject oversized MCP batches before execution (#460) |
 | 2026-10-10 | minor | Added the alpha gate (3 of 6 core workflows fail → pre-alpha) |
 | 2026-10-10 | major | Created from ROADMAP.md's milestones and alpha list; added M12–M17 and the beta plan with estimates |
