Repository: scipy/scipy

Issue:
ENH: Clarify scipy.signal.spectrogram documentation (scaling description does not consider mode)
### Is your feature request related to a problem? Please describe.

I experienced some confusion when I was trying to compare results from scipy's spectrogram function and a custom spectrogram function I had written. It took some experimentation for me to figure out that while the documentation states the following:

> **scaling : { ‘density’, ‘spectrum’ }, optional**
> 
>     Selects between computing the power spectral density (‘density’) where Sxx has units of V\*\*2/Hz and computing the power spectrum (‘spectrum’) where Sxx has units of V\*\*2, if x is measured in V and fs is measured in Hz. Defaults to ‘density’.

This is actually only true if **`mode`** is set to `'psd'`. I had selected 'magnitude' because I naively thought since I was using `'spectrum'` and I wanted a power magnitude, `'psd'` was inappropriate.

https://github.com/scipy/scipy/blob/47bb6febaa10658c72962b9615d5d5aa2513fa3a/scipy/signal/spectral.py#L752-L762

https://github.com/scipy/scipy/blob/47bb6febaa10658c72962b9615d5d5aa2513fa3a/scipy/signal/spectral.py#L1810-L1811

https://github.com/scipy/scipy/blob/47bb6febaa10658c72962b9615d5d5aa2513fa3a/scipy/signal/spectral.py#L1834-L1842

For all other modes, `_spectral_helper` receives the mode parameter `stft`. This in turn means that when our spectrogram `mode` is not `psd`, the output is in *V/sqrt(Hz)* or *V*. It also silently ignores the one_sided parameter for scaling, but still omits negative frequency components. There is no mention of units in the `mode` help:

> **mode : str, optional**
>
>     Defines what kind of return values are expected. Options are [‘psd’, ‘complex’, ‘magnitude’, ‘angle’, ‘phase’]. ‘complex’ is equivalent to the output of stft with no padding or boundary extension. ‘magnitude’ returns the absolute magnitude of the STFT. ‘angle’ and ‘phase’ return the complex angle of the STFT, with and without unwrapping, respectively.


From the documentation, I had incorrectly assumed that the `Sxx` output of `scipy.signal.spectrogram` always had the units described in the section on scaling. The use of `psd` as the mode which returns the correct units for the `spectrum` scaling (one that is explicitly not a density) was also part of the problem.

### Describe the solution you'd like.

My preferred solution would be to extend the help for `scaling` to include reference to the `mode`. 

E.g.,

> When the **mode** is **psd**, this selects between computing the power spectral density (‘density’) where Sxx has units of V\*\*2/Hz and computing the power spectrum (‘spectrum’) where Sxx has units of V\*\*2, if x is measured in V and fs is measured in Hz. For other **mode**s, this selects between linear spectral density ('density') where Sxx has units of V/Hz\*\*(1/2) and computing the linear spectrum ('spectrum') where Sxx has units of V, if x is measured in V and fs is measured in Hz. 

This also has the benefit of not requiring any changes to the underlying math or existing functions.

### Describe alternatives you've considered.

Updating `mode` to use terminology less likely to cause confusion would be another solution.  As far as I can tell, `psd` is actually extracting the absolute square of `complex` mode, or the square of the `magnitude` mode, and is relevant for both power spectral density (PSD) and power spectrum (PS) scalings. Thus you could describe it as 'AbsSq'. But without changing the `scaling` help, some inexperienced users might expect this output to have units of `V**4`.  It would also cause compatibility problems. 

### Additional context (e.g. screenshots, GIFs)

It might also be useful to mention that `return_onesided` does not actually scale the results in any way when `mode` is not `psd`. The onesided scaling by 2 is correcting for omitted power, and other modes do not actually return power. 

If you reverse the math, you could argue that the positive frequencies of a double sided linear spectrum or spectral density could be multiplied by the square root of two to obtain the equivalent magnitude of a single sided linear spectrum or spectral density, but as far as I know this factor of square root of 2 does not have any obvious physical meaning in linear space, compared to the simple 'you lost half the power by omitting negative frequencies' explanation in power space.
Provide a crosspower spectrogram
The SciPy autopower spectrum `psd` has a crosspower spectrum `csd` counterpart, so it would make sense if the autopower `spectrogram` had a crosspower equivalent.

Our group has a prototype that is just a wrapper around `_spectral_helper.`  This seems cleaner than the alternative of iterating `csd` on a per-time-window basis.  


Discussion:
Hi, for reference, I looked into the variation of the parameters: The test signal is a 10 second single cosine with amplitude 10 V. Hence its energy is 500 V²s and its power is 50 V².

The table shows the STFT's  maximum and its spectral power depending on the input parameters:
```
Mode       Scaling    One-sided    max(S)       Power
---------  ---------  -----------  ---------  -------
psd        spectrum   True         50.0          10.8
psd        spectrum   False        25.0          10.8
psd        density    True         232.3         50
psd        density    False        116.1         50
complex    spectrum   True         5.0+0.0j       2
complex    spectrum   False        5.0+0.0j       4
complex    density    True         10.8+0.0j      4.3
complex    density    False        10.8+0.0j      8.6
magnitude  spectrum   True         5.0            2
magnitude  spectrum   False        5.0            4
magnitude  density    True         10.8           4.3
magnitude  density    False        10.8           8.6
```
Probably only those combinations, where either the power (50 V²) or the amplitude (5 V) for a given frequency can be derived, are useful in practice.

The table can be reproduced by the following code:

```python
from itertools import product

import numpy as np
from scipy import signal
from tabulate import tabulate

n = 500  # number of signal samples
T = 10 / n  # sampling interval in seconds
a = 10  # amplitude

# Create a cosine signal:
f_X = np.fft.rfftfreq(n, T)
X = np.zeros(len(f_X), dtype=complex)
X[len(f_X) // 2] = n * a / 2
x = np.fft.irfft(X)
t_x = np.arange(n) * T
print(f"x is a {n*T} s cosine with amplitude {x[0]}.")
print(f"Energy is {sum(x**2)*T:g} and power is {sum(x**2) / n:g}")


def gen_table():
    scalings = {'density', 'spectrum'}
    modes = ['psd', 'complex', 'magnitude']
    one_sided = (True, False)
    for m_, s_, o_ in product(modes, scalings, one_sided):
        f_S, t_S, S = signal.spectrogram(x, 1 / T, scaling=s_, mode=m_,
                                         return_onesided=o_)
        delta_f = f_S[1] - f_S[0]
        P_V = sum(abs(S[:, len(t_S) // 2])) * delta_f
        yield m_, s_, o_, f"{np.max(S):.1f}", f"{P_V:.1f}"


ss = tabulate(gen_table(), ('Mode', 'Scaling', 'One-sided', 'max(S)', 'Power'))
print(ss)
```

A PR for extending the documentation will follow.
@lamorton should post the prototype here.
@smithsp  Upon refreshing my memory, I think the best approach would be to generalize spectrogram to accept two signals of equal length, rather than create a separate function.  This would parallel the other periodogram functions. The `psd` function I was thinking of is part of pyplot, not SciPy.  

In order not to make breaking changes, the generalized spectrogram call signature could be designed to detect the case where only a single signal was supplied, & simply duplicate that signal as the second input. 
This is possible via two calls to `stft`. `np.conj(stft_x) * stft_y` will give you the complex "cross_spectrogram," subject to the scaling/units that you're after.  

Resolving patch:
diff --git a/scipy/signal/__init__.py b/scipy/signal/__init__.py
--- a/scipy/signal/__init__.py
+++ b/scipy/signal/__init__.py
@@ -283,8 +283,11 @@
    spectrogram    -- Compute the spectrogram.
    lombscargle    -- Computes the Lomb-Scargle periodogram.
    vectorstrength -- Computes the vector strength.
-   stft           -- Compute the Short Time Fourier Transform.
-   istft          -- Compute the Inverse Short Time Fourier Transform.
+   ShortTimeFFT   -- Interface for calculating the \
+                     :ref:`Short Time Fourier Transform <tutorial_stft>` and \
+                     its inverse.
+   stft           -- Compute the Short Time Fourier Transform (legacy).
+   istft          -- Compute the Inverse Short Time Fourier Transform (legacy).
    check_COLA     -- Check the COLA constraint for iSTFT reconstruction.
    check_NOLA     -- Check the NOLA constraint for iSTFT reconstruction.
 
@@ -329,6 +332,7 @@
 from ._signaltools import *
 from ._savitzky_golay import savgol_coeffs, savgol_filter
 from ._spectral_py import *
+from ._short_time_fft import *
 from ._wavelets import *
 from ._peak_finding import *
 from ._czt import *
diff --git a/scipy/signal/_short_time_fft.py b/scipy/signal/_short_time_fft.py
new file mode 100644
--- /dev/null
+++ b/scipy/signal/_short_time_fft.py
@@ -0,0 +1,1673 @@
+"""Implementation of an FFT-based Short-time Fourier Transform. """
+
+# Implementation Notes for this file (as of 2023-07)
+# --------------------------------------------------
+# * MyPy version 1.1.1 does not seem to support decorated property methods
+#   properly. Hence, applying ``@property`` to methods decorated with `@cache``
+#   (as tried with the ``lower_border_end`` method) causes a mypy error when
+#   accessing it as an index (e.g., ``SFT.lower_border_end[0]``).
+# * Since the method `stft` and `istft` have identical names as the legacy
+#   functions in the signal module, referencing them as HTML link in the
+#   docstrings has to be done by an explicit `~ShortTimeFFT.stft` instead of an
+#   ambiguous `stft` (The ``~`` hides the class / module name).
+# * The HTML documentation currently renders each method/property on a separate
+#   page without reference to the parent class. Thus, a link to `ShortTimeFFT`
+#   was added to the "See Also" section of each method/property. These links
+#   can be removed, when SciPy updates ``pydata-sphinx-theme`` to >= 0.13.3
+#   (currently 0.9). Consult Issue 18512 and PR 16660 for further details.
+#
+
+# Provides typing union operator ``|`` in Python 3.9:
+from __future__ import annotations
+# Linter does not allow to import ``Generator`` from ``typing`` module:
+from collections.abc import Generator
+from functools import cache, lru_cache, partial
+from typing import Callable, get_args, Literal, Union
+
+import numpy as np
+
+import scipy.fft as fft_lib
+from scipy.signal import detrend
+from scipy.signal.windows import get_window
+
+__all__ = ['ShortTimeFFT']
+
+
+#: Allowed values for parameter `padding` of method `ShortTimeFFT.stft()`:
+PAD_TYPE = Literal['zeros', 'edge', 'even', 'odd']
+
+#: Allowed values for property `ShortTimeFFT.fft_mode`:
+FFT_MODE_TYPE = Literal['twosided', 'centered', 'onesided', 'onesided2X']
+
+
+def _calc_dual_canonical_window(win: np.ndarray, hop: int) -> np.ndarray:
+    """Calculate canonical dual window for 1d window `win` and a time step
+    of `hop` samples.
+
+    A ``ValueError`` is raised, if the inversion fails.
+
+    This is a separate function not a method, since it is also used in the
+    class method ``ShortTimeFFT.from_dual()``.
+    """
+    if hop > len(win):
+        raise ValueError(f"{hop=} is larger than window length of {len(win)}" +
+                         " => STFT not invertible!")
+    if issubclass(win.dtype.type, np.integer):
+        raise ValueError("Parameter 'win' cannot be of integer type, but " +
+                         f"{win.dtype=} => STFT not invertible!")
+        # The calculation of `relative_resolution` does not work for ints.
+        # Furthermore, `win / DD` casts the integers away, thus an implicit
+        # cast is avoided, which can always cause confusion when using 32-Bit
+        # floats.
+
+    w2 = win.real**2 + win.imag**2  # win*win.conj() does not ensure w2 is real
+    DD = w2.copy()
+    for k_ in range(hop, len(win), hop):
+        DD[k_:] += w2[:-k_]
+        DD[:-k_] += w2[k_:]
+
+    # check DD > 0:
+    relative_resolution = np.finfo(win.dtype).resolution * max(DD)
+    if not np.all(DD >= relative_resolution):
+        raise ValueError("Short-time Fourier Transform not invertible!")
+
+    return win / DD
+
+
+# noinspection PyShadowingNames
+class ShortTimeFFT:
+    r"""Provide a parametrized discrete Short-time Fourier transform (stft)
+    and its inverse (istft).
+
+    .. currentmodule:: scipy.signal.ShortTimeFFT
+
+    The `~ShortTimeFFT.stft` calculates sequential FFTs by sliding a
+    window (`win`) over an input signal by `hop` increments. It can be used to
+    quantify the change of the spectrum over time.
+
+    The `~ShortTimeFFT.stft` is represented by a complex-valued matrix S[q,p]
+    where the p-th column represents an FFT with the window centered at the
+    time t[p] = p * `delta_t` = p * `hop` * `T` where `T` is  the sampling
+    interval of the input signal. The q-th row represents the values at the
+    frequency f[q] = q * `delta_f` with `delta_f` = 1 / (`mfft` * `T`) being
+    the bin width of the FFT.
+
+    The inverse STFT `~ShortTimeFFT.istft` is calculated by reversing the steps
+    of the STFT: Take the IFFT of the p-th slice of S[q,p] and multiply the
+    result with the so-called dual window (see `dual_win`). Shift the result by
+    p * `delta_t` and add the result to previous shifted results to reconstruct
+    the signal. If only the dual window is known and the STFT is invertible,
+    `from_dual` can be used to instanciate this class.
+
+    Due to the convention of time t = 0 being at the first sample of the input
+    signal, the STFT values typically have negative time slots. Hence,
+    negative indexes like `p_min` or `k_min` do not indicate counting
+    backwards from an array's end like in standard Python indexing but being
+    left of t = 0.
+
+    More detailed information can be found in the :ref:`tutorial_stft` section
+    of the :ref:`user_guide`.
+
+    Note that all parameters of the initializer, except `scale_to` (which uses
+    `scaling`) have identical named attributes.
+
+    Parameters
+    ----------
+    win : np.ndarray
+        The window must be a real- or complex-valued 1d array.
+    hop : int
+        The increment in samples, by which the window is shifted in each step.
+    fs : float
+        Sampling frequency of input signal and window. Its relation to the
+        sampling interval `T` is ``T = 1 / fs``.
+    fft_mode : 'twosided', 'centered', 'onesided', 'onesided2X'
+        Mode of FFT to be used (default 'onesided').
+        See property `fft_mode` for details.
+    mfft: int | None
+        Length of the FFT used, if a zero padded FFT is desired.
+        If ``None`` (default), the length of the window `win` is used.
+    dual_win : np.ndarray | None
+        The dual window of `win`. If set to ``None``, it is calculated if
+        needed.
+    scale_to : 'magnitude', 'psd' | None
+        If not ``None`` (default) the window function is scaled, so each STFT
+        column represents  either a 'magnitude' or a power spectral density
+        ('psd') spectrum. This parameter sets the property `scaling` to the
+        same value. See method `scale_to` for details.
+    phase_shift : int | None
+        If set, add a linear phase `phase_shift` / `mfft` * `f` to each
+        frequency `f`. The default value 0 ensures that there is no phase shift
+        on the zeroth slice (in which t=0 is centered). See property
+        `phase_shift` for more details.
+
+    Examples
+    --------
+    The following example shows the magnitude of the STFT of a sine with
+    varying frequency :math:`f_i(t)` (marked by a red dashed line in the plot):
+
+    >>> import numpy as np
+    >>> import matplotlib.pyplot as plt
+    >>> from scipy.signal import ShortTimeFFT
+    >>> from scipy.signal.windows import gaussian
+    ...
+    >>> T_x, N = 1 / 20, 1000  # 20 Hz sampling rate for 50 s signal
+    >>> t_x = np.arange(N) * T_x  # time indexes for signal
+    >>> f_i = 1 * np.arctan((t_x - t_x[N // 2]) / 2) + 5  # varying frequency
+    >>> x = np.sin(2*np.pi*np.cumsum(f_i)*T_x) # the signal
+
+    The utilized Gaussian window is 50 samples or 2.5 s long. The parameter
+    ``mfft=200`` in `ShortTimeFFT` causes the spectrum to be oversampled
+    by a factor of 4:
+
+    >>> g_std = 8  # standard deviation for Gaussian window in samples
+    >>> w = gaussian(50, std=g_std, sym=True)  # symmetric Gaussian window
+    >>> SFT = ShortTimeFFT(w, hop=10, fs=1/T_x, mfft=200, scale_to='magnitude')
+    >>> Sx = SFT.stft(x)  # perform the STFT
+
+    In the plot, the time extent of the signal `x` is marked by vertical dashed
+    lines. Note that the SFT produces values outside the time range of `x`. The
+    shaded areas on the left and the right indicate border effects caused
+    by  the window slices in that area not fully being inside time range of
+    `x`:
+
+    >>> fig1, ax1 = plt.subplots(figsize=(6., 4.))  # enlarge plot a bit
+    >>> t_lo, t_hi = SFT.extent(N)[:2]  # time range of plot
+    >>> ax1.set_title(rf"STFT ({SFT.m_num*SFT.T:g}$\,s$ Gaussian window, " +
+    ...               rf"$\sigma_t={g_std*SFT.T}\,$s)")
+    >>> ax1.set(xlabel=f"Time $t$ in seconds ({SFT.p_num(N)} slices, " +
+    ...                rf"$\Delta t = {SFT.delta_t:g}\,$s)",
+    ...         ylabel=f"Freq. $f$ in Hz ({SFT.f_pts} bins, " +
+    ...                rf"$\Delta f = {SFT.delta_f:g}\,$Hz)",
+    ...         xlim=(t_lo, t_hi))
+    ...
+    >>> im1 = ax1.imshow(abs(Sx), origin='lower', aspect='auto',
+    ...                  extent=SFT.extent(N), cmap='viridis')
+    >>> ax1.plot(t_x, f_i, 'r--', alpha=.5, label='$f_i(t)$')
+    >>> fig1.colorbar(im1, label="Magnitude $|S_x(t, f)|$")
+    ...
+    >>> # Shade areas where window slices stick out to the side:
+    >>> for t0_, t1_ in [(t_lo, SFT.lower_border_end[0] * SFT.T),
+    ...                  (SFT.upper_border_begin(N)[0] * SFT.T, t_hi)]:
+    ...     ax1.axvspan(t0_, t1_, color='w', linewidth=0, alpha=.2)
+    >>> for t_ in [0, N * SFT.T]:  # mark signal borders with vertical line:
+    ...     ax1.axvline(t_, color='y', linestyle='--', alpha=0.5)
+    >>> ax1.legend()
+    >>> fig1.tight_layout()
+    >>> plt.show()
+
+    Reconstructing the signal with the `~ShortTimeFFT.istft` is
+    straightforward, but note that the length of `x1` should be specified,
+    since the SFT length increases in `hop` steps:
+
+    >>> SFT.invertible  # check if invertible
+    True
+    >>> x1 = SFT.istft(Sx, k1=N)
+    >>> np.allclose(x, x1)
+    True
+
+    It is possible to calcluate the SFT of signal parts:
+
+    >>> p_q = SFT.nearest_k_p(N // 2)
+    >>> Sx0 = SFT.stft(x[:p_q])
+    >>> Sx1 = SFT.stft(x[p_q:])
+
+    When assembling sequential STFT parts together, the overlap needs to be
+    considered:
+
+    >>> p0_ub = SFT.upper_border_begin(p_q)[1] - SFT.p_min
+    >>> p1_le = SFT.lower_border_end[1] - SFT.p_min
+    >>> Sx01 = np.hstack((Sx0[:, :p0_ub],
+    ...                   Sx0[:, p0_ub:] + Sx1[:, :p1_le],
+    ...                   Sx1[:, p1_le:]))
+    >>> np.allclose(Sx01, Sx)  # Compare with SFT of complete signal
+    True
+
+    It is also possible to calculate the `itsft` for signal parts:
+
+    >>> y_p = SFT.istft(Sx, N//3, N//2)
+    >>> np.allclose(y_p, x[N//3:N//2])
+    True
+
+    """
+    # immutable attributes (only have getters but no setters):
+    _win: np.ndarray  # window
+    _dual_win: np.ndarray | None = None  # canonical dual window
+    _hop: int  # Step of STFT in number of samples
+
+    # mutable attributes:
+    _fs: float  # sampling frequency of input signal and window
+    _fft_mode: FFT_MODE_TYPE = 'onesided'  # Mode of FFT to use
+    _mfft: int  # length of FFT used - defaults to len(win)
+    _scaling: Literal['magnitude', 'psd'] | None = None  # Scaling of _win
+    _phase_shift: int | None  # amount to shift phase of FFT in samples
+
+    # attributes for caching calculated values:
+    _fac_mag: float | None = None
+    _fac_psd: float | None = None
+    _lower_border_end: tuple[int, int] | None = None
+
+    def __init__(self, win: np.ndarray, hop: int, fs: float, *,
+                 fft_mode: FFT_MODE_TYPE = 'onesided',
+                 mfft: int | None = None,
+                 dual_win: np.ndarray | None = None,
+                 scale_to: Literal['magnitude', 'psd'] | None = None,
+                 phase_shift: int | None = 0):
+        if not (win.ndim == 1 and win.size > 0):
+            raise ValueError(f"Parameter win must be 1d, but {win.shape=}!")
+        if not all(np.isfinite(win)):
+            raise ValueError("Parameter win must have finite entries!")
+        if not (hop >= 1 and isinstance(hop, int)):
+            raise ValueError(f"Parameter {hop=} is not an integer >= 1!")
+        self._win, self._hop, self.fs = win, hop, fs
+
+        self.mfft = len(win) if mfft is None else mfft
+
+        if dual_win is not None:
+            if dual_win.shape != win.shape:
+                raise ValueError(f"{dual_win.shape=} must equal {win.shape=}!")
+            if not all(np.isfinite(dual_win)):
+                raise ValueError("Parameter dual_win must be a finite array!")
+        self._dual_win = dual_win  # needs to be set before scaling
+
+        if scale_to is not None:  # needs to be set before fft_mode
+            self.scale_to(scale_to)
+
+        self.fft_mode, self.phase_shift = fft_mode, phase_shift
+
+    @classmethod
+    def from_dual(cls, dual_win: np.ndarray, hop: int, fs: float, *,
+                  fft_mode: FFT_MODE_TYPE = 'onesided',
+                  mfft: int | None = None,
+                  scale_to: Literal['magnitude', 'psd'] | None = None,
+                  phase_shift: int | None = 0):
+        r"""Instantiate a `ShortTimeFFT` by only providing a dual window.
+
+        If an STFT is invertible, it is possible to calculate the window `win`
+        from a given dual window `dual_win`. All other parameters have the
+        same meaning as in the initializer of `ShortTimeFFT`.
+
+        As explained in the :ref:`tutorial_stft` section of the
+        :ref:`user_guide`, an invertible STFT can be interpreted as series
+        expansion of time-shifted and frequency modulated dual windows. E.g.,
+        the series coefficient S[q,p] belongs to the term, which shifted
+        `dual_win` by p * `delta_t` and multiplied it by
+        exp( 2 * j * pi * t * q * `delta_f`).
+
+
+        Examples
+        --------
+        The following example discusses decomposing a signal into time- and
+        frequency-shifted Gaussians. A Gaussian with standard deviation of
+        one made up of 51 samples will be used:
+
+        >>> import numpy as np
+        >>> import matplotlib.pyplot as plt
+        >>> from scipy.signal import ShortTimeFFT
+        >>> from scipy.signal.windows import gaussian
+        ...
+        >>> T, N = 0.1, 51
+        >>> d_win = gaussian(N, std=1/T, sym=True)  # symmetric Gaussian window
+        >>> t = T * (np.arange(N) - N//2)
+        ...
+        >>> fg1, ax1 = plt.subplots()
+        >>> ax1.set_title(r"Dual Window: Gaussian with $\sigma_t=1$")
+        >>> ax1.set(xlabel=f"Time $t$ in seconds ({N} samples, $T={T}$ s)",
+        ...        xlim=(t[0], t[-1]), ylim=(0, 1.1*max(d_win)))
+        >>> ax1.plot(t, d_win, 'C0-')
+
+        The following plot with the overlap of 41, 11 and 2 samples show how
+        the `hop` interval affects the shape of the window `win`:
+
+        >>> fig2, axx = plt.subplots(3, 1, sharex='all')
+        ...
+        >>> axx[0].set_title(r"Windows for hop$\in\{10, 40, 49\}$")
+        >>> for c_, h_ in enumerate([10, 40, 49]):
+        ...     SFT = ShortTimeFFT.from_dual(d_win, h_, 1/T)
+        ...     axx[c_].plot(t + h_ * T, SFT.win, 'k--', alpha=.3, label=None)
+        ...     axx[c_].plot(t - h_ * T, SFT.win, 'k:', alpha=.3, label=None)
+        ...     axx[c_].plot(t, SFT.win, f'C{c_+1}',
+        ...                     label=r"$\Delta t=%0.1f\,$s" % SFT.delta_t)
+        ...     axx[c_].set_ylim(0, 1.1*max(SFT.win))
+        ...     axx[c_].legend(loc='center')
+        >>> axx[-1].set(xlabel=f"Time $t$ in seconds ({N} samples, $T={T}$ s)",
+        ...             xlim=(t[0], t[-1]))
+        >>> plt.show()
+
+        Beside the window `win` centered at t = 0 the previous (t = -`delta_t`)
+        and following window (t = `delta_t`) are depicted. It can be seen that
+        for small `hop` intervals, the window is compact and smooth, having a
+        good time-frequency concentration in the STFT. For the large `hop`
+        interval of 4.9 s, the window has small values around t = 0, which are
+        not covered by the overlap of the adjacent windows, which could lead to
+        numeric inaccuracies. Furthermore, the peaky shape at the beginning and
+        the end of the window points to a higher bandwidth, resulting in a
+        poorer time-frequency resolution of the STFT.
+        Hence, the choice of the `hop` interval will be a compromise between
+        a time-frequency resolution and memory requirements demanded by small
+        `hop` sizes.
+
+        See Also
+        --------
+        from_window: Create instance by wrapping `get_window`.
+        ShortTimeFFT: Create instance using standard initalizer.
+        """
+        win = _calc_dual_canonical_window(dual_win, hop)
+        return cls(win=win, hop=hop, fs=fs, fft_mode=fft_mode, mfft=mfft,
+                   dual_win=dual_win, scale_to=scale_to,
+                   phase_shift=phase_shift)
+
+    @classmethod
+    def from_window(cls, win_param: Union[str, tuple, float],
+                    fs: float, nperseg: int, noverlap: int, *,
+                    symmetric_win: bool = False,
+                    fft_mode: FFT_MODE_TYPE = 'onesided',
+                    mfft: int | None = None,
+                    scale_to: Literal['magnitude', 'psd'] | None = None,
+                    phase_shift: int | None = 0):
+        """Instantiate `ShortTimeFFT` by using `get_window`.
+
+        The method `get_window` is used to create a window of length
+        `nperseg`. The parameter names `noverlap`, and `nperseg` are used here,
+        since they more inline with other classical STFT libraries.
+
+        Parameters
+        ----------
+        win_param: Union[str, tuple, float],
+            Parameters passed to `get_window`. For windows with no parameters,
+            it may be a string (e.g., ``'hann'``), for parametrized windows a
+            tuple, (e.g., ``('gaussian', 2.)``) or a single float specifying
+            the shape parameter of a kaiser window (i.e. ``4.``  and
+            ``('kaiser', 4.)`` are equal. See `get_window` for more details.
+        fs : float
+            Sampling frequency of input signal. Its relation to the
+            sampling interval `T` is ``T = 1 / fs``.
+        nperseg: int
+            Window length in samples, which corresponds to the `m_num`.
+        noverlap: int
+            Window overlap in samples. It relates to the `hop` increment by
+            ``hop = npsereg - noverlap``.
+        symmetric_win: bool
+            If ``True`` then a symetric window is generated, else a periodic
+            window is generated (default). Though symmetric windows seem for
+            most applications to be more sensible, the default of a periodic
+            windows was chosen to correspond to the default of `get_window`.
+        fft_mode : 'twosided', 'centered', 'onesided', 'onesided2X'
+            Mode of FFT to be used (default 'onesided').
+            See property `fft_mode` for details.
+        mfft: int | None
+            Length of the FFT used, if a zero padded FFT is desired.
+            If ``None`` (default), the length of the window `win` is used.
+        scale_to : 'magnitude', 'psd' | None
+            If not ``None`` (default) the window function is scaled, so each
+            STFT column represents  either a 'magnitude' or a power spectral
+            density ('psd') spectrum. This parameter sets the property
+            `scaling` to the same value. See method `scale_to` for details.
+        phase_shift : int | None
+            If set, add a linear phase `phase_shift` / `mfft` * `f` to each
+            frequency `f`. The default value 0 ensures that there is no phase
+            shift on the zeroth slice (in which t=0 is centered). See property
+            `phase_shift` for more details.
+
+        Examples
+        --------
+        The following instances ``SFT0`` and ``SFT1`` are equivalent:
+
+        >>> from scipy.signal import ShortTimeFFT, get_window
+        >>> nperseg = 9  # window length
+        >>> w = get_window(('gaussian', 2.), nperseg)
+        >>> fs = 128  # sampling frequency
+        >>> hop = 3  # increment of STFT time slice
+        >>> SFT0 = ShortTimeFFT(w, hop, fs=fs)
+        >>> SFT1 = ShortTimeFFT.from_window(('gaussian', 2.), fs, nperseg,
+        ...                                 noverlap=nperseg-hop)
+
+        See Also
+        --------
+        scipy.signal.get_window: Return a window of a given length and type.
+        from_dual: Create instance using dual window.
+        ShortTimeFFT: Create instance using standard initalizer.
+        """
+        win = get_window(win_param, nperseg, fftbins=not symmetric_win)
+        return cls(win, hop=nperseg-noverlap, fs=fs, fft_mode=fft_mode,
+                   mfft=mfft, scale_to=scale_to, phase_shift=phase_shift)
+
+    @property
+    def win(self) -> np.ndarray:
+        """Window function as real- or complex-valued 1d array.
+
+        This attribute is read only, since `dual_win` depends on it.
+
+        See Also
+        --------
+        dual_win: Canonical dual window.
+        m_num: Number of samples in window `win`.
+        m_num_mid: Center index of window `win`.
+        mfft: Length of input for the FFT used - may be larger than `m_num`.
+        hop: ime increment in signal samples for sliding window.
+        win: Window function as real- or complex-valued 1d array.
+        ShortTimeFFT: Class this property belongs to.
+        """
+        return self._win
+
+    @property
+    def hop(self) -> int:
+        """Time increment in signal samples for sliding window.
+
+        This attribute is read only, since `dual_win` depends on it.
+
+        See Also
+        --------
+        delta_t: Time increment of STFT (``hop*T``)
+        m_num: Number of samples in window `win`.
+        m_num_mid: Center index of window `win`.
+        mfft: Length of input for the FFT used - may be larger than `m_num`.
+        T: Sampling interval of input signal and of the window.
+        win: Window function as real- or complex-valued 1d array.
+        ShortTimeFFT: Class this property belongs to.
+        """
+        return self._hop
+
+    @property
+    def T(self) -> float:
+        """Sampling interval of input signal and of the window.
+
+        A ``ValueError`` is raised if it is set to a non-positive value.
+
+        See Also
+        --------
+        delta_t: Time increment of STFT (``hop*T``)
+        hop: Time increment in signal samples for sliding window.
+        fs: Sampling frequency (being ``1/T``)
+        t: Times of STFT for an input signal with `n` samples.
+        ShortTimeFFT: Class this property belongs to.
+        """
+        return 1 / self._fs
+
+    @T.setter
+    def T(self, v: float):
+        """Sampling interval of input signal and of the window.
+
+        A ``ValueError`` is raised if it is set to a non-positive value.
+        """
+        if not (v > 0):
+            raise ValueError(f"Sampling interval T={v} must be positive!")
+        self._fs = 1 / v
+
+    @property
+    def fs(self) -> float:
+        """Sampling frequency of input signal and of the window.
+
+        The sampling frequency is the inverse of the sampling interval `T`.
+        A ``ValueError`` is raised if it is set to a non-positive value.
+
+        See Also
+        --------
+        delta_t: Time increment of STFT (``hop*T``)
+        hop: Time increment in signal samples for sliding window.
+        T: Sampling interval of input signal and of the window (``1/fs``).
+        ShortTimeFFT: Class this property belongs to.
+        """
+        return self._fs
+
+    @fs.setter
+    def fs(self, v: float):
+        """Sampling frequency of input signal and of the window.
+
+        The sampling frequency is the inverse of the sampling interval `T`.
+        A ``ValueError`` is raised if it is set to a non-positive value.
+        """
+        if not (v > 0):
+            raise ValueError(f"Sampling frequency fs={v} must be positive!")
+        self._fs = v
+
+    @property
+    def fft_mode(self) -> FFT_MODE_TYPE:
+        """Mode of utilized FFT ('twosided', 'centered', 'onesided' or
+        'onesided2X').
+
+        It can have the following values:
+
+        'twosided':
+            Two-sided FFT, where values for the negative frequencies are in
+            upper half of the array. Corresponds to :func:`scipy.fft.fft()`.
+        'centered':
+            Two-sided FFT with the values being ordered along monotonically
+            increasing frequencies. Corresponds to applying
+            :func:`scipy.fft.fftshift()` to :func:`scipy.fft.fft()`.
+        'onesided':
+            Calculates only values for non-negative frequency values.
+            Corresponds to :func:`scipy.fft.rfft()`.
+        'onesided2X':
+            Like `onesided`, but the non-zero frequencies are doubled if
+            `scaling` is set to 'magnitude' or multiplied by ``sqrt(2)`` if
+            set to 'psd'. If `scaling` is ``None``, setting `fft_mode` to
+            `onesided2X` is not allowed.
+            If the FFT length `mfft` is even, the last FFT value is not paired,
+            and thus it is not scaled.
+
+        Note that the frequency values can be obtained by reading the `f`
+        property, and the number of samples by accessing the `f_pts` property.
+
+        See Also
+        --------
+        delta_f: Width of the frequency bins of the STFT.
+        f: Frequencies values of the STFT.
+        f_pts: Width of the frequency bins of the STFT.
+        onesided_fft: True if a one-sided FFT is used.
+        scaling: Normalization applied to the window function
+        ShortTimeFFT: Class this property belongs to.
+        """
+        return self._fft_mode
+
+    @fft_mode.setter
+    def fft_mode(self, t: FFT_MODE_TYPE):
+        """Set mode of FFT.
+
+        Allowed values are 'twosided', 'centered', 'onesided', 'onesided2X'.
+        See the property `fft_mode` for more details.
+        """
+        if t not in (fft_mode_types := get_args(FFT_MODE_TYPE)):
+            raise ValueError(f"fft_mode='{t}' not in {fft_mode_types}!")
+
+        if t in {'onesided', 'onesided2X'} and np.iscomplexobj(self.win):
+            raise ValueError(f"One-sided spectra, i.e., fft_mode='{t}', " +
+                             "are not allowed for complex-valued windows!")
+
+        if t == 'onesided2X' and self.scaling is None:
+            raise ValueError(f"For scaling is None, fft_mode='{t}' is invalid!"
+                             "Do scale_to('psd') or scale_to('magnitude')!")
+        self._fft_mode = t
+
+    @property
+    def mfft(self) -> int:
+        """Length of input for the FFT used - may be larger than window
+        length `m_num`.
+
+        If not set, `mfft` defaults to the window length `m_num`.
+
+        See Also
+        --------
+        f_pts: Number of points along the frequency axis.
+        f: Frequencies values of the STFT.
+        m_num: Number of samples in window `win`.
+        ShortTimeFFT: Class this property belongs to.
+        """
+        return self._mfft
+
+    @mfft.setter
+    def mfft(self, n_: int):
+        """Setter for the length of FFT utilized.
+
+        See the property `mfft` for further details.
+        """
+        if not (n_ >= self.m_num):
+            raise ValueError(f"Attribute mfft={n_} needs to be at least the " +
+                             f"window length m_num={self.m_num}!")
+        self._mfft = n_
+
+    @property
+    def scaling(self) -> Literal['magnitude', 'psd'] | None:
+        """Normalization applied to the window function
+        ('magnitude', 'psd' or ``None``).
+
+        If not ``None``, the FFTs can be either interpreted as a magnitude or
+        a power spectral density spectrum.
+
+        The window function can be scaled by calling the `scale_to()` method,
+        or it is set by the initializer parameter `scale_to`.
+
+        See Also
+        --------
+        fac_magnitude: Scaling factor for to a magnitude spectrum.
+        fac_psd: Scaling factor for to  a power spectral density spectrum.
+        fft_mode: Mode of utilized FFT
+        scale_to: Scale window to obtain 'magnitude' or 'psd' scaling.
+        ShortTimeFFT: Class this property belongs to.
+        """
+        return self._scaling
+
+    def scale_to(self, scaling: Literal['magnitude', 'psd']):
+        """Scale window to obtain 'magnitude' or 'psd' scaling for the STFT.
+
+        The window of a 'magnitude' spectrum has an integral of one, i.e., unit
+        area for non-negative windows. This ensures that absolute the values of
+        spectrum does not change if the length of the window changes (given
+        the input signal is stationary).
+
+        To represent the power spectral density ('psd') for varying length
+        windows the area of the absolute square of the window needs to be
+        unity.
+
+        The `scaling` property shows the current scaling. The properties
+        `fac_magnitude` and `fac_psd` show the scaling factors required to
+        scale the STFT values to a magnitude or a psd spectrum.
+
+        This method is called, if the initializer parameter `scale_to` is set.
+
+        See Also
+        --------
+        fac_magnitude: Scaling factor for to  a magnitude spectrum.
+        fac_psd: Scaling factor for to  a power spectral density spectrum.
+        fft_mode: Mode of utilized FFT
+        scaling: Normalization applied to the window function.
+        ShortTimeFFT: Class this method belongs to.
+        """
+        if scaling not in (scaling_values := {'magnitude', 'psd'}):
+            raise ValueError(f"{scaling=} not in {scaling_values}!")
+        if self._scaling == scaling:  # do nothing
+            return
+
+        s_fac = self.fac_psd if scaling == 'psd' else self.fac_magnitude
+        self._win = self._win * s_fac
+        if self._dual_win is not None:
+            self._dual_win = self._dual_win / s_fac
+        self._fac_mag, self._fac_psd = None, None  # reset scaling factors
+        self._scaling = scaling
+
+    @property
+    def phase_shift(self) -> int | None:
+        """If set, add linear phase `phase_shift` / `mfft` * `f` to each FFT
+        slice of frequency `f`.
+
+        Shifting (more precisely `rolling`) an `mfft`-point FFT input by
+        `phase_shift` samples results in a multiplication of the output by
+        ``np.exp(2j*np.pi*q*phase_shift/mfft)`` at the frequency q * `delta_f`.
+
+        The default value 0 ensures that there is no phase shift on the
+        zeroth slice (in which t=0 is centered).
+        No phase shift (``phase_shift is None``) is equivalent to
+        ``phase_shift = -mfft//2``. In this case slices are not shifted
+        before calculating the FFT.
+
+        The absolute value of `phase_shift` is limited to be less than `mfft`.
+
+        See Also
+        --------
+        delta_f: Width of the frequency bins of the STFT.
+        f: Frequencies values of the STFT.
+        mfft: Length of input for the FFT used
+        ShortTimeFFT: Class this property belongs to.
+        """
+        return self._phase_shift
+
+    @phase_shift.setter
+    def phase_shift(self, v: int | None):
+        """The absolute value of the phase shift needs to be less than mfft
+        samples.
+
+        See the `phase_shift` getter method for more details.
+        """
+        if v is None:
+            self._phase_shift = v
+            return
+        if not isinstance(v, int):
+            raise ValueError(f"phase_shift={v} has the unit samples. Hence " +
+                             "it needs to be an int or it may be None!")
+        if not (-self.mfft < v < self.mfft):
+            raise ValueError("-mfft < phase_shift < mfft does not hold " +
+                             f"for mfft={self.mfft}, phase_shift={v}!")
+        self._phase_shift = v
+
+    def _x_slices(self, x: np.ndarray, k_off: int, p0: int, p1: int,
+                  padding: PAD_TYPE) -> Generator[np.ndarray, None, None]:
+        """Generate signal slices along last axis of `x`.
+
+        This method is only used by `stft_detrend`. The parameters are
+        described in `~ShortTimeFFT.stft`.
+        """
+        if padding not in (padding_types := get_args(PAD_TYPE)):
+            raise ValueError(f"Parameter {padding=} not in {padding_types}!")
+        pad_kws: dict[str, dict] = {  # possible keywords to pass to np.pad:
+            'zeros': dict(mode='constant', constant_values=(0, 0)),
+            'edge': dict(mode='edge'),
+            'even': dict(mode='reflect', reflect_type='even'),
+            'odd': dict(mode='reflect', reflect_type='odd'),
+           }  # typing of pad_kws is needed to make mypy happy
+
+        n, n1 = x.shape[-1], (p1 - p0) * self.hop
+        k0 = p0 * self.hop - self.m_num_mid + k_off  # start sample
+        k1 = k0 + n1 + self.m_num  # end sample
+
+        i0, i1 = max(k0, 0), min(k1, n)  # indexes to shorten x
+        # dimensions for padding x:
+        pad_width = [(0, 0)] * (x.ndim-1) + [(-min(k0, 0), max(k1 - n, 0))]
+
+        x1 = np.pad(x[..., i0:i1], pad_width, **pad_kws[padding])
+        for k_ in range(0, n1, self.hop):
+            yield x1[..., k_:k_ + self.m_num]
+
+    def stft(self, x: np.ndarray, p0: int | None = None,
+             p1: int | None = None, *, k_offset: int = 0,
+             padding: PAD_TYPE = 'zeros', axis: int = -1) \
+            -> np.ndarray:
+        """Perform the short-time Fourier transform.
+
+        A two-dimensional matrix with ``p1-p0`` columns is calculated.
+        The `f_pts` rows represent value at the frequencies `f`. The q-th
+        column of the windowed FFT with the window `win` is centered at t[q].
+        The columns represent the values at the frequencies `f`.
+
+        Parameters
+        ----------
+        x
+            The input signal as real or complex valued array.
+        p0
+            The first element of the range of slices to calculate. If ``None``
+            then it is set to :attr:`p_min`, which is the smallest possible
+            slice.
+        p1
+            The end of the array. If ``None`` then `p_max(n)` is used.
+        k_offset
+            Index of first sample (t = 0) in `x`.
+        padding
+            Kind of values which are added, when the sliding window sticks out
+            on either the lower or upper end of the input `x`. Zeros are added
+            if the default 'zeros' is set. For 'edge' either the first or the
+            last value of `x` is used. 'even' pads by reflecting the
+            signal on the first or last sample and 'odd' additionally
+            multiplies it with -1.
+        axis
+            The axis of `x` over which to compute the STFT.
+            If not given, the last axis is used.
+
+        Returns
+        -------
+        S
+            A complex array is returned with the dimension always being larger
+            by one than of `x`. The last axis always represent the time slices
+            of the STFT. `axis` defines the frequency axis (default second to
+            last). E.g., for a one-dimensional `x`, a complex 2d array is
+            returned, with axis 0 representing frequency and axis 1 the time
+            slices.
+
+        See Also
+        --------
+        delta_f: Width of the frequency bins of the STFT.
+        delta_t: Time increment of STFT
+        f: Frequencies values of the STFT.
+        invertible: Check if STFT is invertible.
+        :meth:`~ShortTimeFFT.istft`: Inverse short-time Fourier transform.
+        p_range: Determine and validate slice index range.
+        stft_detrend: STFT with detrended segments.
+        t: Times of STFT for an input signal with `n` samples.
+        :class:`scipy.signal.ShortTimeFFT`: Class this method belongs to.
+        """
+        return self.stft_detrend(x, None, p0, p1, k_offset=k_offset,
+                                 padding=padding, axis=axis)
+
+    def stft_detrend(self, x: np.ndarray,
+                     detr: Union[Callable[[np.ndarray], np.ndarray],
+                                 Literal['linear', 'constant'], None],
+                     p0: int | None = None, p1: int | None = None, *,
+                     k_offset: int = 0, padding: PAD_TYPE = 'zeros',
+                     axis: int = -1) \
+            -> np.ndarray:
+        """Short-time Fourier transform with a trend being subtracted from each
+        segment beforehand.
+
+        If `detr` is set to 'constant', the mean is subtracted, if set to
+        "linear", the linear trend is removed. This is achieved by calling
+        :func:`scipy.signal.detrend`. If `detr` is a function, `detr` is
+        applied to each segment.
+        All other parameters have the same meaning as in `~ShortTimeFFT.stft`.
+
+        Note that due to the detrending, the original signal cannot be
+        reconstructed by the `~ShortTimeFFT.istft`.
+
+        See Also
+        --------
+        invertible: Check if STFT is invertible.
+        :meth:`~ShortTimeFFT.istft`: Inverse short-time Fourier transform.
+        :meth:`~ShortTimeFFT.stft`: Short-time Fourier transform
+                                   (without detrending).
+        :class:`scipy.signal.ShortTimeFFT`: Class this method belongs to.
+        """
+        if isinstance(detr, str):
+            detr = partial(detrend, type=detr)
+        elif not (detr is None or callable(detr)):
+            raise ValueError(f"Parameter {detr=} is not a str, function or " +
+                             "None!")
+        n = x.shape[axis]
+        if not (n >= (m2p := self.m_num-self.m_num_mid)):
+            e_str = f'{len(x)=}' if x.ndim == 1 else f'of {axis=} of {x.shape}'
+            raise ValueError(f"{e_str} must be >= ceil(m_num/2) = {m2p}!")
+
+        if x.ndim > 1:  # motivated by the NumPy broadcasting mechanisms:
+            x = np.moveaxis(x, axis, -1)
+        # determine slice index range:
+        p0, p1 = self.p_range(n, p0, p1)
+        S_shape_1d = (self.f_pts, p1 - p0)
+        S_shape = x.shape[:-1] + S_shape_1d if x.ndim > 1 else S_shape_1d
+        S = np.zeros(S_shape, dtype=complex)
+        for p_, x_ in enumerate(self._x_slices(x, k_offset, p0, p1, padding)):
+            if detr is not None:
+                x_ = detr(x_)
+            S[..., :, p_] = self._fft_func(x_ * self.win.conj())
+        if x.ndim > 1:
+            return np.moveaxis(S, -2, axis if axis >= 0 else axis-1)
+        return S
+
+    def spectrogram(self, x: np.ndarray, y: np.ndarray | None = None,
+                    detr: Union[Callable[[np.ndarray], np.ndarray],
+                                Literal['linear', 'constant'], None] = None, *,
+                    p0: int | None = None, p1: int | None = None,
+                    k_offset: int = 0, padding: PAD_TYPE = 'zeros',
+                    axis: int = -1) \
+            -> np.ndarray:
+        r"""Calculate spectrogram or cross-spectrogram.
+
+        The spectrogram is the absolute square of the STFT, i.e, it is
+        ``abs(S[q,p])**2`` for given ``S[q,p]``  and thus is always
+        non-negative.
+        For two STFTs ``Sx[q,p], Sy[q,p]``, the cross-spectrogram is defined
+        as ``Sx[q,p] * np.conj(Sx[q,p])`` and is complex-valued.
+        This is a convenience function for calling `~ShortTimeFFT.stft` /
+        `stft_detrend`, hence all parameters are discussed there. If `y` is not
+        ``None`` it needs to have the same shape as `x`.
+
+        Examples
+        --------
+        The following example shows the spectrogram of a square wave with
+        varying frequency :math:`f_i(t)` (marked by a green dashed line in the
+        plot) sampled with 20 Hz:
+
+        >>> import matplotlib.pyplot as plt
+        >>> import numpy as np
+        >>> from scipy.signal import square, ShortTimeFFT
+        >>> from scipy.signal.windows import gaussian
+        ...
+        >>> T_x, N = 1 / 20, 1000  # 20 Hz sampling rate for 50 s signal
+        >>> t_x = np.arange(N) * T_x  # time indexes for signal
+        >>> f_i = 5e-3*(t_x - t_x[N // 3])**2 + 1  # varying frequency
+        >>> x = square(2*np.pi*np.cumsum(f_i)*T_x)  # the signal
+
+        The utitlized Gaussian window is 50 samples or 2.5 s long. The
+        parameter ``mfft=800`` (oversampling factor 16) and the `hop` interval
+        of 2 in `ShortTimeFFT` was chosen to produce a sufficient number of
+        points:
+
+        >>> g_std = 12  # standard deviation for Gaussian window in samples
+        >>> win = gaussian(50, std=g_std, sym=True)  # symmetric Gaussian wind.
+        >>> SFT = ShortTimeFFT(win, hop=2, fs=1/T_x, mfft=800, scale_to='psd')
+        >>> Sx2 = SFT.spectrogram(x)  # calculate absolute square of STFT
+
+        The plot's colormap is logarithmically scaled as the power spectral
+        density is in dB. The time extent of the signal `x` is marked by
+        vertical dashed lines and the shaded areas mark the presence of border
+        effects:
+
+        >>> fig1, ax1 = plt.subplots(figsize=(6., 4.))  # enlarge plot a bit
+        >>> t_lo, t_hi = SFT.extent(N)[:2]  # time range of plot
+        >>> ax1.set_title(rf"Spectrogram ({SFT.m_num*SFT.T:g}$\,s$ Gaussian " +
+        ...               rf"window, $\sigma_t={g_std*SFT.T:g}\,$s)")
+        >>> ax1.set(xlabel=f"Time $t$ in seconds ({SFT.p_num(N)} slices, " +
+        ...                rf"$\Delta t = {SFT.delta_t:g}\,$s)",
+        ...         ylabel=f"Freq. $f$ in Hz ({SFT.f_pts} bins, " +
+        ...                rf"$\Delta f = {SFT.delta_f:g}\,$Hz)",
+        ...         xlim=(t_lo, t_hi))
+        >>> Sx_dB = 10 * np.log10(np.fmax(Sx2, 1e-4))  # limit range to -40 dB
+        >>> im1 = ax1.imshow(Sx_dB, origin='lower', aspect='auto',
+        ...                  extent=SFT.extent(N), cmap='magma')
+        >>> ax1.plot(t_x, f_i, 'g--', alpha=.5, label='$f_i(t)$')
+        >>> fig1.colorbar(im1, label='Power Spectral Density ' +
+        ...                          r"$20\,\log_{10}|S_x(t, f)|$ in dB")
+        ...
+        >>> # Shade areas where window slices stick out to the side:
+        >>> for t0_, t1_ in [(t_lo, SFT.lower_border_end[0] * SFT.T),
+        ...                  (SFT.upper_border_begin(N)[0] * SFT.T, t_hi)]:
+        ...     ax1.axvspan(t0_, t1_, color='w', linewidth=0, alpha=.3)
+        >>> for t_ in [0, N * SFT.T]:  # mark signal borders with vertical line
+        ...     ax1.axvline(t_, color='c', linestyle='--', alpha=0.5)
+        >>> ax1.legend()
+        >>> fig1.tight_layout()
+        >>> plt.show()
+
+        The logarithmic scaling reveals the odd harmonics of the square wave,
+        which are reflected at the Nyquist frequency of 10 Hz. This aliasing
+        is also the main source of the noise artifacts in the plot.
+
+
+        See Also
+        --------
+        :meth:`~ShortTimeFFT.stft`: Perform the short-time Fourier transform.
+        stft_detrend: STFT with a trend subtracted from each segment.
+        :class:`scipy.signal.ShortTimeFFT`: Class this method belongs to.
+        """
+        Sx = self.stft_detrend(x, detr, p0, p1, k_offset=k_offset,
+                               padding=padding, axis=axis)
+        if y is None or y is x:  # do spectrogram:
+            return Sx.real**2 + Sx.imag**2
+        # Cross-spectrogram:
+        Sy = self.stft_detrend(y, detr, p0, p1, k_offset=k_offset,
+                               padding=padding, axis=axis)
+        return Sx * Sy.conj()
+
+    @property
+    def dual_win(self) -> np.ndarray:
+        """Canonical dual window.
+
+        A STFT can be interpreted as the input signal being expressed as a
+        weighted sum of modulated and time-shifted dual windows. Note that for
+        a given window there exist many dual windows. The canonical window is
+        the one with the minimal energy (i.e., :math:`L_2` norm).
+
+        `dual_win` has same length as `win`, namely `m_num` samples.
+
+        If the dual window cannot be calculated a ``ValueError`` is raised.
+        This attribute is read only and calculated lazily.
+
+        See Also
+        --------
+        dual_win: Canonical dual window.
+        m_num: Number of samples in window `win`.
+        win: Window function as real- or complex-valued 1d array.
+        ShortTimeFFT: Class this property belongs to.
+        """
+        if self._dual_win is None:
+            self._dual_win = _calc_dual_canonical_window(self.win, self.hop)
+        return self._dual_win
+
+    @property
+    def invertible(self) -> bool:
+        """Check if STFT is invertible.
+
+        This is achieved by trying to calculate the canonical dual window.
+
+        See Also
+        --------
+        :meth:`~ShortTimeFFT.istft`: Inverse short-time Fourier transform.
+        m_num: Number of samples in window `win` and `dual_win`.
+        dual_win: Canonical dual window.
+        win: Window for STFT.
+        ShortTimeFFT: Class this property belongs to.
+        """
+        try:
+            return len(self.dual_win) > 0  # call self.dual_win()
+        except ValueError:
+            return False
+
+    def istft(self, S: np.ndarray, k0: int = 0, k1: int | None = None, *,
+              f_axis: int = -2, t_axis: int = -1) \
+            -> np.ndarray:
+        """Inverse short-time Fourier transform.
+
+        It returns an array of dimension ``S.ndim - 1``  which is real
+        if `onesided_fft` is set, else complex. If the STFT is not
+        `invertible`, or the parameters are out of bounds  a ``ValueError`` is
+        raised.
+
+        Parameters
+        ----------
+        S
+            A complex valued array where `f_axis` denotes the frequency
+            values and the `t-axis` dimension the temporal values of the
+            STFT values.
+        k0, k1
+            The start and the end index of the reconstructed signal. The
+            default (``k0 = 0``, ``k1 = None``) assumes that the maximum length
+            signal should be reconstructed.
+        f_axis, t_axis
+            The axes in `S` denoting the frequency and the time dimension.
+
+        Notes
+        -----
+        It is required that `S` has `f_pts` entries along the `f_axis`. For
+        the `t_axis` it is assumed that the first entry corresponds to
+        `p_min` * `delta_t` (being <= 0). The length of `t_axis` needs to be
+        compatible with `k1`. I.e., ``S.shape[t_axis] >= self.p_max(k1)`` must
+        hold, if `k1` is not ``None``. Else `k1` is set to `k_max` with::
+
+            q_max = S.shape[t_range] + self.p_min
+            k_max = (q_max - 1) * self.hop + self.m_num - self.m_num_mid
+
+        The :ref:`tutorial_stft` section of the :ref:`user_guide` discussed the
+        slicing behavior by means of an example.
+
+        See Also
+        --------
+        invertible: Check if STFT is invertible.
+        :meth:`~ShortTimeFFT.stft`: Perform Short-time Fourier transform.
+        :class:`scipy.signal.ShortTimeFFT`: Class this method belongs to.
+        """
+        if f_axis == t_axis:
+            raise ValueError(f"{f_axis=} may not be equal to {t_axis=}!")
+        if S.shape[f_axis] != self.f_pts:
+            raise ValueError(f"{S.shape[f_axis]=} must be equal to " +
+                             f"{self.f_pts=} ({S.shape=})!")
+        n_min = self.m_num-self.m_num_mid  # minimum signal length
+        if not (S.shape[t_axis] >= (q_num := self.p_num(n_min))):
+            raise ValueError(f"{S.shape[t_axis]=} needs to have at least " +
+                             f"{q_num} slices ({S.shape=})!")
+        if t_axis != S.ndim - 1 or f_axis != S.ndim - 2:
+            t_axis = S.ndim + t_axis if t_axis < 0 else t_axis
+            f_axis = S.ndim + f_axis if f_axis < 0 else f_axis
+            S = np.moveaxis(S, (f_axis, t_axis), (-2, -1))
+
+        q_max = S.shape[-1] + self.p_min
+        k_max = (q_max - 1) * self.hop + self.m_num - self.m_num_mid
+
+        k1 = k_max if k1 is None else k1
+        if not (self.k_min <= k0 < k1 <= k_max):
+            raise ValueError(f"({self.k_min=}) <= ({k0=}) < ({k1=}) <= " +
+                             f"({k_max=}) is false!")
+        if not (num_pts := k1 - k0) >= n_min:
+            raise ValueError(f"({k1=}) - ({k0=}) = {num_pts} has to be at " +
+                             f"least the half the window length {n_min}!")
+
+        q0 = (k0 // self.hop + self.p_min if k0 >= 0 else  # p_min always <= 0
+              k0 // self.hop)
+        q1 = min(self.p_max(k1), q_max)
+        k_q0, k_q1 = self.nearest_k_p(k0), self.nearest_k_p(k1, left=False)
+        n_pts = k_q1 - k_q0 + self.m_num - self.m_num_mid
+        x = np.zeros(S.shape[:-2] + (n_pts,),
+                     dtype=float if self.onesided_fft else complex)
+        for q_ in range(q0, q1):
+            xs = self._ifft_func(S[..., :, q_ - self.p_min]) * self.dual_win
+            i0 = q_ * self.hop - self.m_num_mid
+            i1 = min(i0 + self.m_num, n_pts+k0)
+            j0, j1 = 0, i1 - i0
+            if i0 < k0:  # xs sticks out to the left on x:
+                j0 += k0 - i0
+                i0 = k0
+            x[..., i0-k0:i1-k0] += xs[..., j0:j1]
+        x = x[..., :k1-k0]
+        if x.ndim > 1:
+            x = np.moveaxis(x, -1, f_axis if f_axis < x.ndim else t_axis)
+        return x
+
+    @property
+    def fac_magnitude(self) -> float:
+        """Factor to multiply the STFT values by to scale each frequency slice
+        to a magnitude spectrum.
+
+        It is 1 if attribute ``scaling == 'magnitude'``.
+        The window can be scaled to a magnitude spectrum by using the method
+        `scale_to`.
+
+        See Also
+        --------
+        fac_psd: Scaling factor for to a power spectral density spectrum.
+        scale_to: Scale window to obtain 'magnitude' or 'psd' scaling.
+        scaling: Normalization applied to the window function.
+        ShortTimeFFT: Class this property belongs to.
+        """
+        if self.scaling == 'magnitude':
+            return 1
+        if self._fac_mag is None:
+            self._fac_mag = 1 / abs(sum(self.win))
+        return self._fac_mag
+
+    @property
+    def fac_psd(self) -> float:
+        """Factor to multiply the STFT values by to scale each frequency slice
+        to a power spectral density (PSD).
+
+        It is 1 if attribute ``scaling == 'psd'``.
+        The window can be scaled to a psd spectrum by using the method
+        `scale_to`.
+
+        See Also
+        --------
+        fac_magnitude: Scaling factor for to a magnitude spectrum.
+        scale_to: Scale window to obtain 'magnitude' or 'psd' scaling.
+        scaling: Normalization applied to the window function.
+        ShortTimeFFT: Class this property belongs to.
+        """
+        if self.scaling == 'psd':
+            return 1
+        if self._fac_psd is None:
+            self._fac_psd = 1 / np.sqrt(
+                sum(self.win.real**2+self.win.imag**2) / self.T)
+        return self._fac_psd
+
+    @property
+    def m_num(self) -> int:
+        """Number of samples in window `win`.
+
+        Note that the FFT can be oversampled by zero-padding. This is achieved
+        by setting the `mfft` property.
+
+        See Also
+        --------
+        m_num_mid: Center index of window `win`.
+        mfft: Length of input for the FFT used - may be larger than `m_num`.
+        hop: Time increment in signal samples for sliding window.
+        win: Window function as real- or complex-valued 1d array.
+        ShortTimeFFT: Class this property belongs to.
+        """
+        return len(self.win)
+
+    @property
+    def m_num_mid(self) -> int:
+        """Center index of window `win`.
+
+        For odd `m_num`, ``(m_num - 1) / 2`` is returned and
+        for even `m_num` (per definition) ``m_num / 2`` is returned.
+
+        See Also
+        --------
+        m_num: Number of samples in window `win`.
+        mfft: Length of input for the FFT used - may be larger than `m_num`.
+        hop: ime increment in signal samples for sliding window.
+        win: Window function as real- or complex-valued 1d array.
+        ShortTimeFFT: Class this property belongs to.
+        """
+        return self.m_num // 2
+
+    @cache
+    def _pre_padding(self) -> tuple[int, int]:
+        """Smallest signal index and slice index due to padding.
+
+         Since, per convention, for time t=0, n,q is zero, the returned values
+         are negative or zero.
+         """
+        w2 = self.win.real**2 + self.win.imag**2
+        # move window to the left until the overlap with t >= 0 vanishes:
+        n0 = -self.m_num_mid
+        for q_, n_ in enumerate(range(n0, n0-self.m_num-1, -self.hop)):
+            n_next = n_ - self.hop
+            if n_next + self.m_num <= 0 or all(w2[n_next:] == 0):
+                return n_, -q_
+        raise RuntimeError("This is code line should not have been reached!")
+        # If this case is reached, it probably means the first slice should be
+        # returned, i.e.: return n0, 0
+
+    @property
+    def k_min(self) -> int:
+        """The smallest possible signal index of the STFT.
+
+        `k_min` is the index of the left-most non-zero value of the lowest
+        slice `p_min`. Since the zeroth slice is centered over the zeroth
+        sample of the input signal, `k_min` is never positive.
+        A detailed example is provided in the :ref:`tutorial_stft_sliding_win`
+        section of the :ref:`user_guide`.
+
+        See Also
+        --------
+        k_max: First sample index after signal end not touched by a time slice.
+        lower_border_end: Where pre-padding effects end.
+        p_min: The smallest possible slice index.
+        p_max: Index of first non-overlapping upper time slice.
+        p_num: Number of time slices, i.e., `p_max` - `p_min`.
+        p_range: Determine and validate slice index range.
+        upper_border_begin: Where post-padding effects start.
+        ShortTimeFFT: Class this property belongs to.
+        """
+        return self._pre_padding()[0]
+
+    @property
+    def p_min(self) -> int:
+        """The smallest possible slice index.
+
+        `p_min` is the index of the left-most slice, where the window still
+        sticks into the signal, i.e., has non-zero part for t >= 0.
+        `k_min` is the smallest index where the window function of the slice
+        `p_min` is non-zero.
+
+        Since, per convention the zeroth slice is centered at t=0,
+        `p_min` <= 0 always holds.
+        A detailed example is provided in the :ref:`tutorial_stft_sliding_win`
+        section of the :ref:`user_guide`.
+
+        See Also
+        --------
+        k_min: The smallest possible signal index.
+        k_max: First sample index after signal end not touched by a time slice.
+        p_max: Index of first non-overlapping upper time slice.
+        p_num: Number of time slices, i.e., `p_max` - `p_min`.
+        p_range: Determine and validate slice index range.
+        ShortTimeFFT: Class this property belongs to.
+        """
+        return self._pre_padding()[1]
+
+    @lru_cache(maxsize=256)
+    def _post_padding(self, n: int) -> tuple[int, int]:
+        """Largest signal index and slice index due to padding."""
+        w2 = self.win.real**2 + self.win.imag**2
+        # move window to the right until the overlap for t < t[n] vanishes:
+        q1 = n // self.hop   # last slice index with t[p1] <= t[n]
+        k1 = q1 * self.hop - self.m_num_mid
+        for q_, k_ in enumerate(range(k1, n+self.m_num, self.hop), start=q1):
+            n_next = k_ + self.hop
+            if n_next >= n or all(w2[:n-n_next] == 0):
+                return k_ + self.m_num, q_ + 1
+        raise RuntimeError("This is code line should not have been reached!")
+        # If this case is reached, it probably means the last slice should be
+        # returned, i.e.: return k1 + self.m_num - self.m_num_mid, q1 + 1
+
+    def k_max(self, n: int) -> int:
+        """First sample index after signal end not touched by a time slice.
+
+        `k_max` - 1 is the largest sample index of the slice `p_max` for a
+        given input signal of `n` samples.
+        A detailed example is provided in the :ref:`tutorial_stft_sliding_win`
+        section of the :ref:`user_guide`.
+
+        See Also
+        --------
+        k_min: The smallest possible signal index.
+        p_min: The smallest possible slice index.
+        p_max: Index of first non-overlapping upper time slice.
+        p_num: Number of time slices, i.e., `p_max` - `p_min`.
+        p_range: Determine and validate slice index range.
+        ShortTimeFFT: Class this method belongs to.
+        """
+        return self._post_padding(n)[0]
+
+    def p_max(self, n: int) -> int:
+        """Index of first non-overlapping upper time slice for `n` sample
+        input.
+
+        Note that center point t[p_max] = (p_max(n)-1) * `delta_t` is typically
+        larger than last time index t[n-1] == (`n`-1) * `T`. The upper border
+        of samples indexes covered by the window slices is given by `k_max`.
+        Furthermore, `p_max` does not denote the number of slices `p_num` since
+        `p_min` is typically less than zero.
+        A detailed example is provided in the :ref:`tutorial_stft_sliding_win`
+        section of the :ref:`user_guide`.
+
+        See Also
+        --------
+        k_min: The smallest possible signal index.
+        k_max: First sample index after signal end not touched by a time slice.
+        p_min: The smallest possible slice index.
+        p_num: Number of time slices, i.e., `p_max` - `p_min`.
+        p_range: Determine and validate slice index range.
+        ShortTimeFFT: Class this method belongs to.
+        """
+        return self._post_padding(n)[1]
+
+    def p_num(self, n: int) -> int:
+        """Number of time slices for an input signal with `n` samples.
+
+        It is given by `p_num` = `p_max` - `p_min` with `p_min` typically
+        being negative.
+        A detailed example is provided in the :ref:`tutorial_stft_sliding_win`
+        section of the :ref:`user_guide`.
+
+        See Also
+        --------
+        k_min: The smallest possible signal index.
+        k_max: First sample index after signal end not touched by a time slice.
+        lower_border_end: Where pre-padding effects end.
+        p_min: The smallest possible slice index.
+        p_max: Index of first non-overlapping upper time slice.
+        p_range: Determine and validate slice index range.
+        upper_border_begin: Where post-padding effects start.
+        ShortTimeFFT: Class this method belongs to.
+        """
+        return self.p_max(n) - self.p_min
+
+    @property
+    def lower_border_end(self) -> tuple[int, int]:
+        """First signal index and first slice index unaffected by pre-padding.
+
+        Describes the point where the window does not stick out to the left
+        of the signal domain.
+        A detailed example is provided in the :ref:`tutorial_stft_sliding_win`
+        section of the :ref:`user_guide`.
+
+        See Also
+        --------
+        k_min: The smallest possible signal index.
+        k_max: First sample index after signal end not touched by a time slice.
+        lower_border_end: Where pre-padding effects end.
+        p_min: The smallest possible slice index.
+        p_max: Index of first non-overlapping upper time slice.
+        p_num: Number of time slices, i.e., `p_max` - `p_min`.
+        p_range: Determine and validate slice index range.
+        upper_border_begin: Where post-padding effects start.
+        ShortTimeFFT: Class this property belongs to.
+        """
+        # not using @cache decorator due to MyPy limitations
+        if self._lower_border_end is not None:
+            return self._lower_border_end
+
+        # first non-zero element in self.win:
+        m0 = np.flatnonzero(self.win.real**2 + self.win.imag**2)[0]
+
+        # move window to the right until does not stick out to the left:
+        k0 = -self.m_num_mid + m0
+        for q_, k_ in enumerate(range(k0, self.hop + 1, self.hop)):
+            if k_ + self.hop >= 0:  # next entry does not stick out anymore
+                self._lower_border_end = (k_ + self.m_num, q_ + 1)
+                return self._lower_border_end
+        self._lower_border_end = (0, max(self.p_min, 0))  # ends at first slice
+        return self._lower_border_end
+
+    @lru_cache(maxsize=256)
+    def upper_border_begin(self, n: int) -> tuple[int, int]:
+        """First signal index and first slice index affected by post-padding.
+
+        Describes the point where the window does begin stick out to the right
+        of the signal domain.
+        A detailed example is given :ref:`tutorial_stft_sliding_win` section
+        of the :ref:`user_guide`.
+
+        See Also
+        --------
+        k_min: The smallest possible signal index.
+        k_max: First sample index after signal end not touched by a time slice.
+        lower_border_end: Where pre-padding effects end.
+        p_min: The smallest possible slice index.
+        p_max: Index of first non-overlapping upper time slice.
+        p_num: Number of time slices, i.e., `p_max` - `p_min`.
+        p_range: Determine and validate slice index range.
+        ShortTimeFFT: Class this method belongs to.
+        """
+        w2 = self.win.real**2 + self.win.imag**2
+        q2 = n // self.hop + 1  # first t[q] >= t[n]
+        q1 = max((n-self.m_num) // self.hop - 1, -1)
+        # move window left until does not stick out to the right:
+        for q_ in range(q2, q1, -1):
+            k_ = q_ * self.hop + (self.m_num - self.m_num_mid)
+            if k_ < n or all(w2[n-k_:] == 0):
+                return (q_ + 1) * self.hop - self.m_num_mid, q_ + 1
+        return 0, 0  # border starts at first slice
+
+    @property
+    def delta_t(self) -> float:
+        """Time increment of STFT.
+
+        The time increment `delta_t` = `T` * `hop` represents the sample
+        increment `hop` converted to time based on the sampling interval `T`.
+
+        See Also
+        --------
+        delta_f: Width of the frequency bins of the STFT.
+        hop: Hop size in signal samples for sliding window.
+        t: Times of STFT for an input signal with `n` samples.
+        T: Sampling interval of input signal and window `win`.
+        ShortTimeFFT: Class this property belongs to
+        """
+        return self.T * self.hop
+
+    def p_range(self, n: int, p0: int | None = None,
+                p1: int | None = None) -> tuple[int, int]:
+        """Determine and validate slice index range.
+
+        Parameters
+        ----------
+        n : int
+            Number of samples of input signal, assuming t[0] = 0.
+        p0 : int | None
+            First slice index. If 0 then the first slice is centered at t = 0.
+            If ``None`` then `p_min` is used. Note that p0 may be < 0 if
+            slices are left of t = 0.
+        p1 : int | None
+            End of interval (last value is p1-1).
+            If ``None`` then `p_max(n)` is used.
+
+
+        Returns
+        -------
+        p0_ : int
+            The fist slice index
+        p1_ : int
+            End of interval (last value is p1-1).
+
+        Notes
+        -----
+        A ``ValueError`` is raised if ``p_min <= p0 < p1 <= p_max(n)`` does not
+        hold.
+
+        See Also
+        --------
+        k_min: The smallest possible signal index.
+        k_max: First sample index after signal end not touched by a time slice.
+        lower_border_end: Where pre-padding effects end.
+        p_min: The smallest possible slice index.
+        p_max: Index of first non-overlapping upper time slice.
+        p_num: Number of time slices, i.e., `p_max` - `p_min`.
+        upper_border_begin: Where post-padding effects start.
+        ShortTimeFFT: Class this property belongs to.
+        """
+        p_max = self.p_max(n)  # shorthand
+        p0_ = self.p_min if p0 is None else p0
+        p1_ = p_max if p1 is None else p1
+        if not (self.p_min <= p0_ < p1_ <= p_max):
+            raise ValueError(f"Invalid Parameter {p0=}, {p1=}, i.e., " +
+                             f"{self.p_min=} <= p0 < p1 <= {p_max=} " +
+                             f"does not hold for signal length {n=}!")
+        return p0_, p1_
+
+    @lru_cache(maxsize=1)
+    def t(self, n: int, p0: int | None = None, p1: int | None = None,
+          k_offset: int = 0) -> np.ndarray:
+        """Times of STFT for an input signal with `n` samples.
+
+        Returns a 1d array with times of the `~ShortTimeFFT.stft` values with
+        the same  parametrization. Note that the slices are
+        ``delta_t = hop * T`` time units apart.
+
+         Parameters
+        ----------
+        n
+            Number of sample of the input signal.
+        x
+            The input signal as real or complex valued array.
+        p0
+            The first element of the range of slices to calculate. If ``None``
+            then it is set to :attr:`p_min`, which is the smallest possible
+            slice.
+        p1
+            The end of the array. If ``None`` then `p_max(n)` is used.
+        k_offset
+            Index of first sample (t = 0) in `x`.
+
+
+        See Also
+        --------
+        delta_t: Time increment of STFT (``hop*T``)
+        hop: Time increment in signal samples for sliding window.
+        nearest_k_p: Nearest sample index k_p for which t[k_p] == t[p] holds.
+        T: Sampling interval of input signal and of the window (``1/fs``).
+        fs: Sampling frequency (being ``1/T``)
+        ShortTimeFFT: Class this method belongs to.
+        """
+        p0, p1 = self.p_range(n, p0, p1)
+        return np.arange(p0, p1) * self.delta_t + k_offset * self.T
+
+    def nearest_k_p(self, k: int, left: bool = True) -> int:
+        """Return nearest sample index k_p for which t[k_p] == t[p] holds.
+
+        The nearest next smaller time sample p (where t[p] is the center
+        position of the window of the p-th slice) is p_k = k // `hop`.
+        If `hop` is a divisor of `k` than `k` is returned.
+        If `left` is set than p_k * `hop` is returned else (p_k+1) * `hop`.
+
+        This method can be used to slice an input signal into chunks for
+        calculating the STFT and iSTFT incrementally.
+
+        See Also
+        --------
+        delta_t: Time increment of STFT (``hop*T``)
+        hop: Time increment in signal samples for sliding window.
+        T: Sampling interval of input signal and of the window (``1/fs``).
+        fs: Sampling frequency (being ``1/T``)
+        t: Times of STFT for an input signal with `n` samples.
+        ShortTimeFFT: Class this method belongs to.
+        """
+        p_q, remainder = divmod(k, self.hop)
+        if remainder == 0:
+            return k
+        return p_q * self.hop if left else (p_q + 1) * self.hop
+
+    @property
+    def delta_f(self) -> float:
+        """Width of the frequency bins of the STFT.
+
+        Return the frequency interval `delta_f` = 1 / (`mfft` * `T`).
+
+        See Also
+        --------
+        delta_t: Time increment of STFT.
+        f_pts: Number of points along the frequency axis.
+        f: Frequencies values of the STFT.
+        mfft: Length of the input for FFT used.
+        T: Sampling interval.
+        t: Times of STFT for an input signal with `n` samples.
+        ShortTimeFFT: Class this property belongs to.
+        """
+        return 1 / (self.mfft * self.T)
+
+    @property
+    def f_pts(self) -> int:
+        """Number of points along the frequency axis.
+
+        See Also
+        --------
+        delta_f: Width of the frequency bins of the STFT.
+        f: Frequencies values of the STFT.
+        mfft: Length of the input for FFT used.
+        ShortTimeFFT: Class this property belongs to.
+        """
+        return self.mfft // 2 + 1 if self.onesided_fft else self.mfft
+
+    @property
+    def onesided_fft(self) -> bool:
+        """Return True if a one-sided FFT is used.
+
+        Returns ``True`` if `fft_mode` is either 'onesided' or 'onesided2X'.
+
+        See Also
+        --------
+        fft_mode: Utilized FFT ('twosided', 'centered', 'onesided' or
+                 'onesided2X')
+        ShortTimeFFT: Class this property belongs to.
+        """
+        return self.fft_mode in {'onesided', 'onesided2X'}
+
+    @property
+    def f(self) -> np.ndarray:
+        """Frequencies values of the STFT.
+
+        A 1d array of length `f_pts` with `delta_f` spaced entries is returned.
+
+        See Also
+        --------
+        delta_f: Width of the frequency bins of the STFT.
+        f_pts: Number of points along the frequency axis.
+        mfft: Length of the input for FFT used.
+        ShortTimeFFT: Class this property belongs to.
+        """
+        if self.fft_mode in {'onesided', 'onesided2X'}:
+            return fft_lib.rfftfreq(self.mfft, self.T)
+        elif self.fft_mode == 'twosided':
+            return fft_lib.fftfreq(self.mfft, self.T)
+        elif self.fft_mode == 'centered':
+            return fft_lib.fftshift(fft_lib.fftfreq(self.mfft, self.T))
+        # This should never happen but makes the Linters happy:
+        fft_modes = get_args(FFT_MODE_TYPE)
+        raise RuntimeError(f"{self.fft_mode=} not in {fft_modes}!")
+
+    def _fft_func(self, x: np.ndarray) -> np.ndarray:
+        """FFT based on the `fft_mode`, `mfft`, `scaling` and `phase_shift`
+        attributes.
+
+        For multidimensional arrays the transformation is carried out on the
+        last axis.
+        """
+        if self.phase_shift is not None:
+            if x.shape[-1] < self.mfft:  # zero pad if needed
+                z_shape = list(x.shape)
+                z_shape[-1] = self.mfft - x.shape[-1]
+                x = np.hstack((x, np.zeros(z_shape, dtype=x.dtype)))
+            p_s = (self.phase_shift + self.m_num_mid) % self.m_num
+            x = np.roll(x, -p_s, axis=-1)
+
+        if self.fft_mode == 'twosided':
+            return fft_lib.fft(x, n=self.mfft, axis=-1)
+        if self.fft_mode == 'centered':
+            return fft_lib.fftshift(fft_lib.fft(x, self.mfft, axis=-1))
+        if self.fft_mode == 'onesided':
+            return fft_lib.rfft(x, n=self.mfft, axis=-1)
+        if self.fft_mode == 'onesided2X':
+            X = fft_lib.rfft(x, n=self.mfft, axis=-1)
+            # Either squared magnitude (psd) or magnitude is doubled:
+            fac = np.sqrt(2) if self.scaling == 'psd' else 2
+            # For even input length, the last entry is unpaired:
+            X[..., 1: -1 if self.mfft % 2 == 0 else None] *= fac
+            return X
+        # This should never happen but makes the Linter happy:
+        fft_modes = get_args(FFT_MODE_TYPE)
+        raise RuntimeError(f"{self.fft_mode=} not in {fft_modes}!")
+
+    def _ifft_func(self, X: np.ndarray) -> np.ndarray:
+        """Inverse to `_fft_func`.
+
+        Returned is an array of length `m_num`. If the FFT is `onesided`
+        then a float array is returned else a complex array is returned.
+        For multidimensional arrays the transformation is carried out on the
+        last axis.
+        """
+        if self.fft_mode == 'twosided':
+            x = fft_lib.ifft(X, n=self.mfft, axis=-1)
+        elif self.fft_mode == 'centered':
+            x = fft_lib.ifft(fft_lib.ifftshift(X), n=self.mfft, axis=-1)
+        elif self.fft_mode == 'onesided':
+            x = fft_lib.irfft(X, n=self.mfft, axis=-1)
+        elif self.fft_mode == 'onesided2X':
+            Xc = X.copy()  # we do not want to modify function parameters
+            fac = np.sqrt(2) if self.scaling == 'psd' else 2
+            # For even length X the last value is not paired with a negative
+            # value on the two-sided FFT:
+            q1 = -1 if self.mfft % 2 == 0 else None
+            Xc[..., 1:q1] /= fac
+            x = fft_lib.irfft(Xc, n=self.mfft, axis=-1)
+        else:  # This should never happen but makes the Linter happy:
+            error_str = f"{self.fft_mode=} not in {get_args(FFT_MODE_TYPE)}!"
+            raise RuntimeError(error_str)
+
+        if self.phase_shift is None:
+            return x[:self.m_num]
+        p_s = (self.phase_shift + self.m_num_mid) % self.m_num
+        return np.roll(x, p_s, axis=-1)[:self.m_num]
+
+    def extent(self, n: int, axes_seq: Literal['tf', 'ft'] = 'tf',
+               center_bins: bool = False) -> tuple[float, float, float, float]:
+        """Return minimum and maximum values time-frequency values.
+
+        A tuple with four floats  ``(t0, t1, f0, f1)`` for 'tf' and
+        ``(f0, f1, t0, t1)`` for 'ft') is returned describing the corners
+        of the time-frequency domain of the `~ShortTimeFFT.stft`.
+        That tuple can be passed to `matplotlib.pyplot.imshow` as a parameter
+        with the same name.
+
+        Parameters
+        ----------
+        n : int
+            Number of samples in input signal.
+        axes_seq : {'tf', 'ft'}
+            Return time extent first and then frequency extent or vice-versa.
+        center_bins: bool
+            If set (default ``False``), the values of the time slots and
+            frequency bins are moved from the side the middle. This is useful,
+            when plotting the `~ShortTimeFFT.stft` values as step functions,
+            i.e., with no interpolation.
+
+        See Also
+        --------
+        :func:`matplotlib.pyplot.imshow`: Display data as an image.
+        :class:`scipy.signal.ShortTimeFFT`: Class this method belongs to.
+        """
+        if axes_seq not in ('tf', 'ft'):
+            raise ValueError(f"Parameter {axes_seq=} not in ['tf', 'ft']!")
+
+        if self.onesided_fft:
+            q0, q1 = 0, self.f_pts
+        elif self.fft_mode == 'centered':
+            q0 = -self.mfft // 2
+            q1 = self.mfft // 2 - 1 if self.mfft % 2 == 0 else self.mfft // 2
+        else:
+            raise ValueError(f"Attribute fft_mode={self.fft_mode} must be " +
+                             "in ['centered', 'onesided', 'onesided2X']")
+
+        p0, p1 = self.p_min, self.p_max(n)  # shorthand
+        if center_bins:
+            t0, t1 = self.delta_t * (p0 - 0.5), self.delta_t * (p1 - 0.5)
+            f0, f1 = self.delta_f * (q0 - 0.5), self.delta_f * (q1 - 0.5)
+        else:
+            t0, t1 = self.delta_t * p0, self.delta_t * p1
+            f0, f1 = self.delta_f * q0, self.delta_f * q1
+        return (t0, t1, f0, f1) if axes_seq == 'tf' else (f0, f1, t0, t1)
diff --git a/scipy/signal/_spectral_py.py b/scipy/signal/_spectral_py.py
--- a/scipy/signal/_spectral_py.py
+++ b/scipy/signal/_spectral_py.py
@@ -1,6 +1,5 @@
 """Tools for spectral analysis.
 """
-
 import numpy as np
 from scipy import fft as sp_fft
 from . import _signaltools
@@ -587,9 +586,9 @@ def csd(x, y, fs=1.0, window='hann', nperseg=None, noverlap=None, nfft=None,
     >>> plt.show()
 
     """
-    freqs, _, Pxy = _spectral_helper(x, y, fs, window, nperseg, noverlap, nfft,
-                                     detrend, return_onesided, scaling, axis,
-                                     mode='psd')
+    freqs, _, Pxy = _spectral_helper(x, y, fs, window, nperseg, noverlap,
+                                     nfft, detrend, return_onesided, scaling,
+                                     axis, mode='psd')
 
     # Average over windows.
     if len(Pxy.shape) >= 2 and Pxy.size > 0:
@@ -622,6 +621,14 @@ def spectrogram(x, fs=1.0, window=('tukey', .25), nperseg=None, noverlap=None,
     Spectrograms can be used as a way of visualizing the change of a
     nonstationary signal's frequency content over time.
 
+    .. legacy:: function
+
+        :class:`ShortTimeFFT` is a newer STFT / ISTFT implementation with more
+        features also including a :meth:`~ShortTimeFFT.spectrogram` method.
+        A :ref:`comparison <tutorial_stft_legacy_stft>` between the
+        implementations can be found in the :ref:`tutorial_stft` section of
+        the :ref:`user_guide`.
+
     Parameters
     ----------
     x : array_like
@@ -688,6 +695,9 @@ def spectrogram(x, fs=1.0, window=('tukey', .25), nperseg=None, noverlap=None,
     lombscargle: Lomb-Scargle periodogram for unevenly sampled data
     welch: Power spectral density by Welch's method.
     csd: Cross spectral density by Welch's method.
+    ShortTimeFFT: Newer STFT/ISTFT implementation providing more features,
+                  which also includes a :meth:`~ShortTimeFFT.spectrogram`
+                  method.
 
     Notes
     -----
@@ -699,6 +709,7 @@ def spectrogram(x, fs=1.0, window=('tukey', .25), nperseg=None, noverlap=None,
     It is for this reason that the default window is a Tukey window with
     1/8th of a window's length overlap at each end.
 
+
     .. versionadded:: 0.16.0
 
     References
@@ -1043,6 +1054,13 @@ def stft(x, fs=1.0, window='hann', nperseg=256, noverlap=None, nfft=None,
     STFTs can be used as a way of quantifying the change of a
     nonstationary signal's frequency and phase content over time.
 
+    .. legacy:: function
+
+        `ShortTimeFFT` is a newer STFT / ISTFT implementation with more
+        features. A :ref:`comparison <tutorial_stft_legacy_stft>` between the
+        implementations can be found in the :ref:`tutorial_stft` section of the
+        :ref:`user_guide`.
+
     Parameters
     ----------
     x : array_like
@@ -1115,6 +1133,7 @@ def stft(x, fs=1.0, window='hann', nperseg=256, noverlap=None, nfft=None,
     See Also
     --------
     istft: Inverse Short Time Fourier Transform
+    ShortTimeFFT: Newer STFT/ISTFT implementation providing more features.
     check_COLA: Check whether the Constant OverLap Add (COLA) constraint
                 is met
     check_NOLA: Check whether the Nonzero Overlap Add (NOLA) constraint is met
@@ -1147,6 +1166,7 @@ def stft(x, fs=1.0, window='hann', nperseg=256, noverlap=None, nfft=None,
     choice of `window`, `nperseg`, and `noverlap` satisfy this constraint can
     be tested with `check_NOLA`.
 
+
     .. versionadded:: 0.19.0
 
     References
@@ -1222,6 +1242,13 @@ def istft(Zxx, fs=1.0, window='hann', nperseg=None, noverlap=None, nfft=None,
           scaling='spectrum'):
     r"""Perform the inverse Short Time Fourier transform (iSTFT).
 
+    .. legacy:: function
+
+        `ShortTimeFFT` is a newer STFT / ISTFT implementation with more
+        features. A :ref:`comparison <tutorial_stft_legacy_stft>` between the
+        implementations can be found in the :ref:`tutorial_stft` section of the
+        :ref:`user_guide`.
+
     Parameters
     ----------
     Zxx : array_like
@@ -1291,6 +1318,7 @@ def istft(Zxx, fs=1.0, window='hann', nperseg=None, noverlap=None, nfft=None,
     See Also
     --------
     stft: Short Time Fourier Transform
+    ShortTimeFFT: Newer STFT/ISTFT implementation providing more features.
     check_COLA: Check whether the Constant OverLap Add (COLA) constraint
                 is met
     check_NOLA: Check whether the Nonzero Overlap Add (NOLA) constraint is met
@@ -1318,6 +1346,7 @@ def istft(Zxx, fs=1.0, window='hann', nperseg=None, noverlap=None, nfft=None,
     the mean squared error between the STFT of the returned signal and
     the modified STFT.
 
+
     .. versionadded:: 0.19.0
 
     References


Test patch:
diff --git a/scipy/signal/tests/_scipy_spectral_test_shim.py b/scipy/signal/tests/_scipy_spectral_test_shim.py
new file mode 100644
--- /dev/null
+++ b/scipy/signal/tests/_scipy_spectral_test_shim.py
@@ -0,0 +1,489 @@
+"""Helpers to utilize existing stft / istft tests for testing `ShortTimeFFT`.
+
+This module provides the functions stft_compare() and istft_compare(), which,
+compares the output between the existing (i)stft() and the shortTimeFFT based
+_(i)stft_wrapper() implementations in this module.
+
+For testing add the following imports to the file ``tests/test_spectral.py``::
+
+    from ._scipy_spectral_test_shim import stft_compare as stft
+    from ._scipy_spectral_test_shim import istft_compare as istft
+
+and remove the existing imports of stft and istft.
+
+The idea of these wrappers is not to provide a backward-compatible interface
+but to demonstrate that the ShortTimeFFT implementation is at least as capable
+as the existing one and delivers comparable results. Furthermore, the
+wrappers highlight the different philosophies of the implementations,
+especially in the border handling.
+"""
+import platform
+from typing import cast, Literal
+
+import numpy as np
+from numpy.testing import assert_allclose
+
+from scipy.signal import ShortTimeFFT
+from scipy.signal import csd, get_window, stft, istft
+from scipy.signal._arraytools import const_ext, even_ext, odd_ext, zero_ext
+from scipy.signal._short_time_fft import FFT_MODE_TYPE
+from scipy.signal._spectral_py import _spectral_helper, _triage_segments, \
+    _median_bias
+
+
+def _stft_wrapper(x, fs=1.0, window='hann', nperseg=256, noverlap=None,
+                  nfft=None, detrend=False, return_onesided=True,
+                  boundary='zeros', padded=True, axis=-1, scaling='spectrum'):
+    """Wrapper for the SciPy `stft()` function based on `ShortTimeFFT` for
+    unit testing.
+
+    Handling the boundary and padding is where `ShortTimeFFT` and `stft()`
+    differ in behavior. Parts of `_spectral_helper()` were copied to mimic
+    the` stft()` behavior.
+
+    This function is meant to be solely used by `stft_compare()`.
+    """
+    if scaling not in ('psd', 'spectrum'):  # same errors as in original stft:
+        raise ValueError(f"Parameter {scaling=} not in ['spectrum', 'psd']!")
+
+    # The following lines are taken from the original _spectral_helper():
+    boundary_funcs = {'even': even_ext,
+                      'odd': odd_ext,
+                      'constant': const_ext,
+                      'zeros': zero_ext,
+                      None: None}
+
+    if boundary not in boundary_funcs:
+        raise ValueError(f"Unknown boundary option '{boundary}', must be one" +
+                         f" of: {list(boundary_funcs.keys())}")
+    if x.size == 0:
+        return np.empty(x.shape), np.empty(x.shape), np.empty(x.shape)
+
+    if nperseg is not None:  # if specified by user
+        nperseg = int(nperseg)
+        if nperseg < 1:
+            raise ValueError('nperseg must be a positive integer')
+
+    # parse window; if array like, then set nperseg = win.shape
+    win, nperseg = _triage_segments(window, nperseg,
+                                    input_length=x.shape[axis])
+
+    if nfft is None:
+        nfft = nperseg
+    elif nfft < nperseg:
+        raise ValueError('nfft must be greater than or equal to nperseg.')
+    else:
+        nfft = int(nfft)
+
+    if noverlap is None:
+        noverlap = nperseg//2
+    else:
+        noverlap = int(noverlap)
+    if noverlap >= nperseg:
+        raise ValueError('noverlap must be less than nperseg.')
+    nstep = nperseg - noverlap
+    n = x.shape[axis]
+
+    # Padding occurs after boundary extension, so that the extended signal ends
+    # in zeros, instead of introducing an impulse at the end.
+    # I.e. if x = [..., 3, 2]
+    # extend then pad -> [..., 3, 2, 2, 3, 0, 0, 0]
+    # pad then extend -> [..., 3, 2, 0, 0, 0, 2, 3]
+
+    if boundary is not None:
+        ext_func = boundary_funcs[boundary]
+        # Extend by nperseg//2 in front and back:
+        x = ext_func(x, nperseg//2, axis=axis)
+
+    if padded:
+        # Pad to integer number of windowed segments
+        # I.e make x.shape[-1] = nperseg + (nseg-1)*nstep, with integer nseg
+        x = np.moveaxis(x, axis, -1)
+
+        # This is an edge case where shortTimeFFT returns one more time slice
+        # than the Scipy stft() shorten to remove last time slice:
+        if n % 2 == 1 and nperseg % 2 == 1 and noverlap % 2 == 1:
+            x = x[..., :axis - 1]
+
+        nadd = (-(x.shape[-1]-nperseg) % nstep) % nperseg
+        zeros_shape = list(x.shape[:-1]) + [nadd]
+        x = np.concatenate((x, np.zeros(zeros_shape)), axis=-1)
+        x = np.moveaxis(x, -1, axis)
+
+    #  ... end original _spectral_helper() code.
+    scale_to = {'spectrum': 'magnitude', 'psd': 'psd'}[scaling]
+
+    if np.iscomplexobj(x) and return_onesided:
+        return_onesided = False
+    # using cast() to make mypy happy:
+    fft_mode = cast(FFT_MODE_TYPE, 'onesided' if return_onesided else 'twosided')
+
+    ST = ShortTimeFFT(win, nstep, fs, fft_mode=fft_mode, mfft=nfft,
+                      scale_to=scale_to, phase_shift=None)
+
+    k_off = nperseg // 2
+    p0 = 0  # ST.lower_border_end[1] + 1
+    nn = x.shape[axis] if padded else n+k_off+1
+    p1 = ST.upper_border_begin(nn)[1]  # ST.p_max(n) + 1
+
+    # This is bad hack to pass the test test_roundtrip_boundary_extension():
+    if padded is True and nperseg - noverlap == 1:
+        p1 -= nperseg // 2 - 1  # the reasoning behind this is not clear to me
+
+    detr = None if detrend is False else detrend
+    Sxx = ST.stft_detrend(x, detr, p0, p1, k_offset=k_off, axis=axis)
+    t = ST.t(nn, 0, p1 - p0, k_offset=0 if boundary is not None else k_off)
+    if x.dtype in (np.float32, np.complex64):
+        Sxx = Sxx.astype(np.complex64)
+
+    # workaround for test_average_all_segments() - seems to be buggy behavior:
+    if boundary is None and padded is False:
+        t, Sxx = t[1:-1], Sxx[..., :-2]
+        t -= k_off / fs
+
+    return ST.f, t, Sxx
+
+
+def _istft_wrapper(Zxx, fs=1.0, window='hann', nperseg=None, noverlap=None,
+                   nfft=None, input_onesided=True, boundary=True, time_axis=-1,
+                   freq_axis=-2, scaling='spectrum') -> \
+        tuple[np.ndarray, np.ndarray, tuple[int, int]]:
+    """Wrapper for the SciPy `istft()` function based on `ShortTimeFFT` for
+        unit testing.
+
+    Note that only option handling is implemented as far as to handle the unit
+    tests. E.g., the case ``nperseg=None`` is not handled.
+
+    This function is meant to be solely used by `istft_compare()`.
+    """
+    # *** Lines are taken from _spectral_py.istft() ***:
+    if Zxx.ndim < 2:
+        raise ValueError('Input stft must be at least 2d!')
+
+    if freq_axis == time_axis:
+        raise ValueError('Must specify differing time and frequency axes!')
+
+    nseg = Zxx.shape[time_axis]
+
+    if input_onesided:
+        # Assume even segment length
+        n_default = 2*(Zxx.shape[freq_axis] - 1)
+    else:
+        n_default = Zxx.shape[freq_axis]
+
+    # Check windowing parameters
+    if nperseg is None:
+        nperseg = n_default
+    else:
+        nperseg = int(nperseg)
+        if nperseg < 1:
+            raise ValueError('nperseg must be a positive integer')
+
+    if nfft is None:
+        if input_onesided and (nperseg == n_default + 1):
+            # Odd nperseg, no FFT padding
+            nfft = nperseg
+        else:
+            nfft = n_default
+    elif nfft < nperseg:
+        raise ValueError('nfft must be greater than or equal to nperseg.')
+    else:
+        nfft = int(nfft)
+
+    if noverlap is None:
+        noverlap = nperseg//2
+    else:
+        noverlap = int(noverlap)
+    if noverlap >= nperseg:
+        raise ValueError('noverlap must be less than nperseg.')
+    nstep = nperseg - noverlap
+
+    # Get window as array
+    if isinstance(window, str) or type(window) is tuple:
+        win = get_window(window, nperseg)
+    else:
+        win = np.asarray(window)
+        if len(win.shape) != 1:
+            raise ValueError('window must be 1-D')
+        if win.shape[0] != nperseg:
+            raise ValueError(f'window must have length of {nperseg}')
+
+    outputlength = nperseg + (nseg-1)*nstep
+    # *** End block of: Taken from _spectral_py.istft() ***
+
+    # Using cast() to make mypy happy:
+    fft_mode = cast(FFT_MODE_TYPE, 'onesided' if input_onesided else 'twosided')
+    scale_to = cast(Literal['magnitude', 'psd'],
+                    {'spectrum': 'magnitude', 'psd': 'psd'}[scaling])
+
+    ST = ShortTimeFFT(win, nstep, fs, fft_mode=fft_mode, mfft=nfft,
+                      scale_to=scale_to, phase_shift=None)
+
+    if boundary:
+        j = nperseg if nperseg % 2 == 0 else nperseg - 1
+        k0 = ST.k_min + nperseg // 2
+        k1 = outputlength - j + k0
+    else:
+        raise NotImplementedError("boundary=False does not make sense with" +
+                                  "ShortTimeFFT.istft()!")
+
+    x = ST.istft(Zxx, k0=k0, k1=k1, f_axis=freq_axis, t_axis=time_axis)
+    t = np.arange(k1 - k0) * ST.T
+    k_hi = ST.upper_border_begin(k1 - k0)[0]
+    # using cast() to make mypy happy:
+    return t, x, (ST.lower_border_end[0], k_hi)
+
+
+def _csd_wrapper(x, y, fs=1.0, window='hann', nperseg=None, noverlap=None,
+                 nfft=None, detrend='constant', return_onesided=True,
+                 scaling='density', axis=-1, average='mean'):
+    """Wrapper for the `csd()` function based on `ShortTimeFFT` for
+        unit testing.
+    """
+    freqs, _, Pxy = _csd_test_shim(x, y, fs, window, nperseg, noverlap, nfft,
+                                   detrend, return_onesided, scaling, axis)
+
+    # The following code is taken from csd():
+    if len(Pxy.shape) >= 2 and Pxy.size > 0:
+        if Pxy.shape[-1] > 1:
+            if average == 'median':
+                # np.median must be passed real arrays for the desired result
+                bias = _median_bias(Pxy.shape[-1])
+                if np.iscomplexobj(Pxy):
+                    Pxy = (np.median(np.real(Pxy), axis=-1)
+                           + 1j * np.median(np.imag(Pxy), axis=-1))
+                else:
+                    Pxy = np.median(Pxy, axis=-1)
+                Pxy /= bias
+            elif average == 'mean':
+                Pxy = Pxy.mean(axis=-1)
+            else:
+                raise ValueError('average must be "median" or "mean", got %s'
+                                 % (average,))
+        else:
+            Pxy = np.reshape(Pxy, Pxy.shape[:-1])
+
+    return freqs, Pxy
+
+
+def _csd_test_shim(x, y, fs=1.0, window='hann', nperseg=None, noverlap=None,
+                   nfft=None, detrend='constant', return_onesided=True,
+                   scaling='density', axis=-1):
+    """Compare output of  _spectral_helper() and ShortTimeFFT, more
+    precisely _spect_helper_csd() for used in csd_wrapper().
+
+   The motivation of this function is to test if the ShortTimeFFT-based
+   wrapper `_spect_helper_csd()` returns the same values as `_spectral_helper`.
+   This function should only be usd by csd() in (unit) testing.
+   """
+    freqs, t, Pxy = _spectral_helper(x, y, fs, window, nperseg, noverlap, nfft,
+                                     detrend, return_onesided, scaling, axis,
+                                     mode='psd')
+    freqs1, Pxy1 = _spect_helper_csd(x, y, fs, window, nperseg, noverlap, nfft,
+                                     detrend, return_onesided, scaling, axis)
+
+    np.testing.assert_allclose(freqs1, freqs)
+    amax_Pxy = max(np.abs(Pxy).max(), 1) if Pxy.size else 1
+    atol = np.finfo(Pxy.dtype).resolution * amax_Pxy  # needed for large Pxy
+    # for c_ in range(Pxy.shape[-1]):
+    #    np.testing.assert_allclose(Pxy1[:, c_], Pxy[:, c_], atol=atol)
+    np.testing.assert_allclose(Pxy1, Pxy, atol=atol)
+    return freqs, t, Pxy
+
+
+def _spect_helper_csd(x, y, fs=1.0, window='hann', nperseg=None, noverlap=None,
+                      nfft=None, detrend='constant', return_onesided=True,
+                      scaling='density', axis=-1):
+    """Wrapper for replacing _spectral_helper() by using the ShortTimeFFT
+      for use by csd().
+
+    This function should be only used by _csd_test_shim() and is only useful
+    for testing the ShortTimeFFT implementation.
+    """
+
+    # The following lines are taken from the original _spectral_helper():
+    same_data = y is x
+    axis = int(axis)
+
+    # Ensure we have np.arrays, get outdtype
+    x = np.asarray(x)
+    if not same_data:
+        y = np.asarray(y)
+    #     outdtype = np.result_type(x, y, np.complex64)
+    # else:
+    #     outdtype = np.result_type(x, np.complex64)
+
+    if not same_data:
+        # Check if we can broadcast the outer axes together
+        xouter = list(x.shape)
+        youter = list(y.shape)
+        xouter.pop(axis)
+        youter.pop(axis)
+        try:
+            outershape = np.broadcast(np.empty(xouter), np.empty(youter)).shape
+        except ValueError as e:
+            raise ValueError('x and y cannot be broadcast together.') from e
+
+    if same_data:
+        if x.size == 0:
+            return np.empty(x.shape), np.empty(x.shape)
+    else:
+        if x.size == 0 or y.size == 0:
+            outshape = outershape + (min([x.shape[axis], y.shape[axis]]),)
+            emptyout = np.moveaxis(np.empty(outshape), -1, axis)
+            return emptyout, emptyout
+
+    if nperseg is not None:  # if specified by user
+        nperseg = int(nperseg)
+        if nperseg < 1:
+            raise ValueError('nperseg must be a positive integer')
+
+    # parse window; if array like, then set nperseg = win.shape
+    n = x.shape[axis] if same_data else max(x.shape[axis], y.shape[axis])
+    win, nperseg = _triage_segments(window, nperseg, input_length=n)
+
+    if nfft is None:
+        nfft = nperseg
+    elif nfft < nperseg:
+        raise ValueError('nfft must be greater than or equal to nperseg.')
+    else:
+        nfft = int(nfft)
+
+    if noverlap is None:
+        noverlap = nperseg // 2
+    else:
+        noverlap = int(noverlap)
+    if noverlap >= nperseg:
+        raise ValueError('noverlap must be less than nperseg.')
+    nstep = nperseg - noverlap
+
+    if np.iscomplexobj(x) and return_onesided:
+        return_onesided = False
+
+    # using cast() to make mypy happy:
+    fft_mode = cast(FFT_MODE_TYPE, 'onesided' if return_onesided
+                    else 'twosided')
+    scale = {'spectrum': 'magnitude', 'density': 'psd'}[scaling]
+    SFT = ShortTimeFFT(win, nstep, fs, fft_mode=fft_mode, mfft=nfft,
+                       scale_to=scale, phase_shift=None)
+
+    # _spectral_helper() calculates X.conj()*Y instead of X*Y.conj():
+    Pxy = SFT.spectrogram(y, x, detr=None if detrend is False else detrend,
+                          p0=0, p1=(n-noverlap)//SFT.hop, k_offset=nperseg//2,
+                          axis=axis).conj()
+    # Note:
+    # 'onesided2X' scaling of ShortTimeFFT conflicts with the
+    # scaling='spectrum' parameter, since it doubles the squared magnitude,
+    # which in the view of the ShortTimeFFT implementation does not make sense.
+    # Hence, the doubling of the square is implemented here:
+    if return_onesided:
+        f_axis = Pxy.ndim - 1 + axis if axis < 0 else axis
+        Pxy = np.moveaxis(Pxy, f_axis, -1)
+        Pxy[..., 1:-1 if SFT.mfft % 2 == 0 else None] *= 2
+        Pxy = np.moveaxis(Pxy, -1, f_axis)
+
+    return SFT.f, Pxy
+
+
+def stft_compare(x, fs=1.0, window='hann', nperseg=256, noverlap=None,
+                 nfft=None, detrend=False, return_onesided=True,
+                 boundary='zeros', padded=True, axis=-1, scaling='spectrum'):
+    """Assert that the results from the existing `stft()` and `_stft_wrapper()`
+    are close to each other.
+
+    For comparing the STFT values an absolute tolerance of the floating point
+    resolution was added to circumvent problems with the following tests:
+    * For float32 the tolerances are much higher in
+      TestSTFT.test_roundtrip_float32()).
+    * The TestSTFT.test_roundtrip_scaling() has a high relative deviation.
+      Interestingly this did not appear in Scipy 1.9.1 but only in the current
+      development version.
+    """
+    kw = dict(x=x, fs=fs, window=window, nperseg=nperseg, noverlap=noverlap,
+              nfft=nfft, detrend=detrend, return_onesided=return_onesided,
+              boundary=boundary, padded=padded, axis=axis, scaling=scaling)
+    f, t, Zxx = stft(**kw)
+    f_wrapper, t_wrapper, Zxx_wrapper = _stft_wrapper(**kw)
+
+    e_msg_part = " of `stft_wrapper()` differ from `stft()`."
+    assert_allclose(f_wrapper, f, err_msg=f"Frequencies {e_msg_part}")
+    assert_allclose(t_wrapper, t, err_msg=f"Time slices {e_msg_part}")
+
+    # Adapted tolerances to account for:
+    atol = np.finfo(Zxx.dtype).resolution * 2
+    assert_allclose(Zxx_wrapper, Zxx, atol=atol,
+                    err_msg=f"STFT values {e_msg_part}")
+    return f, t, Zxx
+
+
+def istft_compare(Zxx, fs=1.0, window='hann', nperseg=None, noverlap=None,
+                  nfft=None, input_onesided=True, boundary=True, time_axis=-1,
+                  freq_axis=-2, scaling='spectrum'):
+    """Assert that the results from the existing `istft()` and
+    `_istft_wrapper()` are close to each other.
+
+    Quirks:
+    * If ``boundary=False`` the comparison is skipped, since it does not
+      make sense with ShortTimeFFT.istft(). Only used in test
+      TestSTFT.test_roundtrip_boundary_extension().
+    * If ShortTimeFFT.istft() decides the STFT is not invertible, the
+      comparison is skipped, since istft() only emits a warning and does not
+      return a correct result. Only used in
+      ShortTimeFFT.test_roundtrip_not_nola().
+    * For comparing the signals an absolute tolerance of the floating point
+      resolution was added to account for the low accuracy of float32 (Occurs
+      only in TestSTFT.test_roundtrip_float32()).
+    """
+    kw = dict(Zxx=Zxx, fs=fs, window=window, nperseg=nperseg,
+              noverlap=noverlap, nfft=nfft, input_onesided=input_onesided,
+              boundary=boundary, time_axis=time_axis, freq_axis=freq_axis,
+              scaling=scaling)
+
+    t, x = istft(**kw)
+    if not boundary:  # skip test_roundtrip_boundary_extension():
+        return t, x  # _istft_wrapper does() not implement this case
+    try:  # if inversion fails, istft() only emits a warning:
+        t_wrapper, x_wrapper, (k_lo, k_hi) = _istft_wrapper(**kw)
+    except ValueError as v:  # Do nothing if inversion fails:
+        if v.args[0] == "Short-time Fourier Transform not invertible!":
+            return t, x
+        raise v
+
+    e_msg_part = " of `istft_wrapper()` differ from `istft()`"
+    assert_allclose(t, t_wrapper, err_msg=f"Sample times {e_msg_part}")
+
+    # Adapted tolerances to account for resolution loss:
+    atol = np.finfo(x.dtype).resolution*2  # instead of default atol = 0
+    rtol = 1e-7  # default for np.allclose()
+
+    # Relax atol on 32-Bit platforms a bit to pass CI tests.
+    #  - Not clear why there are discrepancies (in the FFT maybe?)
+    #  - Not sure what changed on 'i686' since earlier on those test passed
+    if x.dtype == np.float32 and platform.machine() == 'i686':
+        # float32 gets only used by TestSTFT.test_roundtrip_float32() so
+        # we are using the tolerances from there to circumvent CI problems
+        atol, rtol = 1e-4, 1e-5
+    elif platform.machine() in ('aarch64', 'i386', 'i686'):
+        atol = max(atol, 1e-12)  # 2e-15 seems too tight for 32-Bit platforms
+
+    assert_allclose(x_wrapper[k_lo:k_hi], x[k_lo:k_hi], atol=atol, rtol=rtol,
+                    err_msg=f"Signal values {e_msg_part}")
+    return t, x
+
+
+def csd_compare(x, y, fs=1.0, window='hann', nperseg=None, noverlap=None,
+                nfft=None, detrend='constant', return_onesided=True,
+                scaling='density', axis=-1, average='mean'):
+    """Assert that the results from the existing `csd()` and `_csd_wrapper()`
+    are close to each other. """
+    kw = dict(x=x, y=y, fs=fs, window=window, nperseg=nperseg,
+              noverlap=noverlap, nfft=nfft, detrend=detrend,
+              return_onesided=return_onesided, scaling=scaling, axis=axis,
+              average=average)
+    freqs0, Pxy0 = csd(**kw)
+    freqs1, Pxy1 = _csd_wrapper(**kw)
+
+    assert_allclose(freqs1, freqs0)
+    assert_allclose(Pxy1, Pxy0)
+    assert_allclose(freqs1, freqs0)
+    return freqs0, Pxy0
diff --git a/scipy/signal/tests/meson.build b/scipy/signal/tests/meson.build
--- a/scipy/signal/tests/meson.build
+++ b/scipy/signal/tests/meson.build
@@ -1,5 +1,6 @@
 py3.install_sources([
     '__init__.py',
+    '_scipy_spectral_test_shim.py',
     'mpsig.py',
     'test_array_tools.py',
     'test_bsplines.py',
@@ -13,6 +14,7 @@ py3.install_sources([
     'test_peak_finding.py',
     'test_result_type.py',
     'test_savitzky_golay.py',
+    'test_short_time_fft.py',
     'test_signaltools.py',
     'test_spectral.py',
     'test_upfirdn.py',
diff --git a/scipy/signal/tests/test_short_time_fft.py b/scipy/signal/tests/test_short_time_fft.py
new file mode 100644
--- /dev/null
+++ b/scipy/signal/tests/test_short_time_fft.py
@@ -0,0 +1,812 @@
+"""Unit tests for module `_short_time_fft`.
+
+This file's structure loosely groups the tests into the following sequential
+categories:
+
+1. Test function `_calc_dual_canonical_window`.
+2. Test for invalid parameters and exceptions in `ShortTimeFFT` (until the
+    `test_from_window` function).
+3. Test algorithmic properties of STFT/ISTFT. Some tests were ported from
+   ``test_spectral.py``.
+
+Notes
+-----
+* Mypy 0.990 does interpret the line::
+
+        from scipy.stats import norm as normal_distribution
+
+  incorrectly (but the code works), hence a ``type: ignore`` was appended.
+"""
+import math
+from itertools import product
+from typing import cast, get_args, Literal
+
+import numpy as np
+import pytest
+from numpy.testing import assert_allclose, assert_equal
+from scipy.fft import fftshift
+from scipy.stats import norm as normal_distribution  # type: ignore
+from scipy.signal import get_window, welch, stft, istft, spectrogram
+
+from scipy.signal._short_time_fft import FFT_MODE_TYPE, \
+    _calc_dual_canonical_window, ShortTimeFFT, PAD_TYPE
+from scipy.signal.windows import gaussian
+
+
+def test__calc_dual_canonical_window_roundtrip():
+    """Test dual window calculation with a round trip to verify duality.
+
+    Note that this works only for canonical window pairs (having minimal
+    energy) like a Gaussian.
+
+    The window is the same as in the example of `from ShortTimeFFT.from_dual`.
+    """
+    win = gaussian(51, std=10, sym=True)
+    d_win = _calc_dual_canonical_window(win, 10)
+    win2 = _calc_dual_canonical_window(d_win, 10)
+    assert_allclose(win2, win)
+
+
+def test__calc_dual_canonical_window_exceptions():
+    """Raise all exceptions in `_calc_dual_canonical_window`."""
+    # Verify that calculation can fail:
+    with pytest.raises(ValueError, match="hop=5 is larger than window len.*"):
+        _calc_dual_canonical_window(np.ones(4), 5)
+    with pytest.raises(ValueError, match=".* Transform not invertible!"):
+        _calc_dual_canonical_window(np.array([.1, .2, .3, 0]), 4)
+
+    # Verify that parameter `win` may not be integers:
+    with pytest.raises(ValueError, match="Parameter 'win' cannot be of int.*"):
+        _calc_dual_canonical_window(np.ones(4, dtype=int), 1)
+
+
+def test_invalid_initializer_parameters():
+    """Verify that exceptions get raised on invalid parameters when
+    instantiating ShortTimeFFT. """
+    with pytest.raises(ValueError, match=r"Parameter win must be 1d, " +
+                                         r"but win.shape=\(2, 2\)!"):
+        ShortTimeFFT(np.ones((2, 2)), hop=4, fs=1)
+    with pytest.raises(ValueError, match="Parameter win must have " +
+                                         "finite entries"):
+        ShortTimeFFT(np.array([1, np.inf, 2, 3]), hop=4, fs=1)
+    with pytest.raises(ValueError, match="Parameter hop=0 is not " +
+                                         "an integer >= 1!"):
+        ShortTimeFFT(np.ones(4), hop=0, fs=1)
+    with pytest.raises(ValueError, match="Parameter hop=2.0 is not " +
+                                         "an integer >= 1!"):
+        # noinspection PyTypeChecker
+        ShortTimeFFT(np.ones(4), hop=2.0, fs=1)
+    with pytest.raises(ValueError, match=r"dual_win.shape=\(5,\) must equal " +
+                                         r"win.shape=\(4,\)!"):
+        ShortTimeFFT(np.ones(4), hop=2, fs=1, dual_win=np.ones(5))
+    with pytest.raises(ValueError, match="Parameter dual_win must be " +
+                                         "a finite array!"):
+        ShortTimeFFT(np.ones(3), hop=2, fs=1,
+                     dual_win=np.array([np.nan, 2, 3]))
+
+
+def test_exceptions_properties_methods():
+    """Verify that exceptions get raised when setting properties or calling
+    method of ShortTimeFFT to/with invalid values."""
+    SFT = ShortTimeFFT(np.ones(8), hop=4, fs=1)
+    with pytest.raises(ValueError, match="Sampling interval T=-1 must be " +
+                                         "positive!"):
+        SFT.T = -1
+    with pytest.raises(ValueError, match="Sampling frequency fs=-1 must be " +
+                                         "positive!"):
+        SFT.fs = -1
+    with pytest.raises(ValueError, match="fft_mode='invalid_typ' not in " +
+                                         r"\('twosided', 'centered', " +
+                                         r"'onesided', 'onesided2X'\)!"):
+        SFT.fft_mode = 'invalid_typ'
+    with pytest.raises(ValueError, match="For scaling is None, " +
+                                         "fft_mode='onesided2X' is invalid.*"):
+        SFT.fft_mode = 'onesided2X'
+    with pytest.raises(ValueError, match="Attribute mfft=7 needs to be " +
+                                         "at least the window length.*"):
+        SFT.mfft = 7
+    with pytest.raises(ValueError, match="scaling='invalid' not in.*"):
+        # noinspection PyTypeChecker
+        SFT.scale_to('invalid')
+    with pytest.raises(ValueError, match="phase_shift=3.0 has the unit .*"):
+        SFT.phase_shift = 3.0
+    with pytest.raises(ValueError, match="-mfft < phase_shift < mfft " +
+                                         "does not hold.*"):
+        SFT.phase_shift = 2*SFT.mfft
+    with pytest.raises(ValueError, match="Parameter padding='invalid' not.*"):
+        # noinspection PyTypeChecker
+        g = SFT._x_slices(np.zeros(16), k_off=0, p0=0, p1=1, padding='invalid')
+        next(g)  # execute generator
+    with pytest.raises(ValueError, match="Trend type must be 'linear' " +
+                                         "or 'constant'"):
+        # noinspection PyTypeChecker
+        SFT.stft_detrend(np.zeros(16), detr='invalid')
+    with pytest.raises(ValueError, match="Parameter detr=nan is not a str, " +
+                                         "function or None!"):
+        # noinspection PyTypeChecker
+        SFT.stft_detrend(np.zeros(16), detr=np.nan)
+    with pytest.raises(ValueError, match="Invalid Parameter p0=0, p1=200.*"):
+        SFT.p_range(100, 0, 200)
+
+    with pytest.raises(ValueError, match="f_axis=0 may not be equal to " +
+                                         "t_axis=0!"):
+        SFT.istft(np.zeros((SFT.f_pts, 2)), t_axis=0, f_axis=0)
+    with pytest.raises(ValueError, match=r"S.shape\[f_axis\]=2 must be equal" +
+                                         " to self.f_pts=5.*"):
+        SFT.istft(np.zeros((2, 2)))
+    with pytest.raises(ValueError, match=r"S.shape\[t_axis\]=1 needs to have" +
+                                         " at least 2 slices.*"):
+        SFT.istft(np.zeros((SFT.f_pts, 1)))
+    with pytest.raises(ValueError, match=r".*\(k1=100\) <= \(k_max=12\) " +
+                                         "is false!$"):
+        SFT.istft(np.zeros((SFT.f_pts, 3)), k1=100)
+    with pytest.raises(ValueError, match=r"\(k1=1\) - \(k0=0\) = 1 has to " +
+                                         "be at least.* length 4!"):
+        SFT.istft(np.zeros((SFT.f_pts, 3)), k0=0, k1=1)
+
+    with pytest.raises(ValueError, match=r"Parameter axes_seq='invalid' " +
+                                         r"not in \['tf', 'ft'\]!"):
+        # noinspection PyTypeChecker
+        SFT.extent(n=100, axes_seq='invalid')
+    with pytest.raises(ValueError, match="Attribute fft_mode=twosided must.*"):
+        SFT.fft_mode = 'twosided'
+        SFT.extent(n=100)
+
+
+@pytest.mark.parametrize('m', ('onesided', 'onesided2X'))
+def test_exceptions_fft_mode_complex_win(m: FFT_MODE_TYPE):
+    """Verify hat one-sided spectra are not allowed with complex-valued
+    windows.
+
+    The reason being, the `rfft` function only accepts real-valued input.
+    """
+    with pytest.raises(ValueError,
+                       match=f"One-sided spectra, i.e., fft_mode='{m}'.*"):
+        ShortTimeFFT(np.ones(8)*1j, hop=4, fs=1, fft_mode=m)
+
+    SFT = ShortTimeFFT(np.ones(8)*1j, hop=4, fs=1, fft_mode='twosided')
+    with pytest.raises(ValueError,
+                       match=f"One-sided spectra, i.e., fft_mode='{m}'.*"):
+        SFT.fft_mode = m
+
+
+def test_invalid_fft_mode_RuntimeError():
+    """Ensure exception gets raised when property `fft_mode` is invalid. """
+    SFT = ShortTimeFFT(np.ones(8), hop=4, fs=1)
+    SFT._fft_mode = 'invalid_typ'
+
+    with pytest.raises(RuntimeError):
+        _ = SFT.f
+    with pytest.raises(RuntimeError):
+        SFT._fft_func(np.ones(8))
+    with pytest.raises(RuntimeError):
+        SFT._ifft_func(np.ones(8))
+
+
+@pytest.mark.parametrize('win_params, Nx', [(('gaussian', 2.), 9),  # in docstr
+                                            ('triang', 7),
+                                            (('kaiser', 4.0), 9),
+                                            (('exponential', None, 1.), 9),
+                                            (4.0, 9)])
+def test_from_window(win_params, Nx: int):
+    """Verify that `from_window()` handels parameters correctly.
+
+    The window parameterizations are documented in the `get_window` docstring.
+    """
+    w_sym, fs = get_window(win_params, Nx, fftbins=False), 16.
+    w_per = get_window(win_params, Nx, fftbins=True)
+    SFT0 = ShortTimeFFT(w_sym, hop=3, fs=fs, fft_mode='twosided',
+                        scale_to='psd', phase_shift=1)
+    nperseg = len(w_sym)
+    noverlap = nperseg - SFT0.hop
+    SFT1 = ShortTimeFFT.from_window(win_params, fs, nperseg, noverlap,
+                                    symmetric_win=True, fft_mode='twosided',
+                                    scale_to='psd', phase_shift=1)
+    # periodic window:
+    SFT2 = ShortTimeFFT.from_window(win_params, fs, nperseg, noverlap,
+                                    symmetric_win=False, fft_mode='twosided',
+                                    scale_to='psd', phase_shift=1)
+    # Be informative when comparing instances:
+    assert_equal(SFT1.win, SFT0.win)
+    assert_allclose(SFT2.win, w_per / np.sqrt(sum(w_per**2) * fs))
+    for n_ in ('hop', 'T', 'fft_mode', 'mfft', 'scaling', 'phase_shift'):
+        v0, v1, v2 = (getattr(SFT_, n_) for SFT_ in (SFT0, SFT1, SFT2))
+        assert v1 == v0, f"SFT1.{n_}={v1} does not equal SFT0.{n_}={v0}"
+        assert v2 == v0, f"SFT2.{n_}={v2} does not equal SFT0.{n_}={v0}"
+
+
+def test_dual_win_roundtrip():
+    """Verify the duality of `win` and `dual_win`.
+
+    Note that this test does not work for arbitrary windows, since dual windows
+    are not unique. It always works for invertible STFTs if the windows do not
+    overlap.
+    """
+    # Non-standard values for keyword arguments (except for `scale_to`):
+    kw = dict(hop=4, fs=1, fft_mode='twosided', mfft=8, scale_to=None,
+              phase_shift=2)
+    SFT0 = ShortTimeFFT(np.ones(4), **kw)
+    SFT1 = ShortTimeFFT.from_dual(SFT0.dual_win, **kw)
+    assert_allclose(SFT1.dual_win, SFT0.win)
+
+
+@pytest.mark.parametrize('scale_to, fac_psd, fac_mag',
+                         [(None, 0.25, 0.125),
+                          ('magnitude', 2.0, 1),
+                          ('psd', 1, 0.5)])
+def test_scaling(scale_to: Literal['magnitude', 'psd'], fac_psd, fac_mag):
+    """Verify scaling calculations.
+
+    * Verify passing `scale_to`parameter  to ``__init__().
+    * Roundtrip while changing scaling factor.
+    """
+    SFT = ShortTimeFFT(np.ones(4) * 2, hop=4, fs=1, scale_to=scale_to)
+    assert SFT.fac_psd == fac_psd
+    assert SFT.fac_magnitude == fac_mag
+    # increase coverage by accessing properties twice:
+    assert SFT.fac_psd == fac_psd
+    assert SFT.fac_magnitude == fac_mag
+
+    x = np.fft.irfft([0, 0, 7, 0, 0, 0, 0])  # periodic signal
+    Sx = SFT.stft(x)
+    Sx_mag, Sx_psd = Sx * SFT.fac_magnitude, Sx * SFT.fac_psd
+
+    SFT.scale_to('magnitude')
+    x_mag = SFT.istft(Sx_mag, k1=len(x))
+    assert_allclose(x_mag, x)
+
+    SFT.scale_to('psd')
+    x_psd = SFT.istft(Sx_psd, k1=len(x))
+    assert_allclose(x_psd, x)
+
+
+def test_scale_to():
+    """Verify `scale_to()` method."""
+    SFT = ShortTimeFFT(np.ones(4) * 2, hop=4, fs=1, scale_to=None)
+
+    SFT.scale_to('magnitude')
+    assert SFT.scaling == 'magnitude'
+    assert SFT.fac_psd == 2.0
+    assert SFT.fac_magnitude == 1
+
+    SFT.scale_to('psd')
+    assert SFT.scaling == 'psd'
+    assert SFT.fac_psd == 1
+    assert SFT.fac_magnitude == 0.5
+
+    SFT.scale_to('psd')  # needed for coverage
+
+    for scale, s_fac in zip(('magnitude', 'psd'), (8, 4)):
+        SFT = ShortTimeFFT(np.ones(4) * 2, hop=4, fs=1, scale_to=None)
+        dual_win = SFT.dual_win.copy()
+
+        SFT.scale_to(cast(Literal['magnitude', 'psd'], scale))
+        assert_allclose(SFT.dual_win, dual_win * s_fac)
+
+
+def test_x_slices_padding():
+    """Verify padding.
+
+    The reference arrays were taken from  the docstrings of `zero_ext`,
+    `const_ext`, `odd_ext()`, and `even_ext()` from the _array_tools module.
+    """
+    SFT = ShortTimeFFT(np.ones(5), hop=4, fs=1)
+    x = np.array([[1, 2, 3, 4, 5], [0, 1, 4, 9, 16]], dtype=float)
+    d = {'zeros': [[[0, 0, 1, 2, 3], [0, 0, 0, 1, 4]],
+                   [[3, 4, 5, 0, 0], [4, 9, 16, 0, 0]]],
+         'edge': [[[1, 1, 1, 2, 3], [0, 0, 0, 1, 4]],
+                  [[3, 4, 5, 5, 5], [4, 9, 16, 16, 16]]],
+         'even': [[[3, 2, 1, 2, 3], [4, 1, 0, 1, 4]],
+                  [[3, 4, 5, 4, 3], [4, 9, 16, 9, 4]]],
+         'odd': [[[-1, 0, 1, 2, 3], [-4, -1, 0, 1, 4]],
+                 [[3, 4, 5, 6, 7], [4, 9, 16, 23, 28]]]}
+    for p_, xx in d.items():
+        gen = SFT._x_slices(np.array(x), 0, 0, 2, padding=cast(PAD_TYPE, p_))
+        yy = np.array([y_.copy() for y_ in gen])  # due to inplace copying
+        assert_equal(yy, xx, err_msg=f"Failed '{p_}' padding.")
+
+
+def test_invertible():
+    """Verify `invertible` property. """
+    SFT = ShortTimeFFT(np.ones(8), hop=4, fs=1)
+    assert SFT.invertible
+    SFT = ShortTimeFFT(np.ones(8), hop=9, fs=1)
+    assert not SFT.invertible
+
+
+def test_border_values():
+    """Ensure that minimum and maximum values of slices are correct."""
+    SFT = ShortTimeFFT(np.ones(8), hop=4, fs=1)
+    assert SFT.p_min == 0
+    assert SFT.k_min == -4
+    assert SFT.lower_border_end == (4, 1)
+    assert SFT.lower_border_end == (4, 1)  # needed to test caching
+    assert SFT.p_max(10) == 4
+    assert SFT.k_max(10) == 16
+    assert SFT.upper_border_begin(10) == (4, 2)
+
+
+def test_border_values_exotic():
+    """Ensure that the border calculations are correct for windows with
+    zeros. """
+    w = np.array([0, 0, 0, 0, 0, 0, 0, 1.])
+    SFT = ShortTimeFFT(w, hop=1, fs=1)
+    assert SFT.lower_border_end == (0, 0)
+
+    SFT = ShortTimeFFT(np.flip(w), hop=20, fs=1)
+    assert SFT.upper_border_begin(4) == (0, 0)
+
+    SFT._hop = -1  # provoke unreachable line
+    with pytest.raises(RuntimeError):
+        _ = SFT.k_max(4)
+    with pytest.raises(RuntimeError):
+        _ = SFT.k_min
+
+
+def test_t():
+    """Verify that the times of the slices are correct. """
+    SFT = ShortTimeFFT(np.ones(8), hop=4, fs=2)
+    assert SFT.T == 1/2
+    assert SFT.fs == 2.
+    assert SFT.delta_t == 4 * 1/2
+    t_stft = np.arange(0, SFT.p_max(10)) * SFT.delta_t
+    assert_equal(SFT.t(10), t_stft)
+    assert_equal(SFT.t(10, 1, 3), t_stft[1:3])
+    SFT.T = 1/4
+    assert SFT.T == 1/4
+    assert SFT.fs == 4
+    SFT.fs = 1/8
+    assert SFT.fs == 1/8
+    assert SFT.T == 8
+
+
+@pytest.mark.parametrize('fft_mode, f',
+                         [('onesided', [0., 1., 2.]),
+                          ('onesided2X', [0., 1., 2.]),
+                          ('twosided', [0., 1., 2., -2., -1.]),
+                          ('centered', [-2., -1., 0., 1., 2.])])
+def test_f(fft_mode: FFT_MODE_TYPE, f):
+    """Verify the frequency values property `f`."""
+    SFT = ShortTimeFFT(np.ones(5), hop=4, fs=5, fft_mode=fft_mode,
+                       scale_to='psd')
+    assert_equal(SFT.f, f)
+
+
+def test_extent():
+    """Ensure that the `extent()` method is correct. """
+    SFT = ShortTimeFFT(np.ones(32), hop=4, fs=32, fft_mode='onesided')
+    assert SFT.extent(100, 'tf', False) == (-0.375, 3.625, 0.0, 17.0)
+    assert SFT.extent(100, 'ft', False) == (0.0, 17.0, -0.375, 3.625)
+    assert SFT.extent(100, 'tf', True) == (-0.4375, 3.5625, -0.5, 16.5)
+    assert SFT.extent(100, 'ft', True) == (-0.5, 16.5, -0.4375, 3.5625)
+
+    SFT = ShortTimeFFT(np.ones(32), hop=4, fs=32, fft_mode='centered')
+    assert SFT.extent(100, 'tf', False) == (-0.375, 3.625, -16.0, 15.0)
+
+
+def test_spectrogram():
+    """Verify spectrogram and cross-spectrogram methods. """
+    SFT = ShortTimeFFT(np.ones(8), hop=4, fs=1)
+    x, y = np.ones(10), np.arange(10)
+    X, Y = SFT.stft(x), SFT.stft(y)
+    assert_allclose(SFT.spectrogram(x), X.real**2+X.imag**2)
+    assert_allclose(SFT.spectrogram(x, y), X * Y.conj())
+
+
+@pytest.mark.parametrize('n', [8, 9])
+def test_fft_func_roundtrip(n: int):
+    """Test roundtrip `ifft_func(fft_func(x)) == x` for all permutations of
+    relevant parameters. """
+    np.random.seed(2394795)
+    x0 = np.random.rand(n)
+    w, h_n = np.ones(n), 4
+
+    pp = dict(
+        fft_mode=get_args(FFT_MODE_TYPE),
+        mfft=[None, n, n+1, n+2],
+        scaling=[None, 'magnitude', 'psd'],
+        phase_shift=[None, -n+1, 0, n // 2, n-1])
+    for f_typ, mfft, scaling, phase_shift in product(*pp.values()):
+        if f_typ == 'onesided2X' and scaling is None:
+            continue  # this combination is forbidden
+        SFT = ShortTimeFFT(w, h_n, fs=n, fft_mode=f_typ, mfft=mfft,
+                           scale_to=scaling, phase_shift=phase_shift)
+        X0 = SFT._fft_func(x0)
+        x1 = SFT._ifft_func(X0)
+        assert_allclose(x0, x1, err_msg="_fft_func() roundtrip failed for " +
+                        f"{f_typ=}, {mfft=}, {scaling=}, {phase_shift=}")
+
+    SFT = ShortTimeFFT(w, h_n, fs=1)
+    SFT._fft_mode = 'invalid_fft'  # type: ignore
+    with pytest.raises(RuntimeError):
+        SFT._fft_func(x0)
+    with pytest.raises(RuntimeError):
+        SFT._ifft_func(x0)
+
+
+@pytest.mark.parametrize('i', range(19))
+def test_impulse_roundtrip(i):
+    """Roundtrip for an impulse being at different positions `i`."""
+    n = 19
+    w, h_n = np.ones(8), 3
+    x = np.zeros(n)
+    x[i] = 1
+
+    SFT = ShortTimeFFT(w, hop=h_n, fs=1, scale_to=None, phase_shift=None)
+    Sx = SFT.stft(x)
+    # test slicing the input signal into two parts:
+    n_q = SFT.nearest_k_p(n // 2)
+    Sx0 = SFT.stft(x[:n_q], padding='zeros')
+    Sx1 = SFT.stft(x[n_q:], padding='zeros')
+    q0_ub = SFT.upper_border_begin(n_q)[1] - SFT.p_min
+    q1_le = SFT.lower_border_end[1] - SFT.p_min
+    assert_allclose(Sx0[:, :q0_ub], Sx[:, :q0_ub], err_msg=f"{i=}")
+    assert_allclose(Sx1[:, q1_le:], Sx[:, q1_le-Sx1.shape[1]:],
+                    err_msg=f"{i=}")
+
+    Sx01 = np.hstack((Sx0[:, :q0_ub],
+                      Sx0[:, q0_ub:] + Sx1[:, :q1_le],
+                      Sx1[:, q1_le:]))
+    assert_allclose(Sx, Sx01, atol=1e-8, err_msg=f"{i=}")
+
+    y = SFT.istft(Sx, 0, n)
+    assert_allclose(y, x, atol=1e-8, err_msg=f"{i=}")
+    y0 = SFT.istft(Sx, 0, n//2)
+    assert_allclose(x[:n//2], y0, atol=1e-8, err_msg=f"{i=}")
+    y1 = SFT.istft(Sx, n // 2, n)
+    assert_allclose(x[n // 2:], y1, atol=1e-8, err_msg=f"{i=}")
+
+
+@pytest.mark.parametrize('hop', [1, 7, 8])
+def test_asymmetric_window_roundtrip(hop: int):
+    """An asymmetric window could uncover indexing problems. """
+    np.random.seed(23371)
+
+    w = np.arange(16) / 8  # must be of type float
+    w[len(w)//2:] = 1
+    SFT = ShortTimeFFT(w, hop, fs=1)
+
+    x = 10 * np.random.randn(64)
+    Sx = SFT.stft(x)
+    x1 = SFT.istft(Sx, k1=len(x))
+    assert_allclose(x1, x1, err_msg="Roundtrip for asymmetric window with " +
+                                    f" {hop=} failed!")
+
+
+@pytest.mark.parametrize('m_num', [6, 7])
+def test_minimal_length_signal(m_num):
+    """Verify that the shortest allowed signal works. """
+    SFT = ShortTimeFFT(np.ones(m_num), m_num//2, fs=1)
+    n = math.ceil(m_num/2)
+    x = np.ones(n)
+    Sx = SFT.stft(x)
+    x1 = SFT.istft(Sx, k1=n)
+    assert_allclose(x1, x, err_msg=f"Roundtrip minimal length signal ({n=})" +
+                                   f" for {m_num} sample window failed!")
+    with pytest.raises(ValueError, match=rf"len\(x\)={n-1} must be >= ceil.*"):
+        SFT.stft(x[:-1])
+    with pytest.raises(ValueError, match=rf"S.shape\[t_axis\]={Sx.shape[1]-1}"
+                       f" needs to have at least {Sx.shape[1]} slices"):
+        SFT.istft(Sx[:, :-1], k1=n)
+
+
+def test_tutorial_stft_sliding_win():
+    """Verify example in "Sliding Windows" subsection from the "User Guide".
+
+    In :ref:`tutorial_stft_sliding_win` (file ``signal.rst``) of the
+    :ref:`user_guide` the behavior the border behavior of
+    ``ShortTimeFFT(np.ones(6), 2, fs=1)`` with a 50 sample signal is discussed.
+    This test verifies the presented indexes.
+    """
+    SFT = ShortTimeFFT(np.ones(6), 2, fs=1)
+
+    # Lower border:
+    assert SFT.m_num_mid == 3, f"Slice middle is not 3 but {SFT.m_num_mid=}"
+    assert SFT.p_min == -1, f"Lowest slice {SFT.p_min=} is not -1"
+    assert SFT.k_min == -5, f"Lowest slice sample {SFT.p_min=} is not -5"
+    k_lb, p_lb = SFT.lower_border_end
+    assert p_lb == 2, f"First unaffected slice {p_lb=} is not 2"
+    assert k_lb == 5, f"First unaffected sample {k_lb=} is not 5"
+
+    n = 50  # upper signal border
+    assert (p_max := SFT.p_max(n)) == 27, f"Last slice {p_max=} must be 27"
+    assert (k_max := SFT.k_max(n)) == 55, f"Last sample {k_max=} must be 55"
+    k_ub, p_ub = SFT.upper_border_begin(n)
+    assert p_ub == 24, f"First upper border slice {p_ub=} must be 24"
+    assert k_ub == 45, f"First upper border slice {k_ub=} must be 45"
+
+
+def test_tutorial_stft_legacy_stft():
+    """Verify STFT example in "Comparison with Legacy Implementation" from the
+    "User Guide".
+
+    In :ref:`tutorial_stft_legacy_stft` (file ``signal.rst``) of the
+    :ref:`user_guide` the legacy and the new implementation are compared.
+    """
+    fs, N = 200, 1001  # # 200 Hz sampling rate for 5 s signal
+    t_z = np.arange(N) / fs  # time indexes for signal
+    z = np.exp(2j*np.pi * 70 * (t_z - 0.2 * t_z ** 2))  # complex-valued chirp
+
+    nperseg, noverlap = 50, 40
+    win = ('gaussian', 1e-2 * fs)  # Gaussian with 0.01 s standard deviation
+
+    # Legacy STFT:
+    f0_u, t0, Sz0_u = stft(z, fs, win, nperseg, noverlap,
+                           return_onesided=False, scaling='spectrum')
+    Sz0 = fftshift(Sz0_u, axes=0)
+
+    # New STFT:
+    SFT = ShortTimeFFT.from_window(win, fs, nperseg, noverlap,
+                                   fft_mode='centered',
+                                   scale_to='magnitude', phase_shift=None)
+    Sz1 = SFT.stft(z)
+
+    assert_allclose(Sz0, Sz1[:, 2:-1])
+
+    assert_allclose((abs(Sz1[:, 1]).min(), abs(Sz1[:, 1]).max()),
+                    (6.925060911593139e-07, 8.00271269218721e-07))
+
+    t0_r, z0_r = istft(Sz0_u, fs, win, nperseg, noverlap, input_onesided=False,
+                       scaling='spectrum')
+    z1_r = SFT.istft(Sz1, k1=N)
+    assert len(z0_r) == N + 9
+    assert_allclose(z0_r[:N], z)
+    assert_allclose(z1_r, z)
+
+    #  Spectrogram is just the absolute square of th STFT:
+    assert_allclose(SFT.spectrogram(z), abs(Sz1) ** 2)
+
+
+def test_tutorial_stft_legacy_spectrogram():
+    """Verify spectrogram example in "Comparison with Legacy Implementation"
+    from the "User Guide".
+
+    In :ref:`tutorial_stft_legacy_stft` (file ``signal.rst``) of the
+    :ref:`user_guide` the legacy and the new implementation are compared.
+    """
+    fs, N = 200, 1001  # 200 Hz sampling rate for almost 5 s signal
+    t_z = np.arange(N) / fs  # time indexes for signal
+    z = np.exp(2j*np.pi*70 * (t_z - 0.2*t_z**2))  # complex-valued sweep
+
+    nperseg, noverlap = 50, 40
+    win = ('gaussian', 1e-2 * fs)  # Gaussian with 0.01 s standard dev.
+
+    # Legacy spectrogram:
+    f2_u, t2, Sz2_u = spectrogram(z, fs, win, nperseg, noverlap, detrend=None,
+                                  return_onesided=False, scaling='spectrum',
+                                  mode='complex')
+
+    f2, Sz2 = fftshift(f2_u), fftshift(Sz2_u, axes=0)
+
+    # New STFT:
+    SFT = ShortTimeFFT.from_window(win, fs, nperseg, noverlap,
+                                   fft_mode='centered', scale_to='magnitude',
+                                   phase_shift=None)
+    Sz3 = SFT.stft(z, p0=0, p1=(N-noverlap) // SFT.hop, k_offset=nperseg // 2)
+    t3 = SFT.t(N, p0=0, p1=(N-noverlap) // SFT.hop, k_offset=nperseg // 2)
+
+    assert_allclose(t2, t3)
+    assert_allclose(f2, SFT.f)
+    assert_allclose(Sz2, Sz3)
+
+
+def test_permute_axes():
+    """Verify correctness of four-dimensional signal by permuting its
+    shape. """
+    n = 25
+    SFT = ShortTimeFFT(np.ones(8)/8, hop=3, fs=n)
+    x0 = np.arange(n)
+    Sx0 = SFT.stft(x0)
+    Sx0 = Sx0.reshape((Sx0.shape[0], 1, 1, 1, Sx0.shape[-1]))
+    SxT = np.moveaxis(Sx0, (0, -1), (-1, 0))
+
+    atol = 2 * np.finfo(SFT.win.dtype).resolution
+    for i in range(4):
+        y = np.reshape(x0, np.roll((n, 1, 1, 1), i))
+        Sy = SFT.stft(y, axis=i)
+        assert_allclose(Sy, np.moveaxis(Sx0, 0, i))
+
+        yb0 = SFT.istft(Sy, k1=n, f_axis=i)
+        assert_allclose(yb0, y, atol=atol)
+        # explicit t-axis parameter (for coverage):
+        yb1 = SFT.istft(Sy, k1=n, f_axis=i, t_axis=Sy.ndim-1)
+        assert_allclose(yb1, y, atol=atol)
+
+        SyT = np.moveaxis(Sy, (i, -1), (-1, i))
+        assert_allclose(SyT, np.moveaxis(SxT, 0, i))
+
+        ybT = SFT.istft(SyT, k1=n, t_axis=i, f_axis=-1)
+        assert_allclose(ybT, y, atol=atol)
+
+
+@pytest.mark.parametrize('window, n, nperseg, noverlap',
+                         [('boxcar', 100, 10, 0),     # Test no overlap
+                          ('boxcar', 100, 10, 9),     # Test high overlap
+                          ('bartlett', 101, 51, 26),  # Test odd nperseg
+                          ('hann', 1024, 256, 128),   # Test defaults
+                          (('tukey', 0.5), 1152, 256, 64),  # Test Tukey
+                          ('hann', 1024, 256, 255),   # Test overlapped hann
+                          ('boxcar', 100, 10, 3),     # NOLA True, COLA False
+                          ('bartlett', 101, 51, 37),  # NOLA True, COLA False
+                          ('hann', 1024, 256, 127),   # NOLA True, COLA False
+                          # NOLA True, COLA False:
+                          (('tukey', 0.5), 1152, 256, 14),
+                          ('hann', 1024, 256, 5)])    # NOLA True, COLA False
+def test_roundtrip_windows(window, n: int, nperseg: int, noverlap: int):
+    """Roundtrip test adapted from `test_spectral.TestSTFT`.
+
+    The parameters are taken from the methods test_roundtrip_real(),
+    test_roundtrip_nola_not_cola(), test_roundtrip_float32(),
+    test_roundtrip_complex().
+    """
+    np.random.seed(2394655)
+
+    w = get_window(window, nperseg)
+    SFT = ShortTimeFFT(w, nperseg - noverlap, fs=1, fft_mode='twosided',
+                       phase_shift=None)
+
+    z = 10 * np.random.randn(n) + 10j * np.random.randn(n)
+    Sz = SFT.stft(z)
+    z1 = SFT.istft(Sz, k1=len(z))
+    assert_allclose(z, z1, err_msg="Roundtrip for complex values failed")
+
+    x = 10 * np.random.randn(n)
+    Sx = SFT.stft(x)
+    x1 = SFT.istft(Sx, k1=len(z))
+    assert_allclose(x, x1, err_msg="Roundtrip for float values failed")
+
+    x32 = x.astype(np.float32)
+    Sx32 = SFT.stft(x32)
+    x32_1 = SFT.istft(Sx32, k1=len(x32))
+    assert_allclose(x32, x32_1,
+                    err_msg="Roundtrip for 32 Bit float values failed")
+
+
+@pytest.mark.parametrize('signal_type', ('real', 'complex'))
+def test_roundtrip_complex_window(signal_type):
+    """Test roundtrip for complex-valued window function
+
+    The purpose of this test is to check if the dual window is calculated
+    correctly for complex-valued windows.
+    """
+    np.random.seed(1354654)
+    win = np.exp(2j*np.linspace(0, np.pi, 8))
+    SFT = ShortTimeFFT(win, 3, fs=1, fft_mode='twosided')
+
+    z = 10 * np.random.randn(11)
+    if signal_type == 'complex':
+        z = z + 2j * z
+    Sz = SFT.stft(z)
+    z1 = SFT.istft(Sz, k1=len(z))
+    assert_allclose(z, z1,
+                    err_msg="Roundtrip for complex-valued window failed")
+
+
+def test_average_all_segments():
+    """Compare `welch` function with stft mean.
+
+    Ported from `TestSpectrogram.test_average_all_segments` from file
+    ``test__spectral.py``.
+    """
+    x = np.random.randn(1024)
+
+    fs = 1.0
+    window = ('tukey', 0.25)
+    nperseg, noverlap = 16, 2
+    fw, Pw = welch(x, fs, window, nperseg, noverlap)
+    SFT = ShortTimeFFT.from_window(window, fs, nperseg, noverlap,
+                                   fft_mode='onesided2X', scale_to='psd',
+                                   phase_shift=None)
+    # `welch` positions the window differently than the STFT:
+    P = SFT.spectrogram(x, detr='constant', p0=0,
+                        p1=(len(x)-noverlap)//SFT.hop, k_offset=nperseg//2)
+
+    assert_allclose(SFT.f, fw)
+    assert_allclose(np.mean(P, axis=-1), Pw)
+
+
+@pytest.mark.parametrize('window, N, nperseg, noverlap, mfft',
+                         # from test_roundtrip_padded_FFT:
+                         [('hann', 1024, 256, 128, 512),
+                          ('hann', 1024, 256, 128, 501),
+                          ('boxcar', 100, 10, 0, 33),
+                          (('tukey', 0.5), 1152, 256, 64, 1024),
+                          # from test_roundtrip_padded_signal:
+                          ('boxcar', 101, 10, 0, None),
+                          ('hann', 1000, 256, 128, None),
+                          # from test_roundtrip_boundary_extension:
+                          ('boxcar', 100, 10, 0, None),
+                          ('boxcar', 100, 10, 9, None)])
+@pytest.mark.parametrize('padding', get_args(PAD_TYPE))
+def test_stft_padding_roundtrip(window, N: int, nperseg: int, noverlap: int,
+                                mfft: int, padding):
+    """Test the parameter 'padding' of `stft` with roundtrips.
+
+    The STFT parametrizations were taken from the methods
+    `test_roundtrip_padded_FFT`, `test_roundtrip_padded_signal` and
+    `test_roundtrip_boundary_extension` from class `TestSTFT` in  file
+    ``test_spectral.py``. Note that the ShortTimeFFT does not need the
+    concept of "boundary extension".
+    """
+    x = normal_distribution.rvs(size=N, random_state=2909)  # real signal
+    z = x * np.exp(1j * np.pi / 4)  # complex signal
+
+    SFT = ShortTimeFFT.from_window(window, 1, nperseg, noverlap,
+                                   fft_mode='twosided', mfft=mfft)
+    Sx = SFT.stft(x, padding=padding)
+    x1 = SFT.istft(Sx, k1=N)
+    assert_allclose(x1, x,
+                    err_msg=f"Failed real roundtrip with '{padding}' padding")
+
+    Sz = SFT.stft(z, padding=padding)
+    z1 = SFT.istft(Sz, k1=N)
+    assert_allclose(z1, z, err_msg="Failed complex roundtrip with " +
+                    f" '{padding}' padding")
+
+
+@pytest.mark.parametrize('N_x', (128, 129, 255, 256, 1337))  # signal length
+@pytest.mark.parametrize('w_size', (128, 256))  # window length
+@pytest.mark.parametrize('t_step', (4, 64))  # SFT time hop
+@pytest.mark.parametrize('f_c', (7., 23.))  # frequency of input sine
+def test_energy_conservation(N_x: int, w_size: int, t_step: int, f_c: float):
+    """Test if a `psd`-scaled STFT conserves the L2 norm.
+
+    This test is adapted from MNE-Python [1]_. Besides being battle-tested,
+    this test has the benefit of using non-standard window including
+    non-positive values and a 2d input signal.
+
+    Since `ShortTimeFFT` requires the signal length `N_x` to be at least the
+    window length `w_size`, the parameter `N_x` was changed from
+    ``(127, 128, 255, 256, 1337)`` to ``(128, 129, 255, 256, 1337)`` to be
+    more useful.
+
+    .. [1] File ``test_stft.py`` of MNE-Python
+        https://github.com/mne-tools/mne-python/blob/main/mne/time_frequency/tests/test_stft.py
+    """
+    window = np.sin(np.arange(.5, w_size + .5) / w_size * np.pi)
+    SFT = ShortTimeFFT(window, t_step, fs=1000, fft_mode='onesided2X',
+                       scale_to='psd')
+    atol = 2*np.finfo(window.dtype).resolution
+    N_x = max(N_x, w_size)  # minimal sing
+    # Test with low frequency signal
+    t = np.arange(N_x).astype(np.float64)
+    x = np.sin(2 * np.pi * f_c * t * SFT.T)
+    x = np.array([x, x + 1.])
+    X = SFT.stft(x)
+    xp = SFT.istft(X, k1=N_x)
+
+    max_freq = SFT.f[np.argmax(np.sum(np.abs(X[0]) ** 2, axis=1))]
+
+    assert X.shape[1] == SFT.f_pts
+    assert np.all(SFT.f >= 0.)
+    assert np.abs(max_freq - f_c) < 1.
+    assert_allclose(x, xp, atol=atol)
+
+    # check L2-norm squared (i.e., energy) conservation:
+    E_x = np.sum(x**2, axis=-1) * SFT.T  # numerical integration
+    aX2 = X.real**2 + X.imag.real**2
+    E_X = np.sum(np.sum(aX2, axis=-1) * SFT.delta_t, axis=-1) * SFT.delta_f
+    assert_allclose(E_X, E_x, atol=atol)
+
+    # Test with random signal
+    np.random.seed(2392795)
+    x = np.random.randn(2, N_x)
+    X = SFT.stft(x)
+    xp = SFT.istft(X, k1=N_x)
+
+    assert X.shape[1] == SFT.f_pts
+    assert np.all(SFT.f >= 0.)
+    assert np.abs(max_freq - f_c) < 1.
+    assert_allclose(x, xp, atol=atol)
+
+    # check L2-norm squared (i.e., energy) conservation:
+    E_x = np.sum(x**2, axis=-1) * SFT.T  # numeric integration
+    aX2 = X.real ** 2 + X.imag.real ** 2
+    E_X = np.sum(np.sum(aX2, axis=-1) * SFT.delta_t, axis=-1) * SFT.delta_f
+    assert_allclose(E_X, E_x, atol=atol)
+
+    # Try with empty array
+    x = np.zeros((0, N_x))
+    X = SFT.stft(x)
+    xp = SFT.istft(X, k1=N_x)
+    assert xp.shape == x.shape
diff --git a/scipy/signal/tests/test_spectral.py b/scipy/signal/tests/test_spectral.py
--- a/scipy/signal/tests/test_spectral.py
+++ b/scipy/signal/tests/test_spectral.py
@@ -9,10 +9,15 @@
 
 from scipy import signal
 from scipy.fft import fftfreq
-from scipy.signal import (periodogram, welch, lombscargle, csd, coherence,
-                          spectrogram, stft, istft, check_COLA, check_NOLA)
+from scipy.signal import (periodogram, welch, lombscargle, coherence,
+                          spectrogram, check_COLA, check_NOLA)
 from scipy.signal._spectral_py import _spectral_helper
 
+# Compare ShortTimeFFT.stft() / ShortTimeFFT.istft() with stft() / istft():
+from scipy.signal.tests._scipy_spectral_test_shim import stft_compare as stft
+from scipy.signal.tests._scipy_spectral_test_shim import istft_compare as istft
+from scipy.signal.tests._scipy_spectral_test_shim import csd_compare as csd
+
 
 class TestPeriodogram:
     def test_real_onesided_even(self):