1.4 Clipping

import matplotlib
if not hasattr(matplotlib.RcParams, "_get"):
    matplotlib.RcParams._get = dict.get

1.4 Clipping#

One last practical concern. The DAC has to take the \([-1, 1]\) amplitude values and scale them back to \([-p_{\text{max}}, p_{\text{max}}]\). Accordingly, when you hand it samples whose absolute values exceed \(1\), it will simply clip them to avoid exceeding \(|p_{\text{max}}\)|:

\[\begin{split} y[n] = \begin{cases} +1 & \text{if } x[n] > +1, \\ x[n] & \text{if } x[n] \in [-1, +1], \\ -1 & \text{if } x[n] < -1. \end{cases} \end{split}\]
A 440 Hz sine wave scaled to amplitude 2 shown faintly, overlaid with the hard-clipped version clamped to the range negative one to one, with dashed red lines at the clipping thresholds

Warning

A critical safety note. When experimenting with synthesis code, do not wear headphones until you know the output is bounded. It is very easy to write a one-line bug that produces a much louder sound than you intended, and a sudden loud signal directly against your eardrums can cause real damage. Listen through external speakers at low volume while you debug, then cautiously put headphones on once the output is well-behaved.

Clipping is extremely intrusive: it introduces a harsh, raspy character into the sound, and at high amplitudes can damage speakers as well as ears. For example, multiplying a clean 440 Hz sine wave by 2 saturates the DAC and produces a signal that’s close to a square wave. Compare them directly:

Clean reference: 440 Hz sine, attenuated for safe playback.

440 Hz sine multiplied by 2, hard-clipped to \([-1, 1]\), then attenuated for safe playback.

The interactive below drives the same sine into the clipper. Drag the gain past 1 and watch the tops flatten.

# hide
# no-output
from IPython.utils.capture import capture_output
with capture_output():
    %pip install -q plotly anywidget

import asyncio
import os
import numpy as np
import plotly.graph_objects as go
from plotly.subplots import make_subplots
import ipywidgets as widgets
from IPython.display import Audio
import icm_plotly
from icm_plotly import RED, GOLD, STEEL

Drag the gain: the faint curve is the scaled sine headed for the DAC, and the red curve is what survives the clipper. The audio card underneath plays the result, attenuated for safe playback.

# hide
# autorun
F0 = 440.0
t = np.linspace(0.0, 2 / F0, 900, endpoint=False)   # two cycles on screen
y = np.sin(2 * np.pi * F0 * t)
sr = 44100
seg = np.arange(sr) / sr                            # one second, for the ear
tone = np.sin(2 * np.pi * F0 * seg)
T_MS = 2000 / F0

def figure():
    fig = go.Figure()
    fig.add_scatter(x=t * 1000, y=y, mode="lines",
                    line=dict(color=STEEL, width=1.4))
    fig.add_scatter(x=t * 1000, y=y, mode="lines",
                    line=dict(color=RED, width=2.2))
    for th in (1, -1):
        fig.add_scatter(x=[0, T_MS], y=[th, th], mode="lines",
                        line=dict(color=RED, width=1, dash="dash"))
    fig.update_xaxes(range=[0, T_MS], title_text="Time (ms)", fixedrange=True)
    fig.update_yaxes(range=[-3.2, 3.2], title_text="Amplitude",
                     fixedrange=True)
    return fig

def controls(fig):
    gain = widgets.FloatSlider(description="Gain", min=0.5, max=3.0,
                               value=1.0, step=0.05)
    readout = widgets.HTML()

    # the defaults snapshot the arrays; the page's notebooks share one kernel
    def update(g, y=y, readout=readout):
        with fig.batch_update():
            fig.data[0].y = g * y
            fig.data[1].y = np.clip(g * y, -1, 1)
        pct = 100 * np.mean(np.abs(g * y) > 1)
        readout.value = (f"<span style='font-size:0.9em'>{pct:.0f}% of "
                         f"samples clipped</span>")

    widgets.interactive_output(update, {"g": gain})

    # the audio card under the controls: the previous clip stays in place
    # while you drag (so the layout never jumps) and is swapped for the new
    # one when the pointer releases (keyboard nudges settle on a timer). It is
    # written through the Output's synced `outputs` trait, which works
    # outside a kernel message, where display() output has no destination
    out = widgets.Output()
    gate = icm_plotly.release_gate()   # pointer state: is a slider mid-drag?
    pending = []
    dirty = []

    def render(tone=tone, sr=sr):
        x = np.clip(gain.value * tone, -1, 1)
        x *= 0.125 / np.abs(x).max()          # about -18 dBFS, a safe level
        x[:441] *= np.linspace(0, 1, 441)
        x[-441:] *= np.linspace(1, 0, 441)
        audio = Audio(x.astype(np.float32), rate=sr, normalize=False)
        data, metadata = get_ipython().display_formatter.format(audio)
        # one assignment swaps the old card for the new one in place, so
        # the page never shows an empty card and nothing shifts
        out.outputs = ({"output_type": "display_data",
                        "data": data, "metadata": metadata},)

    async def settle():
        await asyncio.sleep(0.25)
        pending.clear()
        if dirty and not gate.dragging:
            dirty.clear()
            render()

    def on_change(_):
        dirty.append(True)
        if pending:
            pending.pop().cancel()
        pending.append(asyncio.ensure_future(settle()))

    def on_release(change):
        if not change["new"] and dirty:
            if pending:
                pending.pop().cancel()
            dirty.clear()
            render()

    gate.observe(on_release, names="dragging")

    gain.observe(on_change, names="value")
    if not os.environ.get("ICM_BOOK_BUILD"):   # the build bakes no card
        render()
    return widgets.VBox([gain, readout, out, gate])

icm_plotly.show(figure, controls)
Gain1.00
0% of samples clipped

A simple defensive habit while developing synthesis code is to normalize your output to lie within \([-1, 1]\) before sending it to the DAC, e.g.,

\[y[n] = x[n] / x_{\text{max}}, \text{where}~ x_{\text{max}} = \max_{n \in \{0, \ldots, N-1\}} |x[n]|.\]