diff --git a/src/python_spectrometer/__init__.py b/src/python_spectrometer/__init__.py
index a6319b875357cefede1bf2eecefedc201e98eadc..5b54107ecac6dcd05110c952089def4a3ec3bf17 100644
--- a/src/python_spectrometer/__init__.py
+++ b/src/python_spectrometer/__init__.py
@@ -93,8 +93,11 @@ Finally, plot options can be changed dynamically at runtime::
     spect.plot_raw = True  # Updates the figure accordingly
     spect.plot_timetrace = False
 
+Examples
+--------
+
 Example from :func:`scipy.signal.welch`
----------------------------------------
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 In this short demonstration, we reproduce the example from
 :func:`scipy:scipy.signal.welch`. To this end, we write a custom
 ``DAQ`` class that generates a noisy sine signal.
@@ -146,6 +149,46 @@ Finally, we can also plot data in dB relative to a given dataset.
 >>> float(data.max())  # Factor two in amplitude is approx 3 dB
 3.0284739712568682
 
+Analyzing filter behavior
+^^^^^^^^^^^^^^^^^^^^^^^^^
+:mod:`qutil:qutil.signal_processing.real_space` and
+:mod:`qutil:qutil.signal_processing.fourier_space` define filters that
+work in the time- and frequency-domain, respectively. We can visualize
+the filter properties using the spectrometer:
+
+>>> from tempfile import mkdtemp
+>>> import qutil.signal_processing as sp
+>>> from qutil.functools import partial
+>>> from python_spectrometer import daq, Spectrometer
+
+>>> def compare_filters(typ: str, order: int):
+...     spect = Spectrometer(daq.QoptColoredNoise(), savepath=mkdtemp(),
+...                          plot_dB_scale=True, plot_density=False,
+...                          threaded_acquisition=False)
+...     spect.take('Baseline', n_seg=10, fs=1e4, df=0.1)
+...     spect.procfn = getattr(sp.real_space, f'{typ}_filter')
+...     spect.take(f'Real space {order}. order {typ} filter',
+...                n_seg=10, f_max=1e2, fs=1e4, df=0.1, order=order)
+...     spect.procfn = sp.real_space.Id
+...     spect.psd_estimator = partial(
+...         sp.real_space.welch,
+...         fourier_procfn=getattr(sp.fourier_space, f'{typ}_filter')
+...     )
+...     spect.take(f'Fourier space {order}. order {typ} filter',
+...                n_seg=10, f_max=1e2, fs=1e4, df=0.1, order=order)
+
+RC and Butterworth first order filters are the same (up to real-space
+implementation):
+
+>>> compare_filters('RC', 1)
+>>> compare_filters('butter', 1)
+
+For higher orders, they differ:
+
+>>> compare_filters('RC', 5)
+>>> compare_filters('butter', 5)
+
+
 See the documentation of :class:`~core.Spectrometer` and its methods
 for more information.
 """
diff --git a/src/python_spectrometer/_plot_manager.py b/src/python_spectrometer/_plot_manager.py
index 24030f2eb792fc8b7b98f76f676a3f571a48ae6d..97865a8f284a567c557231df697b2d696ef7fe7f 100644
--- a/src/python_spectrometer/_plot_manager.py
+++ b/src/python_spectrometer/_plot_manager.py
@@ -74,10 +74,6 @@ class PlotManager:
 
         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()):
-            # 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)
 
@@ -102,7 +98,7 @@ class PlotManager:
         try:
             self._fig = plt.figure(**self.figure_kw)
         except TypeError:
-            if layout := self.figure_kw.pop('layout', False):
+            if layout := self.figure_kw.pop('layout', None) is not None:
                 # matplotlib < 3.5 doesn't support layout kwarg yet
                 self.figure_kw[f'{layout}_layout'] = True
             elif layout is False:
@@ -579,6 +575,13 @@ class PlotManager:
             if self._leg is not None:
                 self._leg.remove()
 
+        if 'layout' not in self.figure_kw:
+            try:
+                self.fig.set_layout_engine('tight')
+                self.fig.get_layout_engine().execute(self.fig)
+            finally:
+                self.fig.set_layout_engine('none')
+
         self.fig.canvas.draw_idle()
         self.fig.canvas.flush_events()
 
diff --git a/src/python_spectrometer/core.py b/src/python_spectrometer/core.py
index ec130ba01dfdf244528339bc277db7bfecc0838d..b0d4703f6c10a23bd4cab9c660e5ecbc6296fffc 100644
--- a/src/python_spectrometer/core.py
+++ b/src/python_spectrometer/core.py
@@ -1089,6 +1089,8 @@ class Spectrometer:
         # TODO: Instead of setting up separate LiveView's for time and frequency
         #       data, the cleaner option would arguably be to subclass and add
         #       another subplot.
+        # TODO: When plot_timetrace=True and one view is paused, closing a
+        #       figure causes the thread to not join.
 
         self._assert_ready()
 
@@ -1109,7 +1111,10 @@ class Spectrometer:
             t = np.linspace(0, T, settings['n_pts'])
             while not stop_event.is_set():
                 *_, yt = get_queue.get()
-                put_queue.put((t, yt))
+                if np.issubdtype(self.daq.DTYPE, np.complexfloating):
+                    put_queue.put((t, {'X': yt.real, 'Y': yt.imag}))
+                else:
+                    put_queue.put((t, yt))
                 get_queue.task_done()
                 live_view.flush_queue(get_queue)
 
@@ -1153,7 +1158,7 @@ class Spectrometer:
         settings = self.daq.setup(**settings)
         T = settings['n_pts'] / settings['fs']
 
-        if np.issubdtype(self.daq.DTYPE, np.complexfloating):
+        if np.issubdtype(self.daq.DTYPE, np.complexfloating) and self.plot_negative_frequencies:
             freq_xscale = _asinh_scale_maybe()
             xlim = np.array([-settings['f_max'], settings['f_max']])
         else:
@@ -1167,7 +1172,11 @@ class Spectrometer:
 
         live_view_kw.setdefault('blocking_queue', True)
         live_view_kw.setdefault('autoscale', 'c')
-        live_view_kw.setdefault('autoscale_interval', None)
+        live_view_kw.setdefault('autoscale_interval_ms', None)
+        live_view_kw.setdefault(
+            'plot_legend',
+            'upper right' if np.issubdtype(self.daq.DTYPE, np.complexfloating) else False
+        )
 
         fixed_kw = dict(
             plot_line=True, xlim=xlim, ylim=(0, (max_rows - 1) * T),
@@ -1187,6 +1196,7 @@ class Spectrometer:
 
         if self.plot_timetrace:
             fixed_kw = dict(xlim=(0, T), xlabel='$t$', ylabel='Amplitude',
+                            n_lines=2 if np.issubdtype(self.daq.DTYPE, np.complexfloating) else 1,
                             units={'x': 's', 'y': self.processed_unit})
             time_kw = live_view_kw | fixed_kw
             if any(time_kw[key] != val for key, val in live_view_kw.items()):
diff --git a/src/python_spectrometer/daq/__init__.pyi b/src/python_spectrometer/daq/__init__.pyi
index 1fd2d38ff8846ee49d3ad65731b147d21335b995..83c91696a8ddb3effe78cc899bb549e0ec6aad4a 100644
--- a/src/python_spectrometer/daq/__init__.pyi
+++ b/src/python_spectrometer/daq/__init__.pyi
@@ -1,5 +1,5 @@
-__all__ = ['atsaverage', 'atssimple', 'qcodes', 'simulator', 'swabian_instruments', 'zurich_instruments',
-           'AlazarATS9xx0', 'Keysight344xxA', 'NationalInstrumentsUSB6003',
+__all__ = ['atsaverage', 'atssimple', 'qcodes', 'simulator', 'swabian_instruments',
+           'zurich_instruments', 'AlazarATS9xx0', 'Keysight344xxA', 'NationalInstrumentsUSB6003',
            'SwabianInstrumentsTimeTagger', 'DAQSettings', 'QoptColoredNoise',
            'ZurichInstrumentsMFLIScope', 'ZurichInstrumentsMFLIDAQ']
 
diff --git a/src/python_spectrometer/daq/simulator.py b/src/python_spectrometer/daq/simulator.py
index ed8ebc179a4c6a793133a422a629b6e9506344f8..8e7915a88d750b393a5e97ab19d423f1aa22b442 100644
--- a/src/python_spectrometer/daq/simulator.py
+++ b/src/python_spectrometer/daq/simulator.py
@@ -1,29 +1,40 @@
 """Provides a simulation backend for data acquisition.
 
+The :meth:`DAQ.acquire` method of DAQs in this module accepts a
+``delay`` keyword argument which introduces a delay in between yields
+to simulate a finite data acquisition time. If True, delays by the
+amount of time it would take to actually acquire data with the given
+settings; if float delays by the given amount.
+
 Examples
 --------
 >>> import python_spectrometer as pyspeck
 >>> import tempfile
 >>> speck = pyspeck.Spectrometer(pyspeck.daq.QoptColoredNoise(),
 ...                              savepath=tempfile.mkdtemp())
->>> speck.take('a test', fs=10e3)  #doctest: +ELLIPSIS
+>>> speck.take('a test', fs=10e3)
 ...
 >>> 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
+>>> speck.take('delayed', n_avg=3, delay=True)
 ...
 
 """
 from __future__ import annotations
 
+import copy
 import dataclasses
+import inspect
 import sys
 import time
-from typing import Callable
+from collections.abc import Callable
+from typing import Literal, Union
 
 import numpy as np
-from qutil.functools import partial
+from qutil.functools import partial, wraps
+from qutil.math import cexp
+from qutil.signal_processing import real_space
 
 from .base import DAQ, AcquisitionGenerator
 
@@ -43,6 +54,64 @@ except ImportError as e:
                       "'pip install qopt.'") from e
 
 
+def with_delay(meth):
+    """Wraps an acquisition generator to accept the *delay* kwarg."""
+
+    @wraps(meth)
+    def wrapped(self, *, delay=False, **settings):
+        skip_delay = settings.pop('_skip_delay', False)
+
+        if delay is True:
+            delay = settings['n_pts'] / settings['fs']
+
+        it = meth(self, _skip_delay=True, **settings)
+        while True:
+            tic = time.perf_counter()
+            try:
+                data = next(it)
+            except StopIteration as stop:
+                return stop.value
+            else:
+                if delay and not skip_delay:
+                    time.sleep(max(0, delay - (time.perf_counter() - tic)))
+
+                yield data
+
+    # Insert parameter sig
+    delay_param = inspect.Parameter('delay', inspect.Parameter.KEYWORD_ONLY, default=True,
+                                    annotation=Union[bool, float])
+
+    parameters = list(inspect.signature(meth).parameters.values())
+    if parameters[-1].kind is inspect.Parameter.VAR_KEYWORD:
+        parameters = parameters[:-1] + [delay_param, parameters[-1]]
+    else:
+        parameters = parameters + [delay_param]
+
+    wrapped.__signature__ = inspect.signature(wrapped).replace(parameters=parameters)
+    return wrapped
+
+
+class MonochromaticNoise(DAQ):
+    """Generate monochromatic sinusoidal noise with random phase.
+
+    This DAQ implementation produces sinusoidal data with a fixed frequency
+    but random phase for each acquisition, simulating a simple signal with noise.
+
+    Inherits from the base DAQ class and implements the required acquire method.
+    """
+
+    @with_delay
+    def acquire(self, *, n_avg: int, fs: float, n_pts: int, A: float = 1, f_0: float = 50,
+                **settings) -> AcquisitionGenerator[DAQ.DTYPE]:
+        """Generate sinusoidal data with random phase."""
+
+        t = np.arange(0, n_pts / fs, 1 / fs)
+        rng = np.random.default_rng()
+
+        for _ in range(n_avg):
+            yield np.sin(2 * np.pi * (t * f_0 + rng.random()))
+
+
 @dataclasses.dataclass
 class QoptColoredNoise(DAQ):
     """Simulates noise using :mod:`qopt:qopt`.
@@ -52,11 +121,17 @@ class QoptColoredNoise(DAQ):
     :class:`~python_spectrometer.daq.settings.DAQSettings`
     for more information on setup parameters.
 
+    Attributes
+    ----------
+    spectral_density : Callable[[NDArray, ...], NDArray]
+        A function that generates the power spectral density for given
+        frequencies. Defaults to white noise with scale parameter
+        ``S_0``.
+
     See Also
     --------
     :func:`qopt:qopt.noise.fast_colored_noise`
         For information on the simulation.
-
     """
     spectral_density: Callable[[NDArray, ...], NDArray] = dataclasses.field(
         default_factory=lambda: QoptColoredNoise.white_noise
@@ -74,35 +149,120 @@ class QoptColoredNoise(DAQ):
         """White noise power spectral density with amplitude S_0."""
         return np.full_like(f, S_0)
 
-    def acquire(self, *, n_avg: int, fs: float, n_pts: int, delay: bool | float = False,
+    @with_delay
+    def acquire(self, *, n_avg: int, fs: float, n_pts: int,
                 **settings) -> AcquisitionGenerator[DAQ.DTYPE]:
-        """Executes a measurement and yields the resulting timetrace.
-
-        Optionally set a delay to simulate a finite data acquisition
-        time. If True, delays by the amount of time it would take to
-        actually acquire data with the given settings; if float delay
-        by the given amount.
-        """
-        if delay is True:
-            delay = 1 / settings['df']
-
+        """Executes a measurement and yields the resulting timetrace."""
         for _ in range(n_avg):
-            tic = time.perf_counter()
-            data = qopt.noise.fast_colored_noise(
+            yield qopt.noise.fast_colored_noise(
                 partial(
                     settings.get('spectral_density', self.spectral_density),
                     **settings
                 ),
                 dt=1/fs, n_samples=n_pts, output_shape=()
             )
-            if delay:
-                time.sleep(delay - (time.perf_counter() - tic))
-            yield data
 
         # This is the place to return metadata (possibly obtained from the instrument)
         return {'qopt version': qopt.__version__}
 
 
+class DemodulatorQoptColoredNoise(QoptColoredNoise):
+    """Simulates demodulated noisy data for lock-in measurements.
+
+    Extends QoptColoredNoise to demodulate the simulated signal using
+    complex IQ-demodulation, similar to a lock-in amplifier. This
+    provides a realistic simulation of demodulated signals as would be
+    measured in experiments using lock-in amplification techniques.
+    """
+    DTYPE = np.complexfloating
+
+    @staticmethod
+    def demodulate(signal: np.ndarray, IQ: np.ndarray, **settings) -> np.ndarray:
+        """Demodulate signal using the provided IQ reference.
+
+        Performs complex demodulation by multiplying the signal with
+        the IQ reference and applying an RC filter.
+
+        Parameters
+        ----------
+        signal :
+            Input signal to demodulate
+        IQ :
+            Complex IQ reference for demodulation
+        **settings :
+            Settings for RC filter, including filter parameters
+
+        """
+        # Don't highpass filter
+        settings = copy.deepcopy(settings)
+        settings.pop('f_min', None)
+        return real_space.RC_filter(signal * IQ, **settings)
+
+    @with_delay
+    def acquire(self, *, n_avg: int, freq: float = 50, filter_order: int = 1,
+                modulate_signal: bool = False,
+                filter_method: Literal['forward', 'forward-backward'] = 'forward',
+                **settings) -> AcquisitionGenerator[DTYPE]:
+        r"""Simulate demodulated noisy data.
+
+        Generates simulated data and performs IQ demodulation, mimicking
+        the behavior of a lock-in amplifier. Can simulate either just
+        input noise or noise in the full signal path.
+
+        See [1]_ for an introduction to Lock-in amplification.
+
+        Parameters
+        ----------
+        n_avg :
+            Number of outer averages.
+        freq :
+            Modulation frequency.
+        filter_order :
+            RC filter order used to filter the demodulated signal.
+        modulate_signal :
+            Add the simulated noise to the modulation signal to mimic
+            noise picked up by a Lock-In signal travelling through some
+            DUT. Otherwise, mimics the noise at the input of the
+            amplifier.
+
+            In other words, simulate a Lock-In output connected to an
+            input, or just simulate the input.
+
+            Note that if True, noise is assumed to be additive, that
+            is,
+
+            .. math::
+
+                x(t) = s(t) + \delta(t)
+
+            with :math:`s(t)` the output signal and :math:`\delta(t)`
+            the noise.
+        filter_method :
+            See :func:`~qutil:qutil.signal_processing.real_space.RC_filter`.
+
+        Yields
+        ------
+        data :
+            Demodulated data in complex IQ-representation.
+
+        References
+        ----------
+        .. [1] https://www.zhinst.com/europe/en/resources/principles-of-lock-in-detection
+
+        """
+        t = np.arange(0, settings['n_pts'] / settings['fs'], 1 / settings['fs'])
+        # demodulation by √2 exp(-iωt) (ZI convention)
+        IQ = np.sqrt(2) * cexp(-2 * np.pi * freq * t)
+
+        yield from (
+            self.demodulate(IQ.real + data if modulate_signal else data, IQ,
+                            order=filter_order, method=filter_method, **settings)
+            for data in super().acquire(n_avg=n_avg, **settings)
+        )
+
+        return {'qopt version': qopt.__version__}
+
+
 @deprecated("Use QoptColoredNoise instead")
 class qopt_colored_noise(QoptColoredNoise):
     ...
diff --git a/src/python_spectrometer/daq/swabian_instruments.py b/src/python_spectrometer/daq/swabian_instruments.py
index b6adfa77dd323eaf0d1b137664563fbb0e82e437..5ff94f76a828f473a0160f9e430b01f6eb1d4f11 100644
--- a/src/python_spectrometer/daq/swabian_instruments.py
+++ b/src/python_spectrometer/daq/swabian_instruments.py
@@ -53,7 +53,7 @@ class SwabianInstrumentsTimeTagger(DAQ):
         _ = atexit.register(TimeTagger.freeTimeTagger, tagger)
 
         spect = Spectrometer(daq.swabian_instruments.SwabianInstrumentsTimeTagger(tagger, [1, 2]),
-                             raw_unit='cts', processed_unit='cts')
+                             raw_unit='cts')
 
     """
     tagger: tt.TimeTagger = dataclasses.field()
diff --git a/src/python_spectrometer/daq/zurich_instruments.py b/src/python_spectrometer/daq/zurich_instruments.py
index 876fd1f64d95327030e7464da5f043f87eac581f..64d900573c81900095b4737d58a41cd8fdbe0b04 100644
--- a/src/python_spectrometer/daq/zurich_instruments.py
+++ b/src/python_spectrometer/daq/zurich_instruments.py
@@ -39,7 +39,15 @@ Compare their results::
     spect_scope.take(n_pts=2**14, fs=14.6e3)
 
 The DAQ spectrum should show a peak at :math:`f=-500\,\mathrm{Hz}`,
-corresponding to the oscillator frequency.
+corresponding to the oscillator frequency. This is the shifted 0 Hz
+peak of the instrument's $1/f$ noise.
+
+Use ``procfn`` to compute the phase noise spectrum using the DAQ module::
+
+    spect_daq.procfn = lambda x, **_: np.angle(x)
+    spect_daq.processed_unit = 'rad'
+    spect_daq.drop('all')
+    spect_daq.take()
 
 """
 from __future__ import annotations
@@ -57,6 +65,8 @@ from packaging import version
 from qutil.domains import DiscreteInterval, ExponentialDiscreteInterval
 from scipy.special import gamma
 from zhinst import toolkit
+from zhinst.core.errors import SampleLossError
+from zhinst.toolkit.exceptions import ToolkitError
 
 from .base import DAQ, AcquisitionGenerator
 from .settings import DAQSettings
@@ -78,9 +88,6 @@ if version.parse(toolkit.__version__) < version.parse('0.5.0'):
     raise ImportError('This DAQ requires zhinst-toolkit >= 0.5.0. '
                       "You can install it by running 'pip install zhinst-toolkit>=0.5.0'.")
 
-from zhinst.core.errors import SampleLossError
-from zhinst.toolkit.exceptions import ToolkitError
-
 logger = logging.getLogger(__name__)
 
 ZhinstDeviceT = TypeVar('ZhinstDeviceT', bound=toolkit.driver.devices.base.BaseInstrument)
@@ -116,14 +123,6 @@ class ZurichInstrumentsMFLIDAQ(_ZurichInstrumentsDevice):
     components, X + iY, and therefore the resulting spectrum is two-
     sided.
 
-    .. note::
-
-        For measurements where the input is not connected to the output
-        of the Lock-in, this means there is a coherent signal at the
-        oscillator frequency present in the time series data. Due to
-        non-idealities, its peak in the spectrum has a finite width and
-        may thus overshadow spectral features present in the input.
-
     Parameters
     ----------
     session : toolkit.session.Session
@@ -171,28 +170,14 @@ class ZurichInstrumentsMFLIDAQ(_ZurichInstrumentsDevice):
 
         return MFLIDAQSettings
 
-    def setup(self, bandwidth: Union[str, float] = 'auto', filter_order: Optional[int] = None,
-              freq: float = 50, **settings: Mapping) -> Dict[str, Any]:
+    def setup(self, filter_order: Optional[int] = None, freq: float = 50,
+              **settings: Mapping) -> Dict[str, Any]:
         r"""Sets up the daq module to acquire time series data.
 
         See [1]_ for information on lock-in measurements.
 
         Parameters
         ----------
-        bandwidth : Union[str, float], optional
-            The demodulator noise-equivalent power (NEP) bandwidth.
-            The default is 'auto', in which case it is set to f_max/2.
-
-            The bandwidth is related to the time constant of the RC
-            filter by
-
-            .. math::
-
-                \tau = \frac{\Gamma\left(n - \frac{1}{2}\right)}
-                            {4\sqrt{\pi}f_\mathrm{NEP}\Gamma(n)},
-
-            where :math:`n` is the filter order
-
         filter_order : int, optional
             The filter order. Not changed if not given.
         freq : float, optional
@@ -211,9 +196,23 @@ class ZurichInstrumentsMFLIDAQ(_ZurichInstrumentsDevice):
         **settings : Mapping
             Additional settings for data acqusition.
 
+        Notes
+        -----
+        The demodulator 3 dB bandwidth is chosen as $f_\text{max}$. The
+        noise-equivalent power (NEP) bandwidth is related to the time
+        constant of the RC filter by
+
+        .. math::
+
+            \tau = \frac{\Gamma\left(n - \frac{1}{2}\right)}
+                        {4\sqrt{\pi}f_\mathrm{NEP}\Gamma(n)},
+
+        where :math:`n` is the filter order. By default, it is set to
+        ``fs/4``.
+
         Raises
         ------
-        RuntimeError
+        RuntimeError :
             If settings are incompatible with the hardware.
 
         Returns
@@ -226,18 +225,22 @@ class ZurichInstrumentsMFLIDAQ(_ZurichInstrumentsDevice):
         .. [1] https://www.zhinst.com/europe/en/resources/principles-of-lock-in-detection
 
         """
-        settings = self.DAQSettings(freq=freq, **settings).to_consistent_dict()
+        settings = self.DAQSettings(freq=freq, **settings)
+
+        if 'bandwidth' in settings:
+            warnings.warn('The bandwidth parameter has been replaced by f_max',
+                          DeprecationWarning)
 
-        if bandwidth == 'auto':
-            bandwidth = settings['f_max'] / 2
+        if 'f_max' not in settings:
+            settings.f_max = settings.fs / 4
 
-        if filter_order:
+        if filter_order is not None:
             self.device.demods[self.demod].order(int(filter_order))
 
         # BW 3dB = √(2^(1/n) - 1) / 2πτ
         # BW NEP = Γ(n - 1/2) / 4τ √(π)Γ(n)
         n = self.device.demods[self.demod].order()
-        tc = gamma(n - 0.5) / (4 * bandwidth * np.sqrt(np.pi) * gamma(n))
+        tc = gamma(n - 0.5) / (4 * settings['f_max'] * np.sqrt(np.pi) * gamma(n))
 
         # Do not use context manager here because somehow settings can get lost
         # with device.set_transaction():