diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000000000000000000000000000000000000..048ecad98fef115b886f8864403a1df8bd06f60b
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,7 @@
+# .dockerignore
+__pycache__
+*.pyc
+*.pyo
+*.pyd
+.env
+venv/
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 322defa6fdc9b68312bed01446fe11bf4dece7c8..80cfd07ca36787690e95c744a0bc70c261bea306 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,6 +6,7 @@
 /doc/build/**
 /doc/source/_autogen/**
 /dist/
+/.run/
 .pytest_cache
 pytest_report.xml
 .coverage
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..6b22ec9fc033604f37bc20d7ca486029205bd3be
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,34 @@
+FROM ghcr.io/astral-sh/uv:python3.9-bookworm
+
+# Set environment variables to prevent Python from writing .pyc files and buffering stdout/stderr
+ENV PYTHONDONTWRITEBYTECODE=1
+ENV PYTHONUNBUFFERED=1
+# To mimmick CI for tests
+ENV GITLAB_CI=True
+
+RUN apt-get update && \
+    apt-get install -y --no-install-recommends \
+        portaudio19-dev \ 
+        neovim \
+        && rm -rf /var/lib/apt/lists/*
+
+RUN uv pip install --system hatch
+
+WORKDIR /home/root/
+
+RUN git clone -b master --single-branch https://git.rwth-aachen.de/qutech/qutil.git qutil
+
+WORKDIR qutil
+
+RUN uv pip install --system  -e .[complete]
+
+WORKDIR /home/root/
+
+RUN git clone -b main --single-branch https://git.rwth-aachen.de/qutech/python-spectrometer.git pyspeck
+
+WORKDIR pyspeck
+
+RUN uv pip install --system -e .[complete]
+
+# Set the entry point to run pytest
+CMD ["pytest --doctest-modules"]
diff --git a/README.md b/README.md
index 28f89c445552e7001103b5ae45b9e735b6d29cbf..ed044f5608cdbd999b1b773a1431b7f00d7706f8 100644
--- a/README.md
+++ b/README.md
@@ -56,7 +56,7 @@ If you have already downloaded/cloned the package yourself you can use `python -
 Please file an issue if any of these instructions does not work.
 
 ## Documentation
-Some of the development of this package took place during a course taught at the II. Institute of Physics at RWTH Aachen University in the winter semester 2022/23. Targeting applied research topics too specific for lectures but too general for lab courses, several modules intended for self-learning were developed, one of which focuses on "characterizing and avoiding noise and interference in instrumentation". The material can be found here: 
+Some of the development of this package took place during a course taught at the II. Institute of Physics at RWTH Aachen University in the winter semester 2022/23. Targeting applied research topics too specific for lectures but too general for lab courses, several modules intended for self-learning were developed, one of which focuses on "characterizing and avoiding noise and interference in instrumentation". The material can be found here:
 - [Part 1](https://iffmd.fz-juelich.de/s/6sxq2OgNO),
 - [Part 2](https://iffmd.fz-juelich.de/s/7LQ7xCCNJ).
 
@@ -74,7 +74,7 @@ Make sure the dependencies are installed via
 ```sh
 python -m pip install -e .[doc]
 ```
-in the top-level directory. 
+in the top-level directory.
 
 To check if everything works for a clean install (requires hatch to be installed), run
 ```sh
@@ -93,5 +93,44 @@ or to check if everything works for a clean install (requires hatch to be instal
 python -m hatch run tests:run
 ```
 
+
+### Docker
+
+0. Make sure docker is installed and running:
+    1. `pamac install docker docker-buildx`
+    2. `(sudo) docker buildx install`
+    2. `(sudo) systemctl status docker`
+
+        Example output:
+
+   		```sh
+	  	● docker.service - Docker Application Container Engine
+	         Loaded: loaded (/usr/lib/systemd/system/docker.service; disabled; preset: disabled)
+	         Active: active (running) since Tue 2025-02-11 20:09:55 CET; 1 week 1 day ago
+	     Invocation: 609d5a409daf4e99b7b3b8da9305776d
+	    TriggeredBy: ● docker.socket
+	           Docs: https://docs.docker.com
+	       Main PID: 54128 (dockerd)
+              Tasks: 22
+	         Memory: 38.8M (peak: 1.2G, swap: 21.3M, swap peak: 23.9M)
+	            CPU: 1min 2.133s
+             CGroup: /system.slice/docker.service
+	                 └─54128 /usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock
+  ```
+
+1. Build the docker image:
+   ```sh
+   (sudo) docker build -t pyspeck-dev .
+   ```
+2. Run the image...
+   1. ... either running the tests and exiting:
+      ```sh
+	  (sudo) docker run --rm pyspeck-dev
+	  ```
+   2. ... or entering an interactive console:
+      ```sh
+      (sudo) docker run --rm -it pyspeck-dev /bin/bash
+      ```
+
 ## Releases
 Releases on Gitlab, PyPI, and Zenodo are automatically created and pushed whenever a commit is tagged matching [CalVer](https://calver.org/) in the form `vYYYY.MM.MICRO` or `vYYYY.0M.MICRO`.
diff --git a/pyproject.toml b/pyproject.toml
index bc4f6d44f7d7915e857b71f5ed055e43a3956953..e3175e4630989293048a6d51d3cd47b59f880801 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -24,6 +24,7 @@ classifiers = [
     "Programming Language :: Python :: 3.10",
     "Programming Language :: Python :: 3.11",
     "Programming Language :: Python :: 3.12",
+    "Programming Language :: Python :: 3.13",
     "Topic :: Scientific/Engineering :: Physics",
     "Topic :: Utilities",
 ]
@@ -104,5 +105,7 @@ max-line-length = 99
 addopts = "-ra"
 filterwarnings = [
     "ignore:simanneal:UserWarning",
-    "ignore:Qutip:UserWarning"
+    "ignore:Qutip:UserWarning",
+    # plt.pause() in headless mode
+    'ignore:FigureCanvasAgg is non-interactive:UserWarning'
 ]
diff --git a/src/python_spectrometer/__init__.py b/src/python_spectrometer/__init__.py
index 5ae0c9f3624103c5088d637a1b72dd20c5074fd6..a6319b875357cefede1bf2eecefedc201e98eadc 100644
--- a/src/python_spectrometer/__init__.py
+++ b/src/python_spectrometer/__init__.py
@@ -115,7 +115,8 @@ In this short demonstration, we reproduce the example from
 ...            yield x + self.rng.normal(scale=np.sqrt(noise_power),
 ...                                      size=time.shape)
 >>> spect = Spectrometer(MyDAQ(), savepath=tempfile.mkdtemp(),
-...                      plot_cumulative=True, plot_amplitude=False)
+...                      plot_cumulative=True, plot_amplitude=False,
+...                      threaded_acquisition=False)
 >>> spect.take('2 Vrms', fs=10e3, n_pts=1e5, nperseg=1024,
 ...            amp=2*np.sqrt(2))
 
diff --git a/src/python_spectrometer/_audio_manager.py b/src/python_spectrometer/_audio_manager.py
index a1d25bd5608bc6d33e58dbbca70e1e2169e7b2dc..144c92cc9f6562b9153556b0ebce89ee37df93a9 100644
--- a/src/python_spectrometer/_audio_manager.py
+++ b/src/python_spectrometer/_audio_manager.py
@@ -1,5 +1,5 @@
 """ This module contains methods for using auditory channels to interface to humans """
-
+import os
 import queue
 import threading
 import time
@@ -20,10 +20,16 @@ def _waveform_playback_target(waveform_queue: queue.Queue, stop_flag: threading.
 
     pyaudio_instance = pyaudio.PyAudio()
 
-    stream = pyaudio_instance.open(format=pyaudio.paFloat32,
-                                   channels=1,
-                                   rate=bitrate,
-                                   output=True)
+    try:
+        stream = pyaudio_instance.open(format=pyaudio.paFloat32,
+                                       channels=1,
+                                       rate=bitrate,
+                                       output=True)
+    except OSError:
+        if os.environ.get('GITLAB_CI', 'false').lower() == 'true':
+            return pyaudio_instance.terminate()
+        else:
+            raise
 
     last_waveform = None
     repeats = 0
diff --git a/src/python_spectrometer/_plot_manager.py b/src/python_spectrometer/_plot_manager.py
index d9ed82f8f97db8a13a1e1154a26204709283156d..24030f2eb792fc8b7b98f76f676a3f571a48ae6d 100644
--- a/src/python_spectrometer/_plot_manager.py
+++ b/src/python_spectrometer/_plot_manager.py
@@ -2,17 +2,16 @@
 import contextlib
 import os
 import warnings
-from functools import cached_property
+import weakref
 from itertools import compress
-from typing import (Dict, Any, Optional, Mapping, Tuple, ContextManager, Literal, Iterable,
-                    Union, List)
+from typing import (Dict, Any, Optional, Mapping, Tuple, ContextManager, Iterable, Union, List,
+                    Literal)
 
 import matplotlib.pyplot as plt
 import numpy as np
 from matplotlib import gridspec, scale
 from qutil.misc import filter_warnings
 from qutil.plotting import assert_interactive_figure
-from qutil.typecheck import check_literals
 from scipy import integrate, signal
 
 _keyT = Union[int, str, Tuple[int, str]]
@@ -21,16 +20,19 @@ _styleT = Union[None, _styleT, List[_styleT]]
 
 
 class PlotManager:
+    __instances = weakref.WeakSet()
+
     # TODO: blit?
     PLOT_TYPES = ('main', 'cumulative', 'time')
     LINE_TYPES = ('processed', 'raw')
+    TIMER_INTERVAL: int = 20  # ms
 
     def __init__(self, data: Dict[_keyT, Any], plot_raw: bool = False,
                  plot_timetrace: bool = False, plot_cumulative: bool = False,
                  plot_negative_frequencies: bool = True, plot_absolute_frequencies: bool = True,
                  plot_amplitude: bool = True, plot_density: bool = True,
                  plot_cumulative_normalized: bool = False, plot_style: _styleT = 'fast',
-                 plot_update_mode: str = 'never', plot_dB_scale: bool = False, prop_cycle=None,
+                 plot_dB_scale: bool = False, threaded_acquisition: bool = True, prop_cycle=None,
                  raw_unit: str = 'V', processed_unit: Optional[str] = None,
                  uses_windowed_estimator: bool = True, complex_data: Optional[bool] = False,
                  figure_kw: Optional[Mapping] = None, subplot_kw: Optional[Mapping] = None,
@@ -48,8 +50,8 @@ class PlotManager:
         self._plot_density = plot_density
         self._plot_cumulative_normalized = plot_cumulative_normalized
         self._plot_style = plot_style
-        self._plot_update_mode = plot_update_mode
         self._plot_dB_scale = plot_dB_scale
+        self._threaded_acquisition = threaded_acquisition
         self._processed_unit = processed_unit if processed_unit is not None else raw_unit
 
         # For dB scale plots, default to the first spectrum acquired.
@@ -60,7 +62,9 @@ class PlotManager:
         self.uses_windowed_estimator = uses_windowed_estimator
         self._complex_data = complex_data
 
+        self._fig = None
         self._leg = None
+        self._timer = None
         self.axes = {key: dict.fromkeys(self.LINE_TYPES) for key in self.PLOT_TYPES}
         self.lines = dict()
         self.figure_kw = figure_kw or dict()
@@ -68,29 +72,69 @@ class PlotManager:
         self.gridspec_kw = gridspec_kw or dict()
         self.legend_kw = legend_kw or dict()
 
+        self.legend_kw.setdefault('loc', 'upper right')
+        self.figure_kw.setdefault('num', f'Spectrometer {len(self.__instances) + 1}')
         if not any('layout' in key for key in self.figure_kw.keys()):
-            self.figure_kw['layout'] = 'tight'
-        if self.subplot_kw.pop('sharex', None) is False:
+            # depending on the matplotlib version, this setting is either
+            # layout='tight' or tight_layout=True
+            self.figure_kw.setdefault('layout', 'tight')
+        if self.subplot_kw.pop('sharex', None) is not None:
             warnings.warn('sharex in subplot_kw not negotiable, dropping', UserWarning)
 
-    @cached_property
+        # Keep track of instances that are alive for figure counting
+        self.__instances.add(self)
+        # TODO: this somehow never executes
+        weakref.finalize(self, self.__instances.discard, self)
+
+    def is_fig_open(self) -> bool:
+        """Is the figure currently open, pending all events?"""
+        # Need to flush possible close events before we can be sure!
+        if self._fig is not None:
+            self._fig.canvas.flush_events()
+        return self._fig is not None and plt.fignum_exists(self.figure_kw['num'])
+
+    @property
     def fig(self):
         """The figure hosting the plots."""
+        if self.is_fig_open():
+            return self._fig
+
         try:
-            fig = plt.figure(**self.figure_kw)
+            self._fig = plt.figure(**self.figure_kw)
         except TypeError:
             if layout := self.figure_kw.pop('layout', False):
                 # matplotlib < 3.5 doesn't support layout kwarg yet
                 self.figure_kw[f'{layout}_layout'] = True
             elif layout is False:
                 raise
-            fig = plt.figure(**self.figure_kw)
+            self._fig = plt.figure(**self.figure_kw)
+
+        assert_interactive_figure(self._fig)
+
+        def on_close(event):
+            self._fig = None
+
+            if self._timer is not None:
+                self._timer.stop()
+                self._timer = None
+
+            # Clean up possible leftovers from before.
+            self.destroy_axes()
+            self.update_line_attrs(self.plots_to_draw, self.lines_to_draw, self.shown, stale=True)
+
+        # If the window is closed, remove the figure from the cache so that it can be recreated and
+        # stop the timer to delete any remaining callbacks
+        self._fig.canvas.mpl_connect('close_event', on_close)
 
-        # If the window is closed, remove the figure from the cache so that it can be recreated
-        fig.canvas.mpl_connect('close_event', lambda _: self.__dict__.pop('fig', None))
+        self.setup_figure()
+        return self._fig
 
-        assert_interactive_figure(fig)
-        return fig
+    @property
+    def timer(self):
+        """A timer object associated with the figure."""
+        if self._timer is None:
+            self._timer = self.fig.canvas.new_timer(self.TIMER_INTERVAL)
+        return self._timer
 
     @property
     def ax(self):
@@ -112,7 +156,7 @@ class PlotManager:
     @property
     def shown(self) -> Tuple[Tuple[int, str], ...]:
         return tuple(key for key, val in self.lines.items()
-                     if not val['main']['processed']['hidden'])
+                     if val['main']['processed']['hidden'] is False)
 
     @property
     def lines_to_draw(self) -> Tuple[str, ...]:
@@ -140,7 +184,8 @@ class PlotManager:
         if val != self._plot_raw:
             self._plot_raw = val
             self.update_line_attrs(self.plots_to_draw, ['raw'], stale=True, hidden=not val)
-            if 'fig' in self.__dict__:
+            if self.is_fig_open():
+                # Only update the figure if it's already been created
                 self.setup_figure()
 
     @property
@@ -154,7 +199,7 @@ class PlotManager:
         if val != self._plot_cumulative:
             self._plot_cumulative = val
             self.update_line_attrs(['cumulative'], self.lines_to_draw, stale=True, hidden=not val)
-            if 'fig' in self.__dict__:
+            if self.is_fig_open():
                 self.setup_figure()
 
     @property
@@ -170,7 +215,7 @@ class PlotManager:
         if val != self._plot_timetrace:
             self._plot_timetrace = val
             self.update_line_attrs(['time'], self.lines_to_draw, stale=True, hidden=not val)
-            if 'fig' in self.__dict__:
+            if self.is_fig_open():
                 self.setup_figure()
 
     @property
@@ -184,7 +229,7 @@ class PlotManager:
         if val != self._plot_negative_frequencies:
             self._plot_negative_frequencies = val
             self.update_line_attrs(['main', 'cumulative'], self.lines_to_draw, stale=True)
-            if 'fig' in self.__dict__:
+            if self.is_fig_open():
                 self.setup_figure()
 
     @property
@@ -206,7 +251,7 @@ class PlotManager:
                 keys=[key for key in self.shown if 'freq' in self._data[key]['settings']],
                 stale=True
             )
-            if 'fig' in self.__dict__:
+            if self.is_fig_open():
                 self.setup_figure()
 
     @property
@@ -225,7 +270,7 @@ class PlotManager:
         if val != self._plot_amplitude:
             self._plot_amplitude = val
             self.update_line_attrs(['main', 'cumulative'], self.lines_to_draw, stale=True)
-            if 'fig' in self.__dict__:
+            if self.is_fig_open():
                 self.setup_figure()
 
     @property
@@ -239,7 +284,7 @@ class PlotManager:
         if val != self._plot_density:
             self._plot_density = val
             self.update_line_attrs(['main', 'cumulative'], self.lines_to_draw, stale=True)
-            if 'fig' in self.__dict__:
+            if self.is_fig_open():
                 self.setup_figure()
 
     @property
@@ -253,7 +298,7 @@ class PlotManager:
         if val != self._plot_cumulative_normalized:
             self._plot_cumulative_normalized = val
             self.update_line_attrs(['cumulative'], self.lines_to_draw, stale=True)
-            if 'fig' in self.__dict__:
+            if self.is_fig_open():
                 self.setup_figure()
 
     @property
@@ -271,20 +316,9 @@ class PlotManager:
             self._plot_style = val
             self.destroy_axes()
             self.update_line_attrs(self.plots_to_draw, self.lines_to_draw, stale=True)
-            if 'fig' in self.__dict__:
+            if self.is_fig_open():
                 self.setup_figure()
 
-    @property
-    def plot_update_mode(self) -> str:
-        """Setting influencing how often the matplotlib event queue is
-        flushed."""
-        return self._plot_update_mode
-
-    @plot_update_mode.setter
-    @check_literals
-    def plot_update_mode(self, mode: Literal['fast', 'always', 'never']):
-        self._plot_update_mode = mode
-
     @property
     def plot_dB_scale(self) -> bool:
         """Plot data as dB relative to a reference spectrum.
@@ -298,9 +332,20 @@ class PlotManager:
         if val != self._plot_dB_scale:
             self._plot_dB_scale = val
             self.update_line_attrs(['main', 'cumulative'], self.lines_to_draw, stale=True)
-            if 'fig' in self.__dict__:
+            if self.is_fig_open():
                 self.setup_figure()
 
+    @property
+    def threaded_acquisition(self) -> bool:
+        """Acquire data in a separate thread."""
+        return self._threaded_acquisition
+
+    @threaded_acquisition.setter
+    def threaded_acquisition(self, val: bool):
+        val = bool(val)
+        if val != self._threaded_acquisition:
+            self._threaded_acquisition = val
+
     @property
     def reference_spectrum(self) -> Optional[Tuple[int, str]]:
         """Spectrum taken as a reference for the dB scale.
@@ -310,6 +355,10 @@ class PlotManager:
             return list(self._data)[0]
         return self._reference_spectrum
 
+    @reference_spectrum.setter
+    def reference_spectrum(self, val: Tuple[int, str]):
+        self._reference_spectrum = val
+
     @property
     def processed_unit(self) -> str:
         """The unit displayed for processed data."""
@@ -320,7 +369,7 @@ class PlotManager:
         val = str(val)
         if val != self._processed_unit:
             self._processed_unit = val
-            if 'fig' in self.__dict__:
+            if self.is_fig_open():
                 self.setup_figure()
 
     @property
@@ -508,16 +557,8 @@ class PlotManager:
                         continue
 
     def update_figure(self):
-        if 'fig' not in self.__dict__:
-            # Need to completely create/restore figure
-            self.__dict__.pop('fig', None)
-            self.destroy_axes()
-            self.update_line_attrs(self.plots_to_draw, self.lines_to_draw, self.shown, stale=True)
-            self.setup_figure()
-
-        # Flush out all idle events
-        if self.plot_update_mode in {'always'}:
-            self.fig.canvas.flush_events()
+        # Flush out all idle events, necessary for some reason in sequential mode
+        self.fig.canvas.flush_events()
 
         # First set new axis scales and x-limits, then update the lines (since the cumulative
         # spectrum plot changes dynamically with the limits). Once all lines are drawn, update
@@ -538,10 +579,8 @@ class PlotManager:
             if self._leg is not None:
                 self._leg.remove()
 
-        # Needed to force update during a loop for instance
         self.fig.canvas.draw_idle()
-        if self.plot_update_mode in {'always', 'fast'}:
-            self.fig.canvas.flush_events()
+        self.fig.canvas.flush_events()
 
     def update_lines(self):
         for key in self.shown:
@@ -591,7 +630,7 @@ class PlotManager:
                 for k in self.shown
             ), default=None)
 
-        with filter_warnings('ignore', UserWarning):
+        with filter_warnings(action='ignore', category=UserWarning):
             # ignore warnings issued for empty plots with log scales
             self.axes['main']['processed'].set_xlim(left, right)
 
diff --git a/src/python_spectrometer/core.py b/src/python_spectrometer/core.py
index 5a599aa40c089dc075a8a41ce9a071c12b47dec7..ec130ba01dfdf244528339bc277db7bfecc0838d 100644
--- a/src/python_spectrometer/core.py
+++ b/src/python_spectrometer/core.py
@@ -7,7 +7,7 @@ import warnings
 from datetime import datetime
 from pathlib import Path
 from pprint import pprint
-from queue import Queue, LifoQueue
+from queue import Queue, Empty, LifoQueue
 from threading import Thread, Event
 from typing import (Any, Callable, Dict, Generator, Iterator, List, Literal, Mapping, Optional,
                     Sequence, Tuple, Union, cast)
@@ -19,9 +19,10 @@ from matplotlib import colors
 from matplotlib.axes import Axes
 from matplotlib.figure import Figure
 from matplotlib.legend import Legend
-from qutil import io
+from qutil import io, misc
 from qutil.functools import cached_property, chain, partial
 from qutil.itertools import count
+from qutil.plotting import is_using_mpl_gui_backend
 from qutil.plotting import live_view
 from qutil.signal_processing.real_space import Id, welch
 from qutil.typecheck import check_literals
@@ -237,14 +238,14 @@ class Spectrometer:
     >>> def spectrum(f, A=1e-4, exp=1.5, **_):
     ...     return A/f**exp
     >>> daq = QoptColoredNoise(spectrum)
-    >>> spect = Spectrometer(daq, savepath=mkdtemp())
+    >>> spect = Spectrometer(daq, savepath=mkdtemp(), threaded_acquisition=False)
     >>> spect.take('a comment', f_max=2000, A=2e-4)
     >>> spect.print_keys()
-    (0, 'a comment')
+     - (0, 'a comment')
     >>> spect.take('more comments', df=0.1, f_max=2000)
     >>> spect.print_keys()
-    (0, 'a comment')
-    (1, 'more comments')
+     - (0, 'a comment')
+     - (1, 'more comments')
 
     Hide and show functionality:
 
@@ -259,7 +260,7 @@ class Spectrometer:
     ...     spect.savepath / 'foo', daq
     ... )
     >>> spect_loaded.print_keys()
-    (0, 'a comment')
+     - (0, 'a comment')
     >>> spect.print_settings('a comment')
     Settings for key (0, 'a comment'):
     {'A': 0.0002,
@@ -273,10 +274,36 @@ class Spectrometer:
      'noverlap': 2000,
      'nperseg': 4000}
 
+    Use threaded acquisition to avoid blocking the interpreter and keep
+    the figure responsive:
+
+    >>> spect.threaded_acquisition = True
+    >>> spect.take(n_avg=5, delay=True, progress=False)
+
+    When the spectrometer is still acquiring, starting a new acquisition
+    errors.
+
+    >>> spect.take()
+    Traceback (most recent call last):
+        ...
+    RuntimeError: Spectrometer is currently acquiring.
+
+    To check the acquisition status, use either the :attr:`acquiring`
+    attribute or block until ready (defeats the purpose though).
+
+    >>> spect.block_until_ready()
+    >>> spect.take(n_avg=5, delay=True, progress=False)
+    >>> spect.acquiring
+    True
+    >>> spect.block_until_ready()
+    >>> spect.acquiring
+    False
+
     Use the audio interface to listen to the noise:
 
-    >>> spect_with_audio = Spectrometer(daq, savepath=mkdtemp(), play_sound=True)
-    >>> spect_with_audio.take('a comment', f_max=20000, A=2e-4)  # doctest: +SKIP
+    >>> spect_with_audio = Spectrometer(daq, savepath=mkdtemp(), play_sound=True,
+    ...                                 threaded_acquisition=False)
+    >>> spect_with_audio.take('a comment', f_max=20000, A=2e-4)
     >>> spect_with_audio.audio_stream.stop()
 
     Instead of taking spectra one-by-one, it is also possible to
@@ -300,6 +327,8 @@ class Spectrometer:
     If the :attr:`plot_timetrace` flag is set, another live view for the
     real-time data is instanatiated:
 
+    >>> import matplotlib.pyplot as plt
+    >>> plt.pause(1e-3)  # for doctest
     >>> view.stop()
     >>> speck.plot_timetrace = True
     >>> freq_view, time_view = speck.live_view()
@@ -308,6 +337,7 @@ class Spectrometer:
     :meth:`~qutil:qutil.plotting.live_view.LiveViewBase.stop` or simply
     by closing the figure.
 
+    >>> plt.pause(1e-3)  # for doctest
     >>> freq_view.stop()
 
     Interrupting one live view will also kill the other.
@@ -331,6 +361,8 @@ class Spectrometer:
     ...     proxy.stop()
     ...     proxy.process.terminate()
 
+    >>> plt.close('all')
+
     """
     _OLD_PARAMETER_NAMES = {
         'plot_cumulative_power': 'plot_cumulative',
@@ -341,8 +373,8 @@ class Spectrometer:
     # Expose plot properties from plot manager
     _to_expose = ('fig', 'ax', 'ax_raw', 'leg', 'plot_raw', 'plot_timetrace', 'plot_cumulative',
                   'plot_negative_frequencies', 'plot_absolute_frequencies', 'plot_amplitude',
-                  'plot_density', 'plot_cumulative_normalized', 'plot_style', 'plot_update_mode',
-                  'plot_dB_scale', 'reference_spectrum', 'processed_unit')
+                  'plot_density', 'plot_cumulative_normalized', 'plot_style', 'plot_dB_scale',
+                  'threaded_acquisition', 'reference_spectrum', 'processed_unit')
 
     # type checkers
     fig: Figure
@@ -358,8 +390,8 @@ class Spectrometer:
     plot_density: bool
     plot_cumulative_normalized: bool
     plot_style: _styleT
-    plot_update_mode: Optional[Literal['fast', 'always', 'never']]
     plot_dB_scale: bool
+    threaded_acquisition: bool
     reference_spectrum: _keyT
     processed_unit: str
 
@@ -387,6 +419,7 @@ class Spectrometer:
 
         self._data: Dict[Tuple[int, str], Dict] = {}
         self._savepath: Optional[Path] = None
+        self._acquiring = False
 
         self.daq = daq
         self.procfn = chain(*procfn) if np.iterable(procfn) else chain(procfn or Id)
@@ -395,12 +428,11 @@ class Spectrometer:
             savepath = Path.home() / 'python_spectrometer' / datetime.now().strftime('%Y-%m-%d')
         self.savepath = savepath
         self.compress = compress
-        self.threaded_acquisition = threaded_acquisition
-        if plot_update_mode is None:
-            plot_update_mode = 'never' if self.threaded_acquisition else 'fast'
+        if plot_update_mode is not None:
+            warnings.warn('plot_update_mode is deprecated and has no effect', DeprecationWarning)
         if purge_raw_data:
             warnings.warn('Enabling purge raw data might break some plotting features!',
-                          UserWarning, stacklevel=2)
+                          UserWarning)
         self.purge_raw_data = purge_raw_data
 
         if psd_estimator is None:
@@ -423,7 +455,7 @@ class Spectrometer:
                                          plot_cumulative, plot_negative_frequencies,
                                          plot_absolute_frequencies, plot_amplitude,
                                          plot_density, plot_cumulative_normalized,
-                                         plot_style, plot_update_mode, plot_dB_scale,
+                                         plot_style, plot_dB_scale, threaded_acquisition,
                                          prop_cycle, raw_unit, processed_unit,
                                          uses_windowed_estimator, complex_data, figure_kw,
                                          subplot_kw, gridspec_kw, legend_kw)
@@ -481,6 +513,11 @@ class Spectrometer:
     def savepath(self, path):
         self._savepath = io.to_global_path(path)
 
+    @property
+    def acquiring(self) -> bool:
+        """Indicates if the spectrometer is currently acquiring data."""
+        return self._acquiring
+
     @cached_property
     def audio_stream(self) -> WaveformPlaybackManager:
         """Manages audio waveform playback."""
@@ -572,7 +609,7 @@ class Spectrometer:
     def _repr_keys(self, *keys) -> str:
         if not keys:
             keys = self.keys()
-        return '\n'.join((str(key) for key in sorted(self.keys()) if key in keys))
+        return ' - ' + '\n - '.join((str(key) for key in sorted(self.keys()) if key in keys))
 
     @mock.patch.multiple('pickle', Unpickler=dill.Unpickler, Pickler=dill.Pickler)
     def _savefn(self, file: _pathT, **kwargs):
@@ -605,6 +642,12 @@ class Spectrometer:
 
         return compatible_kwargs
 
+    def _assert_ready(self):
+        if not isinstance(self.daq, DAQ):
+            raise ReadonlyError('Cannot take new data since no DAQ backend given')
+        if self.acquiring:
+            raise RuntimeError('Spectrometer is currently acquiring.')
+
     def _process_data(self, timetrace_raw, **settings) -> Dict[str, Any]:
         S_raw, f_raw, _ = welch(timetrace_raw, **settings)
         S_processed, f_processed, timetrace_processed = self.psd_estimator(
@@ -622,21 +665,74 @@ class Spectrometer:
                     settings=DAQSettings(settings))
         return data
 
-    def _handle_fetched(self, fetched_data, key: _keyT, **settings):
+    def _handle_fetched(self, key: _keyT, fetched_data, **settings):
         processed_data = self._process_data(fetched_data, **settings)
 
         # TODO: This could fail if the iterator was empty and processed_data was never assigned
         self._data[key].update(_merge_data_dicts(self._data[key], processed_data))
         self.set_reference_spectrum(self.reference_spectrum)
-        self.show(key)
+        if self._plot_manager.is_fig_open():
+            self.show(key)
+        else:
+            raise KeyboardInterrupt('Spectrometer was closed before data acquisition finished')
+
+    def _handle_final(self, key: _keyT, metadata: Any):
+        if self.play_sound:
+            self.play(key)
 
-    def _take_threaded(self, iterator: Iterator, progress: bool, key: _keyT, n_avg: int,
-                       **settings):
-        """Acquire data in a separate thread."""
+        if self.purge_raw_data:
+            del self._data[key]['timetrace_raw']
+            del self._data[key]['timetrace_processed']
+            del self._data[key]['f_raw']
+            del self._data[key]['S_raw']
+            self._data[key]['S_processed'] = np.mean(self._data[key]['S_processed'], axis=0)[None]
+
+        self._data[key].update(measurement_metadata=metadata)
+        self._savefn(self._data[key]['filepath'], **self._data[key])
+
+    def _take_threaded(self, progress: bool, key: _keyT, n_avg: int, **settings):
+        """Acquire data in a separate thread.
+
+        The :meth:`.daq.base.DAQ.acquire` iterator is incremented in a
+        background thread and fed into a :class:`~queue.Queue`. The
+        data is fetched and plotted in a callback that is periodically
+        triggered by a timer connected to the figure.
+
+        See Also
+        --------
+        :attr:`._plot_manager.PlotManager.timer`
+        :attr:`._plot_manager.PlotManager.TIMER_INTERVAL`
+
+        """
+
+        def update_plot():
+            try:
+                result = queue.get(block=False)
+            except Empty:
+                return
+            try:
+                if isinstance(result, StopIteration):
+                    self._handle_final(key, result.value)
+                    self._acquiring = False
+                    # Signal the timer that we've stopped. Removes the callback
+                    return False
+                elif isinstance(result, Exception):
+                    # Make sure we are left in a reproducible state
+                    self.drop(key)
+                    self._acquiring = False
+                    raise RuntimeError('Something went wrong during data acquisition') from result
+                else:
+                    self._handle_fetched(key, result, n_avg=n_avg, **settings)
+            finally:
+                queue.task_done()
 
-        def task():
-            for _ in progressbar(count(), disable=not progress, total=n_avg,
+        def acquire():
+            iterator = self.daq.acquire(n_avg=n_avg, **settings)
+            for i in progressbar(count(), disable=not progress, total=n_avg,
                                  desc=f'Acquiring {n_avg} spectra with key {key}'):
+                if stop_flag.is_set():
+                    print('Acquisition interrupted.')
+                    break
                 try:
                     item = next(iterator)
                 except Exception as error:
@@ -645,66 +741,53 @@ class Spectrometer:
                 else:
                     queue.put(item)
 
-        queue = Queue()
-        thread = Thread(target=task)
-        thread.start()
+            # The plot_update callback does not run on a noninteractive backend,
+            # so need to reset the flag at the thread's exit.
+            if not INTERACTIVE:
+                self._acquiring = False
 
-        fetched_data = sentinel = object()
+        def on_close(event):
+            stop_flag.set()
+            self._acquiring = False
 
-        while thread.is_alive():
-            while queue.empty():
-                self.fig.canvas.start_event_loop(20e-3)
+        INTERACTIVE = is_using_mpl_gui_backend(self.fig)
 
-            result = queue.get()
-            try:
-                if isinstance(result, StopIteration):
-                    return result.value
-                elif isinstance(result, Exception):
-                    # Make sure we are left in a reproducible state
-                    self.drop(key)
+        queue = Queue()
+        stop_flag = Event()
+        thread = Thread(target=acquire, daemon=True)
+        thread.start()
 
-                    msg = 'Something went wrong during data acquisition'
-                    if fetched_data is not sentinel:
-                        msg = msg + (f'. {self.daq.acquire} last returned the following data:\n '
-                                     f'{fetched_data}')
+        # Stop data acquisition when the figure is closed
+        self.fig.canvas.mpl_connect('close_event', on_close)
 
-                    raise RuntimeError(msg) from result
-                else:
-                    fetched_data = result
-                    self._handle_fetched(fetched_data, key, n_avg=n_avg, **settings)
-            finally:
-                queue.task_done()
+        # Run the timer that periodically checks for new data and updates the plot
+        self._plot_manager.timer.add_callback(update_plot)
+        self._plot_manager.timer.start()
+        self._acquiring = True
 
-    def _take_sequential(self, iterator: Iterator, progress: bool, key: _keyT, n_avg: int,
-                         **settings):
+    def _take_sequential(self, progress: bool, key: _keyT, n_avg: int, **settings):
         """Acquire data in the main thread."""
-
-        sentinel = object()
-        fetched_data = sentinel
-
-        for i in progressbar(count(), disable=not progress, total=n_avg,
+        # It's not necessary to set the _acquiring flag because this is
+        # blocking anyway.
+        iterator = self.daq.acquire(n_avg=n_avg, **settings)
+        for _ in progressbar(count(), disable=not progress, total=n_avg,
                              desc=f'Acquiring {n_avg} spectra with key {key}'):
             try:
                 fetched_data = next(iterator)
             except StopIteration as stop:
-                return stop.value
+                self._handle_final(key, stop.value)
+                break
             except Exception as error:
                 # Make sure we are left in a reproducible state
                 self.drop(key)
-
-                msg = 'Something went wrong during data acquisition'
-                if fetched_data is not sentinel:
-                    msg = msg + (f'. {self.daq.acquire} last returned the following data:\n '
-                                 f'{fetched_data}')
-
-                raise RuntimeError(msg) from error
+                raise RuntimeError('Something went wrong during data acquisition') from error
             else:
-                self._handle_fetched(fetched_data, key, n_avg=n_avg, **settings)
+                self._handle_fetched(key, fetched_data, n_avg=n_avg, **settings)
 
     def take(self, comment: str = '', progress: bool = True, **settings):
         """Acquire a spectrum with given settings and comment.
 
-        There are default parameter names that manage data acqusition
+        There are default parameter names that manage data acquisition
         settings by way of a dictionary subclass,
         :class:`.daq.settings.DAQSettings`. These are checked for
         consistency at runtime, since it is for example not possible to
@@ -719,15 +802,14 @@ class Spectrometer:
         comment : str
             An explanatory comment that helps identify the spectrum.
         progress : bool
-            Show a progressbar for the outer repetitions of data acqusition.
-            Default True.
+            Show a progressbar for the outer repetitions of data
+            acqusition. Default True.
         **settings
             Keyword argument settings for the data acquisition and
             possibly data processing using :attr:`procfn` or
             :attr:`fourier_procfn`.
         """
-        if not isinstance(self.daq, DAQ):
-            raise ReadonlyError('Cannot take new data since no DAQ backend given')
+        self._assert_ready()
 
         if (key := (self._index, comment)) in self._data:
             raise KeyError(f'Key {key} already exists. Choose a different comment.')
@@ -744,25 +826,10 @@ class Spectrometer:
         self._plot_manager.update_figure()
         self._plot_manager.add_new_line_entry(key)
 
-        iterator = self.daq.acquire(**settings)
-
         if self.threaded_acquisition:
-            measurement_metadata = self._take_threaded(iterator, progress, key, **settings)
+            self._take_threaded(progress, key, **settings)
         else:
-            measurement_metadata = self._take_sequential(iterator, progress, key, **settings)
-
-        if self.play_sound:
-            self.play(key)
-
-        self._data[key].update(measurement_metadata=measurement_metadata)
-        if self.purge_raw_data:
-            del self._data[key]['timetrace_raw']
-            del self._data[key]['timetrace_processed']
-            del self._data[key]['f_raw']
-            del self._data[key]['S_raw']
-            self._data[key]['S_processed'] = np.mean(self._data[key]['S_processed'], axis=0)[None]
-
-        self._savefn(filepath, **self._data[key])
+            self._take_sequential(progress, key, **settings)
 
     take.__doc__ = (take.__doc__.replace(8*' ', '')
                     + '\n\nDAQ Parameters'
@@ -979,8 +1046,7 @@ class Spectrometer:
             if repetitions > 1:
                 data = np.repeat(data[None, :], repetitions, axis=0).flatten()
 
-        if self.audio_stream is not None:
-            self.audio_stream.notify(data.flatten().astype("float32"), fs)
+        self.audio_stream.notify(data.flatten().astype("float32"), fs)
 
     def live_view(
             self,
@@ -1024,8 +1090,7 @@ class Spectrometer:
         #       data, the cleaner option would arguably be to subclass and add
         #       another subplot.
 
-        if not isinstance(self.daq, DAQ):
-            raise ReadonlyError('Cannot take new data since no DAQ backend given')
+        self._assert_ready()
 
         # Since (up to) two views need to obtain data from a single source, we feed the data
         # into separate queues from a third thread. The views then obtain the data from those
@@ -1068,6 +1133,7 @@ class Spectrometer:
             for event in set_events:
                 if event is not None:
                     event.set()
+            self._acquiring = False
 
         def get_live_view(cls, *args, **kwargs):
             if not in_process:
@@ -1088,10 +1154,10 @@ class Spectrometer:
         T = settings['n_pts'] / settings['fs']
 
         if np.issubdtype(self.daq.DTYPE, np.complexfloating):
-            xscale = _asinh_scale_maybe()
+            freq_xscale = _asinh_scale_maybe()
             xlim = np.array([-settings['f_max'], settings['f_max']])
         else:
-            xscale = 'log'
+            freq_xscale = 'log'
             xlim = np.array([settings['f_min'], settings['f_max']])
         if self.plot_absolute_frequencies:
             xlim += settings.get('freq', 0)
@@ -1099,9 +1165,9 @@ class Spectrometer:
         if live_view_kw is None:
             live_view_kw = {}
 
+        live_view_kw.setdefault('blocking_queue', True)
         live_view_kw.setdefault('autoscale', 'c')
         live_view_kw.setdefault('autoscale_interval', None)
-        live_view_kw.setdefault('xscale', xscale)
 
         fixed_kw = dict(
             plot_line=True, xlim=xlim, ylim=(0, (max_rows - 1) * T),
@@ -1109,13 +1175,13 @@ class Spectrometer:
             units={'x': 'Hz', 'y': 's', 'c': self.processed_unit + r'$/\sqrt{{Hz}}$'},
             img_kw=dict(norm=colors.LogNorm(vmin=0.1, vmax=10), cmap='Blues')
         )
-        freq_kw = live_view_kw | fixed_kw
+        freq_kw = {'xscale': freq_xscale} | live_view_kw | fixed_kw
         if any(freq_kw[key] != val for key, val in live_view_kw.items()):
             warnings.warn('Overrode some keyword arguments for FrequencyLiveView', UserWarning)
 
         # The view(s) get data from these queues and subsequently put them into their own
         stop_event = Event()
-        get_queues = [Queue()]
+        get_queues = [LifoQueue(maxsize=int(live_view_kw['blocking_queue']))]
         views = [get_live_view(FrequencyLiveView, put_frequency_data, get_queue=get_queues[0],
                                **freq_kw)]
 
@@ -1126,7 +1192,7 @@ class Spectrometer:
             if any(time_kw[key] != val for key, val in live_view_kw.items()):
                 warnings.warn('Overrode some keyword arguments for TimeLiveView', UserWarning)
 
-            get_queues.append(LifoQueue())
+            get_queues.append(LifoQueue(maxsize=int(live_view_kw['blocking_queue'])))
             views.append(get_live_view(TimeLiveView, put_time_data, get_queue=get_queues[1],
                                        **time_kw))
 
@@ -1164,8 +1230,27 @@ class Spectrometer:
             else:
                 view.block_until_ready()
 
+        self._acquiring = True
         return views
 
+    def block_until_ready(self, timeout: float = float('inf')):
+        """Block the interpreter until acquisition is complete.
+
+        This is a convenience function to ensure all GUI events are
+        processed before another acquisition is started.
+
+        Spins the event loop to allow the figure to remain responsive.
+
+        Parameters
+        ----------
+        timeout :
+            Optionally specify a timeout after which a :class:`TimeoutError`
+            is raised.
+        """
+        with misc.timeout(timeout, raise_exc=True) as exceeded:
+            while self.acquiring and not exceeded:
+                self.fig.canvas.start_event_loop(self._plot_manager.TIMER_INTERVAL * 1e-3)
+
     def reprocess_data(self,
                        *comment_or_index: _keyT,
                        save: Literal[False, True, 'overwrite'] = False,
@@ -1231,11 +1316,12 @@ class Spectrometer:
                 return
         key = self._parse_keys(comment_or_index)[0]
         if key != self.reference_spectrum:
-            self._plot_manager._reference_spectrum = key
+            self._plot_manager.reference_spectrum = key
             if self.plot_dB_scale:
                 self._plot_manager.update_line_attrs(['main', 'cumulative'],
                                                      self._plot_manager.lines_to_draw,
                                                      stale=True)
+            if self._plot_manager.is_fig_open():
                 self._plot_manager.setup_figure()
 
     @staticmethod
diff --git a/src/python_spectrometer/daq/simulator.py b/src/python_spectrometer/daq/simulator.py
index 125b14ecf9618dc946a350905c837c99dd6b0615..ed8ebc179a4c6a793133a422a629b6e9506344f8 100644
--- a/src/python_spectrometer/daq/simulator.py
+++ b/src/python_spectrometer/daq/simulator.py
@@ -8,6 +8,7 @@ Examples
 ...                              savepath=tempfile.mkdtemp())
 >>> speck.take('a test', fs=10e3)  #doctest: +ELLIPSIS
 ...
+>>> speck.block_until_ready()  # for doctest
 
 Add an artificial time delay to mimick finite data acquisition time:
 >>> speck.take('delayed', n_avg=3, delay=True)  #doctest: +ELLIPSIS
diff --git a/src/python_spectrometer/daq/zurich_instruments.py b/src/python_spectrometer/daq/zurich_instruments.py
index 9bb5cb1167be84bd823589cf4df504ec806b33f5..876fd1f64d95327030e7464da5f043f87eac581f 100644
--- a/src/python_spectrometer/daq/zurich_instruments.py
+++ b/src/python_spectrometer/daq/zurich_instruments.py
@@ -54,7 +54,7 @@ from typing import Any, Dict, Mapping, Optional, Type, Union
 
 import numpy as np
 from packaging import version
-from qutil.domains import BoundedSet, DiscreteInterval
+from qutil.domains import DiscreteInterval, ExponentialDiscreteInterval
 from scipy.special import gamma
 from zhinst import toolkit
 
@@ -166,8 +166,7 @@ class ZurichInstrumentsMFLIDAQ(_ZurichInstrumentsDevice):
         class MFLIDAQSettings(DAQSettings):
             CLOCKBASE = self.device.clockbase()
             # TODO: always the same for each instrument?
-            ALLOWED_FS = BoundedSet(CLOCKBASE / 70 / 2 ** np.arange(24),
-                                    precision=DAQSettings.PRECISION)
+            ALLOWED_FS = ExponentialDiscreteInterval(-23, 0, base=2, prefactor=CLOCKBASE / 70)
             DEFAULT_FS = CLOCKBASE / 70 / 2**6
 
         return MFLIDAQSettings
@@ -411,8 +410,8 @@ class ZurichInstrumentsMFLIScope(_ZurichInstrumentsDevice):
             CLOCKBASE = self.device.clockbase()
             # TODO: always the same for each instrument?
             ALLOWED_N_PTS = DiscreteInterval(2 ** 12, 2 ** 14, precision=DAQSettings.PRECISION)
-            ALLOWED_FS = BoundedSet(CLOCKBASE / 2 ** np.arange(17),
-                                    precision=DAQSettings.PRECISION)
+            ALLOWED_FS = ExponentialDiscreteInterval(-16, 0, prefactor=CLOCKBASE, base=2,
+                                                     precision=DAQSettings.PRECISION)
             DEFAULT_FS = CLOCKBASE / 2 ** 8
 
         return MFLIScopeSettings
diff --git a/tests/test_audio.py b/tests/test_audio.py
index 404a9bccec3fcb65041ae13a2ccc879ecd7855c9..159f878301d48c853d6541f0b053e6008940a484 100644
--- a/tests/test_audio.py
+++ b/tests/test_audio.py
@@ -20,8 +20,9 @@ def import_or_mock_pyaudio():
 
 def test_audio(import_or_mock_pyaudio):
     spect = Spectrometer(QoptColoredNoise(), savepath=mkdtemp(),
-                         play_sound=True)
+                         play_sound=True, threaded_acquisition=False)
     spect.take('with sound', f_max=20000, A=2e-4)
+    spect.block_until_ready()
 
     assert 'audio_stream' in spect.__dict__
     assert spect.audio_stream.playback_thread is not None
diff --git a/tests/test_live_view.py b/tests/test_live_view.py
index 73d5408b900c04c058d12e95e371da2503f85343..ab10cd1437626e21cc3f6253888f4229ebfb2400 100644
--- a/tests/test_live_view.py
+++ b/tests/test_live_view.py
@@ -21,24 +21,34 @@ def start_animation(fig):
         fig.canvas.callbacks.process(
             'draw_event', DrawEvent('draw_event', fig.canvas, fig.canvas.get_renderer())
         )
+    else:
+        fig.canvas.flush_events()
 
 
 def advance_frames(view: LiveViewT, n_frames: int):
     # Need to manually trigger events in headless mode. Else does nothing.
-    if not is_using_mpl_gui_backend(view.fig):
-        # Start the animation
-        for _ in range(n_frames):
+    for _ in range(n_frames):
+        if not is_using_mpl_gui_backend(view.fig):
             for fun, args, kwargs in view.animation.event_source.callbacks:
                 fun(*args, **kwargs)
+        else:
+            view.fig.canvas.flush_events()
+
+
+def stop_view(view):
+    try:
+        view.stop()
+    except RuntimeError:
+        # Thread did not join. Try to trigger event loop once.
+        advance_frames(view, 1)
+        view.stop()
 
 
 def close_figure(fig):
     if not is_using_mpl_gui_backend(fig):
         # Need to manually trigger the close event because the event loop isn't running
         fig.canvas.callbacks.process('close_event', CloseEvent('close_event', fig.canvas))
-    else:
-        # Required to have a figure that's up-to-date
-        fig.canvas.flush_events()
+
     # remove figure from registry
     plt.close(fig)
 
@@ -59,12 +69,12 @@ def spectrometer():
     return Spectrometer(daq.QoptColoredNoise(), savepath=mkdtemp())
 
 
-@pytest.fixture(params=[True, False])
+@pytest.fixture(params=[True, False], ids=['timetrace', 'no_timetrace'])
 def plot_timetrace(request):
     return request.param
 
 
-@pytest.fixture(params=[True, False])
+@pytest.fixture(params=[True, False], ids=['in_child_process', 'in_main_process'])
 def in_process(request):
     return request.param
 
@@ -98,22 +108,39 @@ def started(spectrometer, plot_timetrace, in_process, in_gitlab_ci):
 @pytest.fixture
 def stopped(started: list[LiveViewT], in_process: bool):
     view = random.choice(started)
-    view.stop()
+    stop_view(view)
 
     if not in_process and len(started) > 1:
-        view = started[1 - started.index(view)]
-        if is_using_mpl_gui_backend(view.fig):
-            view.fig.canvas.start_event_loop(100e-3)
-        else:
-            advance_frames(view, 1)
+        advance_frames(started[1 - started.index(view)], 1)
 
     stopped = started
     return stopped
 
 
-def test_started(started, plot_timetrace, in_process):
+@pytest.fixture
+def closed(started: list[LiveViewT], in_process: bool):
+    view = random.choice(started)
+
+    if in_process:
+        view.close_event.set()
+    else:
+        close_figure(view.fig)
+
+    if not in_process and len(started) > 1:
+        advance_frames(started[1 - started.index(view)], 1)
+    else:
+        # Give the process time to process all events
+        time.sleep(250e-3)
+
+    closed = started
+    yield closed
+
+
+def test_started(spectrometer, started, plot_timetrace, in_process):
     views = started
 
+    assert spectrometer.acquiring
+
     if plot_timetrace:
         assert len(views) == 2
         if not in_process:
@@ -135,8 +162,12 @@ def test_started(started, plot_timetrace, in_process):
         assert not statement_timed_out(lambda: not view.stop_event.is_set(), timeout=1)
 
 
-def test_stopped(stopped):
-    views = stopped
+@pytest.mark.parametrize("view_state", ["stopped", "closed"])
+def test_finished(spectrometer, view_state, request, plot_timetrace, in_process, in_gitlab_ci):
+    """Test the state of view(s) and spectrometer after stopping and closing."""
+    views = request.getfixturevalue(view_state)
+
+    assert not spectrometer.acquiring
 
     for view in views:
         assert not statement_timed_out(lambda: view.is_running() is None, timeout=10)
diff --git a/tests/test_serialization.py b/tests/test_serialization.py
index 83a87b9bb5c069ce0085da0762aca9394f0333fd..595b9376842a1a886cc246d3cce39ee3b05952fd 100644
--- a/tests/test_serialization.py
+++ b/tests/test_serialization.py
@@ -2,13 +2,11 @@ import os
 import pathlib
 import random
 import string
-from pathlib import Path
 from tempfile import mkdtemp
-from typing import Any, Generator
 
 import pytest
-
 from python_spectrometer import Spectrometer, daq
+from qutil.plotting import is_using_mpl_gui_backend
 
 
 def remove_file_if_exists(file):
@@ -25,22 +23,51 @@ def remove_dir_if_exists(d):
         pass
 
 
+def advance_frames(spect: Spectrometer, n_frames: int):
+    # Need to manually trigger events in headless mode. Else does nothing.
+    if not is_using_mpl_gui_backend(spect.fig):
+        # Run callbacks
+        for _ in range(n_frames):
+            for fun, args, kwargs in spect._plot_manager.timer.callbacks:
+                fun(*args, **kwargs)
+
+
+@pytest.fixture(params=[True, False])
+def relative_paths(request):
+    return request.param
+
+
 @pytest.fixture(params=[True, False])
-def spectrometer(monkeypatch, request) -> Generator[Spectrometer, Any, None]:
-    # patch input to answer overwrite queries with "yes"
+def threaded_acquisition(request):
+    return request.param
+
+
+@pytest.fixture(scope='function')
+def spectrometer(monkeypatch, relative_paths: bool, threaded_acquisition: bool):
+    # patch input to answer overwrite queries with "yes". Disable this when debugging :)
     monkeypatch.setattr('builtins.input', lambda: 'y')
 
     speck = Spectrometer(daq.QoptColoredNoise(),
                          savepath=pathlib.Path(cwd := mkdtemp(), 'test_data'),
                          plot_cumulative=True,
-                         relative_paths=request.param)
+                         threaded_acquisition=threaded_acquisition,
+                         relative_paths=relative_paths)
     speck.savepath.mkdir(parents=True, exist_ok=True)
 
     try:
         os.chdir(speck.savepath)
 
-        speck.take('foo')
-        speck.take('baz', fs=1e3, nperseg=400)
+        # XXX: need to block to avoid timing issues when running tests
+        speck.take('foo', progress=False)
+        speck.block_until_ready()
+        if threaded_acquisition:
+            # Advance two frames in headless mode, otherwise data is not saved.
+            advance_frames(speck, 2)
+
+        speck.take('baz', fs=1e3, nperseg=400, progress=False)
+        speck.block_until_ready()
+        if threaded_acquisition:
+            advance_frames(speck, 2)
 
         yield speck
     finally:
@@ -52,7 +79,7 @@ def spectrometer(monkeypatch, request) -> Generator[Spectrometer, Any, None]:
 
 
 @pytest.fixture
-def serialized(spectrometer: Spectrometer) -> Generator[Path, Any, None]:
+def serialized(spectrometer: Spectrometer):
     stem = ''.join(random.choices(string.ascii_letters, k=10))
 
     try:
@@ -69,10 +96,13 @@ def serialized(spectrometer: Spectrometer) -> Generator[Path, Any, None]:
             remove_file_if_exists(spectrometer.savepath / f'{stem}{ext}')
 
 
-def test_saving(spectrometer: Spectrometer):
+def test_saving(spectrometer: Spectrometer, relative_paths: bool):
     assert spectrometer.savepath.exists()
     for file in spectrometer.files:
-        assert os.path.exists(file)
+        if relative_paths:
+            assert os.path.exists(spectrometer.savepath / file)
+        else:
+            assert os.path.exists(file)
 
 
 def test_serialization(spectrometer: Spectrometer):
@@ -88,9 +118,9 @@ def test_serialization(spectrometer: Spectrometer):
 
 
 def test_deserialization(serialized: pathlib.Path):
-    speck = Spectrometer.recall_from_disk(serialized)
-    for data, comment in zip(speck, ['foo', 'baz']):
+    deserialized = Spectrometer.recall_from_disk(serialized)
+    for data, comment in zip(deserialized, ['foo', 'baz']):
         assert data['comment'] == comment
-    assert speck['baz']['settings']['fs'] == 1e3
-    assert speck['baz']['settings']['nperseg'] == 400
-    assert speck.plot_cumulative is True
+    assert deserialized['baz']['settings']['fs'] == 1e3
+    assert deserialized['baz']['settings']['nperseg'] == 400
+    assert deserialized.plot_cumulative is True