Skip to content

visualdynamics.core.sine_tracking

sine_tracking

A swept sine read the way a controller's tracking filter reads it.

core.sine.extract_sine is the best estimate of a tone's level there is: every tone solved jointly, debiased, its smoothing chosen from the data. That is the right number for a report and the wrong one for a question a test floor asks often — why does the controller's level disagree with mine? — because the controller did not read it that way. It read the response through a tracking filter, a band-pass that follows the drive frequency, of a fixed width in Hz or a width proportional to the drive, and turned what came out into a level with a detector: the peak, the RMS or the mean absolute value (the last two reported as the peak of the sine they would be), or the filter's own output. This module does that, so the readings can be laid side by side and the difference seen rather than argued.

What the readings show, and why the module exists: a harmonic or broadband noise moves the level by an amount that depends on the detector and on the filter. Read unfiltered, a peak detector counts the harmonic's peaks and an RMS detector counts its power; read through the filter, neither is seen. The filter's price is time: it settles in roughly one over its bandwidth, so a narrow band lags a change in level that a wide one follows. Which reading a controller used is a setting, and the same response record gives a different level curve for each.

Demodulation. The record is multiplied by the complex exponential of the drive's own phase, which moves the drive frequency to zero; a low-pass of half the bandwidth there is the band-pass about the drive, and its magnitude is the tone's amplitude. A band-pass run on the raw record would have to be centered afresh at every frequency of the sweep; the low-pass only changes its corner.

Causal, on purpose. core.filters.filtered runs forward and backward for zero phase, which is right for a record and wrong here: the point of the reading is the lag a controller's filter has, and a zero-phase pass would spread it to both sides of a change and hide it. The filter runs forward only, starting settled on the tone's first cycle, the way a controller's filter has settled during the ramp-up before the sweep proper begins.

One filter, integrated through every sample. A proportional band's corner moves with the drive, so no fixed digital design fits it. The first cut redesigned core.filters.design's Butterworth each time the corner had moved one percent and carried the state across, and it measured wrong two ways (2026-10-05). Carried raw, the state of a narrow section rang at each redesign: 5.7 % of error on a clean log sweep read at 10 %. Carried as its settled part plus the transient in flight, the redesigns still came at a steady rate along a log sweep, and the demodulation's image (below) mixed down against that rate: at 50 % the image got through ten times stronger than the filter allows, and a step a third the size moved the mixing frequency without curing the leak. So the filter is the same Butterworth — scipy's analog prototype, the one design's butter maps to a digital filter — split into its first-order modes, and each mode is integrated exactly across each sample at the corner that sample has, the input held over the sample. Nothing is redesigned; a constant input stays constant to rounding whatever the corner does, because a mode's settled state does not depend on its corner; and the image gets through at the filter's own response, measured equal to it. A fixed band is the same code with a corner that does not move. Against design's bilinear filter at a fixed corner well below Nyquist it differs by 0.3 % RMS on white noise, the hold against the bilinear map, which no level reads.

Fourth order by default, because of the image. Demodulating a real record moves the tone to zero and also leaves its mirror image at twice the drive frequency, which the low-pass attenuates by its response there, about (bandwidth / (4 x drive)) to the power of the order; what gets through is a ripple at twice the drive. At second order that is 1.6 % at 50 % proportional and 6.2 % at 100 %, a demodulator's artifact rather than a reading; at fourth order, 0.02 % and 0.4 %. A wide fixed band at the bottom of a sweep — half the bandwidth near or past the drive frequency — is the case where the band is no longer about the drive at all, and there the ripple is what the reading shows.

A peak is the peak of the samples, as a sampled detector reads it: it under-reads a sine by at most cos(pi x drive / sample rate), 0.3 % at a fortieth of the sample rate. RMS and mean read over the same span, a whole number of the drive's cycles (DETECTOR_CYCLES by default), where a pure sine's RMS times the square root of two and its mean absolute value times pi/2 are its peak exactly.

They are exact only if the span really is a whole number of cycles and the waveform between the samples is seen. Read off the samples alone, neither holds when a cycle is a few samples long: the span snapped to whole samples, and |x| has a corner at every zero crossing that a handful of samples cannot place. On a clean 2 m/s**2 tone sampled at 4096 Hz, one cycle, RMS read 3.6 % off at 400 Hz (10 samples a cycle) and mean 9 % at 750 Hz (5.5) (2026-10-05, found in review; the tests here run at 51 samples a cycle and never saw it). So the two are read from the band-limited waveform: the samples around each span upsampled until a cycle has FINE_POINTS points (scipy.signal.resample_poly), and averaged over exactly the span's phase, its first point interpolated at the span's start. Measured the same way, a clean sweep to 780 Hz: RMS within 0.005 % and mean within 0.08 % sampled at 4096 Hz (5.3 samples a cycle at the top), both within 0.09 % at 2048 Hz (2.6). The reconstruction leans on MARGIN samples either side of a span, so a reading looks that far past its instant: interpolation, not a lag. The peak stays the peak of the samples, by design: that is how a sampled controller reads it.

The waveform, the shape and the weight, from the same filter. A level is what a controller reports; the view that explains it draws the record through the band, the band's magnitude about the drive and the band's weight on the record behind any instant (plot.tracking_filter). All three come from here, so the picture cannot describe a different filter from the one that read the level: track_waveform is track_sine's own band run over every sample of the span, and SineTracking.response and SineTracking.weighting are the frequency and impulse responses of the same Butterworth modes the band is integrated with — at the corner a given drive frequency has, since a proportional band has a different one at every instant.

Memory. One channel's span of the tone is held at a time, with its phase, the demodulated record, the filter's output and one mode's working arrays beside it — about a hundred bytes a sample. The extraction streams where this does not; a recording of tens of millions of samples per channel wants the extraction's chunking, which this first cut does not have.

References

The tracking filter here is complex demodulation followed by a Butterworth low-pass, integrated with its input held over each sample; these are where the three are set out, and the test this reading is used to understand.

  1. Bloomfield, P. (2000). Fourier Analysis of Time Series: An Introduction, 2nd ed. Wiley. Complex demodulation: a component near a known frequency moved to zero and low-passed to read its amplitude and phase as they vary.
  2. Oppenheim, A. V., & Schafer, R. W. (2010). Discrete-Time Signal Processing, 3rd ed. Prentice Hall. The Butterworth low-pass and its analog prototype.
  3. Franklin, G. F., Powell, J. D., & Workman, M. L. (1998). Digital Control of Dynamic Systems, 3rd ed. Addison-Wesley. A continuous system integrated exactly across a sample with its input held: the zero-order-hold equivalent each mode is stepped with.
  4. IEC 60068-2-6:2007, Environmental testing - Part 2-6: Tests - Test Fc: Vibration (sinusoidal). The swept sine test whose drive level a tracking filter and detector measure.

Classes:

Name Description
SineTracking

How a tone is read: through which band, by which detector.

TrackedWaveform

One channel's span of a tone as a tracking band passes it,

Functions:

Name Description
track_sine

Read one tone through a tracking filter and detector, one level

track_waveform

One channel's span of a tone through a tracking band, at every

Classes

SineTracking dataclass

SineTracking(*, detector: str = 'filtered', proportional: float | None = None, fixed: float | None = None, order: int = ORDER, cycles: float = DETECTOR_CYCLES)

How a tone is read: through which band, by which detector.

proportional is the band's width as a fraction of the drive frequency (0.5 is 50 %); fixed is a width in Hz. Neither is an unfiltered reading, where the detector reads the record as it stands. Both at once is refused — a band is one or the other — and so is the filter's own output with no filter. Which band this is follows from which width exists (kind), the way core.filters.Filtering derives low- or high-pass from its edges, so no setting can contradict its numbers.

detector is 'filtered' (the band's own output: the demodulated amplitude at the instant of the reading), 'peak', 'rms' or 'mean' — the last two reported as the peak of the sine they would be (TO_PEAK). The three waveform detectors read over the last cycles cycles of the drive; the filter's output ignores it. order is the low-pass's design order (ORDER).

A proportional band must sit under twice the drive frequency: past that, half of it reaches below zero frequency at every point of the sweep, and the band is not about the drive anywhere. Frozen like Filtering, so a setting cannot change under the levels read with it.

Methods:

Name Description
bandwidth

The band's full width in Hz at drive frequency frequency.

settling

Roughly how long the band takes to follow a change of level,

response

The band's magnitude in dB at frequencies when the drive is

weighting

How much the record lags seconds before an instant counts

describe

The reading in words — 'rms as peak, 10 % proportional'. One

Attributes:

Name Type Description
kind str

'unfiltered', 'proportional' or 'fixed' — derived from which

Attributes
kind property
kind: str

'unfiltered', 'proportional' or 'fixed' — derived from which width exists, never stored beside it.

Methods:
bandwidth
bandwidth(frequency: Any) -> ndarray

The band's full width in Hz at drive frequency frequency.

Parameters:

Name Type Description Default
frequency float or array - like

The drive frequency, Hz.

required

Returns:

Type Description
ndarray

The width, Hz, the shape of frequency.

Source code in src/visualdynamics/core/sine_tracking.py
def bandwidth(self, frequency: Any) -> np.ndarray:
    """The band's full width in Hz at drive frequency `frequency`.

    Parameters
    ----------
    frequency : float or array-like
        The drive frequency, Hz.

    Returns
    -------
    ndarray
        The width, Hz, the shape of `frequency`.
    """
    frequency = np.asarray(frequency, dtype=float)
    if self.proportional is not None:
        return self.proportional * frequency
    if self.fixed is not None:
        return np.full(frequency.shape, self.fixed)
    raise ValueError('an unfiltered reading has no band')
settling
settling(frequency: Any) -> ndarray

Roughly how long the band takes to follow a change of level, in seconds: one over its width. A rule of thumb rather than a bound — the fourth-order filter reaches half of a step in level at about 0.9 of it and nine tenths at about 1.3, the same at every width, measured on a step in a swept tone (2026-10-05).

Parameters:

Name Type Description Default
frequency float or array - like

The drive frequency, Hz.

required

Returns:

Type Description
ndarray

Seconds, the shape of frequency.

Source code in src/visualdynamics/core/sine_tracking.py
def settling(self, frequency: Any) -> np.ndarray:
    """Roughly how long the band takes to follow a change of level,
    in seconds: one over its width. A rule of thumb rather than a
    bound — the fourth-order filter reaches half of a step in level
    at about 0.9 of it and nine tenths at about 1.3, the same at
    every width, measured on a step in a swept tone (2026-10-05).

    Parameters
    ----------
    frequency : float or array-like
        The drive frequency, Hz.

    Returns
    -------
    ndarray
        Seconds, the shape of `frequency`.
    """
    return 1.0 / self.bandwidth(frequency)
response
response(drive: float, frequencies: Any) -> ndarray

The band's magnitude in dB at frequencies when the drive is at drive Hz: the shape the record is read through at that instant.

The low-pass's response at the offset from the drive, which is what a band-pass about the drive is after demodulation; −3 dB at the drive plus and minus half the bandwidth, by the Butterworth's definition. What the image at twice the drive adds (the module docstring's ripple) is not in it: that is the demodulator's artifact, not the band's shape. The analog prototype's response, the one each mode is integrated from; the held input moves it by nothing a picture shows this far below the sample rate.

Parameters:

Name Type Description Default
drive float

The drive frequency, Hz.

required
frequencies float or array - like

Where to read the band, Hz.

required

Returns:

Type Description
ndarray

dB, the shape of frequencies; 0 dB at the drive.

Source code in src/visualdynamics/core/sine_tracking.py
def response(self, drive: float, frequencies: Any) -> np.ndarray:
    """The band's magnitude in dB at `frequencies` when the drive is
    at `drive` Hz: the shape the record is read through at that
    instant.

    The low-pass's response at the offset from the drive, which is
    what a band-pass about the drive is after demodulation; −3 dB
    at the drive plus and minus half the bandwidth, by the
    Butterworth's definition. What the image at twice the drive
    adds (the module docstring's ripple) is not in it: that is the
    demodulator's artifact, not the band's shape. The analog
    prototype's response, the one each mode is integrated from;
    the held input moves it by nothing a picture shows this far
    below the sample rate.

    Parameters
    ----------
    drive : float
        The drive frequency, Hz.
    frequencies : float or array-like
        Where to read the band, Hz.

    Returns
    -------
    ndarray
        dB, the shape of `frequencies`; 0 dB at the drive.
    """
    poles, residues = _modes(self.order)
    corner = float(self.bandwidth(float(drive))) / 2.0
    offset = (np.asarray(frequencies, dtype=float) - float(drive)) / corner
    gain = np.zeros(offset.shape, dtype=complex)
    for pole, residue in zip(poles, residues):
        gain += residue / (1j * offset - pole)
    return 20.0 * np.log10(np.maximum(np.abs(gain), 1e-300))
weighting
weighting(drive: float, lags: Any) -> ndarray

How much the record lags seconds before an instant counts in the band's output at that instant, when the drive is at drive Hz: the low-pass's impulse response, per second.

It integrates to one (a settled band reads a steady tone at its amplitude), rises from zero, peaks and has a small negative lobe — the overshoot a fourth order has on a step. Its running integral is the band's step response, half way at about 0.9 of one over the bandwidth (settling). Zero at negative lags: the band cannot see ahead.

Parameters:

Name Type Description Default
drive float

The drive frequency, Hz, which sets a proportional band's corner.

required
lags float or array - like

Seconds before the instant.

required

Returns:

Type Description
ndarray

Per second, the shape of lags.

Source code in src/visualdynamics/core/sine_tracking.py
def weighting(self, drive: float, lags: Any) -> np.ndarray:
    """How much the record `lags` seconds before an instant counts
    in the band's output at that instant, when the drive is at
    `drive` Hz: the low-pass's impulse response, per second.

    It integrates to one (a settled band reads a steady tone at its
    amplitude), rises from zero, peaks and has a small negative
    lobe — the overshoot a fourth order has on a step. Its running
    integral is the band's step response, half way at about 0.9 of
    one over the bandwidth (`settling`). Zero at negative lags: the
    band cannot see ahead.

    Parameters
    ----------
    drive : float
        The drive frequency, Hz, which sets a proportional band's
        corner.
    lags : float or array-like
        Seconds before the instant.

    Returns
    -------
    ndarray
        Per second, the shape of `lags`.
    """
    poles, residues = _modes(self.order)
    corner = np.pi * float(self.bandwidth(float(drive)))   # rad/s
    lags = np.asarray(lags, dtype=float)
    ahead = np.maximum(lags, 0.0)
    total = np.zeros(lags.shape, dtype=complex)
    for pole, residue in zip(poles, residues):
        total += residue * corner * np.exp(pole * corner * ahead)
    return np.where(lags >= 0.0, total.real, 0.0)
describe
describe() -> str

The reading in words — 'rms as peak, 10 % proportional'. One implementation: a legend, the levels' comments and the guide all say it this way.

Source code in src/visualdynamics/core/sine_tracking.py
def describe(self) -> str:
    """The reading in words — 'rms as peak, 10 % proportional'. One
    implementation: a legend, the levels' comments and the guide
    all say it this way."""
    if self.proportional is not None:
        band = f'{self.proportional * 100.0:g} % proportional'
    elif self.fixed is not None:
        band = f'{self.fixed:g} Hz fixed'
    else:
        band = 'unfiltered'
    return f'{DETECTOR_WORDS[self.detector]}, {band}'

TrackedWaveform dataclass

TrackedWaveform(*, setting: SineTracking, tone: str, onset: float, dof: str, dimension: str, unit: str, time: ndarray, drive: ndarray, passed: ndarray, level: ndarray)

One channel's span of a tone as a tracking band passes it, sample by sample: what track_waveform returns.

time is the record's own clock over the span; drive the drive frequency at each of those samples; passed the record through the band, in the record's units — the waveform a detector after the band reads — and level the band's output amplitude, the 'filtered' reading at every sample rather than at a reading's lines. Not compared by value (arrays do not have one), and frozen like the setting it was read with.

Methods:

Name Description
instant

The first second, on the record's clock, at which the drive

Methods:
instant
instant(frequency: float) -> float

The first second, on the record's clock, at which the drive reaches frequency — where a cursor goes to look at the band as the tone passes a frequency.

Parameters:

Name Type Description Default
frequency float

Hz.

required

Returns:

Type Description
float

Seconds on the record's clock.

Source code in src/visualdynamics/core/sine_tracking.py
def instant(self, frequency: float) -> float:
    """The first second, on the record's clock, at which the drive
    reaches `frequency` — where a cursor goes to look at the band
    as the tone passes a frequency.

    Parameters
    ----------
    frequency : float
        Hz.

    Returns
    -------
    float
        Seconds on the record's clock.
    """
    side = np.sign(self.drive - float(frequency))
    crossed = np.flatnonzero((side[:-1] != side[1:]) | (side[:-1] == 0))
    if not crossed.size:
        raise ValueError(f'the drive never passes {frequency:g} Hz; it '
                         f'runs {self.drive.min():g} to '
                         f'{self.drive.max():g} Hz')
    k = int(crossed[0])
    if side[k] == 0 or self.drive[k + 1] == self.drive[k]:
        return float(self.time[k])
    share = ((float(frequency) - self.drive[k])
             / (self.drive[k + 1] - self.drive[k]))
    return float(self.time[k] + share * (self.time[k + 1]
                                         - self.time[k]))

Functions:

track_sine

track_sine(history: Any, specification: SineSweepSpecification, settings: SineTracking | Sequence[SineTracking], *, tone: str | None = None, onset: float | None = None, channels: Sequence[str] | None = None, lines: int = LINES) -> list[SineLevel]

Read one tone through a tracking filter and detector, one level per setting.

The tone's sweep is rebuilt from the specification and laid on the recording where its sweep begins (found by matched filter, as the extraction finds it, or at onset seconds into the recording); each channel is demodulated against the sweep's phase once, and each setting reads it: through its band, if it has one, then by its detector. Every setting is read at the same lines instants, evenly spaced through the tone's span from the first instant every detector has a whole span behind it, so the levels overlay line for line.

Parameters:

Name Type Description Default
history TimeHistory

The recording, evenly sampled.

required
specification SineSweepSpecification

The sweep the drive followed.

required
settings SineTracking or sequence of SineTracking

The readings to take. Several from one record is the point: the same response through different bands and detectors.

required
tone str

Which tone, by name; the specification's first by default.

None
onset float

Seconds into the recording where the tone's sweep begins. Found by matched filter when omitted.

None
channels sequence of str

The DOFs to read; the specification's control channels by default. Any channel of the recording may be named.

None
lines int

How many readings along the sweep.

LINES

Returns:

Type Description
list of SineLevel

One per setting, in order: the level against frequency on every channel read, ascending in frequency whichever way the tone swept, in the recording's own units, each line stamped with the second it was read (SineLevel.seconds). Each channel's comment names the tone and the reading (SineTracking.describe); the phase is not kept, since a peak or an RMS has none.

Source code in src/visualdynamics/core/sine_tracking.py
def track_sine(history: Any, specification: SineSweepSpecification,
               settings: SineTracking | Sequence[SineTracking], *,
               tone: str | None = None, onset: float | None = None,
               channels: Sequence[str] | None = None,
               lines: int = LINES) -> list[SineLevel]:
    """Read one tone through a tracking filter and detector, one level
    per setting.

    The tone's sweep is rebuilt from the specification and laid on the
    recording where its sweep begins (found by matched filter, as the
    extraction finds it, or at `onset` seconds into the recording);
    each channel is demodulated against the sweep's phase once, and
    each setting reads it: through its band, if it has one, then by
    its detector. Every setting is read at the same `lines` instants,
    evenly spaced through the tone's span from the first instant every
    detector has a whole span behind it, so the levels overlay line for
    line.

    Parameters
    ----------
    history : TimeHistory
        The recording, evenly sampled.
    specification : SineSweepSpecification
        The sweep the drive followed.
    settings : SineTracking or sequence of SineTracking
        The readings to take. Several from one record is the point:
        the same response through different bands and detectors.
    tone : str, optional
        Which tone, by name; the specification's first by default.
    onset : float, optional
        Seconds into the recording where the tone's sweep begins.
        Found by matched filter when omitted.
    channels : sequence of str, optional
        The DOFs to read; the specification's control channels by
        default. Any channel of the recording may be named.
    lines : int
        How many readings along the sweep.

    Returns
    -------
    list of SineLevel
        One per setting, in order: the level against frequency on
        every channel read, ascending in frequency whichever way the
        tone swept, in the recording's own units, each line stamped
        with the second it was read (`SineLevel.seconds`). Each
        channel's comment names the tone and the reading
        (`SineTracking.describe`); the phase is not kept, since a peak
        or an RMS has none.
    """
    if isinstance(settings, SineTracking):
        settings = [settings]
    settings = list(settings)
    if not settings:
        raise ValueError('nothing to read: give at least one setting')
    dt = _even_steps(np.asarray(history.abscissa, dtype=float), None)
    rate = 1.0 / dt
    rows = _rows(history, specification, channels)
    chosen, onset = _tone_at(history, specification, rows, tone, onset, dt)
    first, last, frequency, phase = _laid(history, chosen, onset, dt)
    # phase runs forward whichever way the frequency sweeps, so the
    # detectors find their spans by searching it
    widest = max([setting.cycles for setting in settings
                  if setting.detector != 'filtered'] or [1.0])
    begin = int(np.searchsorted(phase, phase[0] + 2.0 * np.pi * widest))
    if begin >= len(phase) - 1:
        raise ValueError(f'{chosen.name}: the recording holds less than '
                         f'{widest:g} cycles of the tone; nothing to read')
    reads = np.unique(np.linspace(begin, len(phase) - 1,
                                  int(lines)).astype(np.int64))
    order = np.argsort(frequency[reads], kind='stable')

    readings = np.zeros((len(settings), len(rows), len(reads)))
    for c, row in enumerate(rows):
        x = np.real(np.asarray(history.ordinate[row, first:last],
                               dtype=float))
        demodulated = None
        if any(s.kind != 'unfiltered' for s in settings):
            demodulated = _Demodulated(x, phase)
        for k, setting in enumerate(settings):
            if setting.kind == 'unfiltered':
                readings[k, c] = _detect(x, phase, reads, setting.detector,
                                         setting.cycles)
                continue
            amplitude = demodulated.through(setting, frequency, rate)
            if setting.detector == 'filtered':
                readings[k, c] = np.abs(amplitude[reads])
            else:
                passed = demodulated.waveform(amplitude)
                readings[k, c] = _detect(passed, phase, reads,
                                         setting.detector, setting.cycles)

    dims = [history.ordinate_dim[row] for row in rows]
    units = [history.ordinate_unit[row] for row in rows]
    dofs = [history.response_dof[row] for row in rows]
    stamped = (first + reads) * dt
    return [SineLevel(
        abscissa=frequency[reads][order],
        ordinate=readings[k][:, order],
        response_dof=dofs, ordinate_dim=dims, ordinate_unit=units,
        comment=[f'{chosen.name} at {dof}, {setting.describe()}'
                 for dof in dofs],
        tone=chosen.name, onset=onset, seconds=stamped[order])
        for k, setting in enumerate(settings)]

track_waveform

track_waveform(history: Any, specification: SineSweepSpecification, setting: SineTracking, *, tone: str | None = None, onset: float | None = None, channel: str | None = None) -> TrackedWaveform

One channel's span of a tone through a tracking band, at every sample: the waveform the band passes and its output amplitude.

The same band track_sine reads its levels through, run the same way — the tone laid on the recording where its sweep begins, demodulated against its phase, low-passed causally from settled — and kept at every sample instead of read at lines, so the band can be drawn on the record it was applied to.

Parameters:

Name Type Description Default
history TimeHistory

The recording, evenly sampled.

required
specification SineSweepSpecification

The sweep the drive followed.

required
setting SineTracking

The band. One with no band is refused: there is nothing to pass the record through.

required
tone str

Which tone; the specification's first by default.

None
onset float

Seconds into the recording where the tone's sweep begins; found by matched filter when omitted.

None
channel str

The DOF read; the specification's first control channel by default. Any channel of the recording may be named.

None

Returns:

Type Description
TrackedWaveform

The span's clock, drive frequency, passed waveform and output amplitude, in the recording's own units.

Source code in src/visualdynamics/core/sine_tracking.py
def track_waveform(history: Any, specification: SineSweepSpecification,
                   setting: SineTracking, *, tone: str | None = None,
                   onset: float | None = None,
                   channel: str | None = None) -> TrackedWaveform:
    """One channel's span of a tone through a tracking band, at every
    sample: the waveform the band passes and its output amplitude.

    The same band `track_sine` reads its levels through, run the same
    way — the tone laid on the recording where its sweep begins,
    demodulated against its phase, low-passed causally from settled —
    and kept at every sample instead of read at lines, so the band can
    be drawn on the record it was applied to.

    Parameters
    ----------
    history : TimeHistory
        The recording, evenly sampled.
    specification : SineSweepSpecification
        The sweep the drive followed.
    setting : SineTracking
        The band. One with no band is refused: there is nothing to
        pass the record through.
    tone : str, optional
        Which tone; the specification's first by default.
    onset : float, optional
        Seconds into the recording where the tone's sweep begins;
        found by matched filter when omitted.
    channel : str, optional
        The DOF read; the specification's first control channel by
        default. Any channel of the recording may be named.

    Returns
    -------
    TrackedWaveform
        The span's clock, drive frequency, passed waveform and output
        amplitude, in the recording's own units.
    """
    if setting.kind == 'unfiltered':
        raise ValueError(f'{setting.describe()} has no band to pass the '
                         'record through')
    dt = _even_steps(np.asarray(history.abscissa, dtype=float), None)
    rate = 1.0 / dt
    rows = _rows(history, specification,
                 None if channel is None else [channel])[:1]
    chosen, onset = _tone_at(history, specification, rows, tone, onset, dt)
    first, last, frequency, phase = _laid(history, chosen, onset, dt)
    row = rows[0]
    x = np.real(np.asarray(history.ordinate[row, first:last], dtype=float))
    demodulated = _Demodulated(x, phase)
    amplitude = demodulated.through(setting, frequency, rate)
    return TrackedWaveform(
        setting=setting, tone=chosen.name, onset=onset,
        dof=history.response_dof[row],
        dimension=history.ordinate_dim[row],
        unit=history.ordinate_unit[row],
        time=np.asarray(history.abscissa, dtype=float)[first:last],
        drive=frequency, passed=demodulated.waveform(amplitude),
        level=np.abs(amplitude))