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

15.1 Psychoacoustics#

Acoustics concerns the physics of sound in the natural world. Psychoacoustics concerns our perception of sound. How does our perception relate to the physics? Let’s start with two dimensions that we have studied closely: frequency and amplitude. Our perceptions of pitch and loudness are roughly proportional to the logarithms of their physical counterparts:

\[\text{pitch} \propto \log(\text{frequency}), \qquad \text{loudness} \propto \log(\text{amplitude}).\]

These logarithmic relationships are not limited to hearing. They are a common phenomenon across our senses, for example, our perception of the brightness of light and the heaviness of weights.

Why might this be the case? One hypothesis is that the sounds of our everyday environment are spread fairly evenly across this logarithmic space. They range from about \(20\) Hz to \(20{,}000\) Hz (three orders of magnitude) in frequency, and from about \(0\) dB SPL to \(120\) dB SPL (six orders of magnitude) in pressure. A logarithmic perception may have evolved to put all of these sounds on a level playing field. For example, here are some familiar sounds, drawn in linear units (Hertz and Pascals) and in logarithmic units (octaves and decibels):

Two scatter plots of the same 16 familiar sounds. Left, in linear units: frequency from 0 to 20 kHz and sound pressure from 0 to 70 pascals. A jet engine sits alone near 63 pascals, thunder near 20, a chainsaw near 6, and a motorcycle near 2. Every other sound, from rustling leaves to a dog barking, piles up in a cluster at the origin, labeled everything else. Right, in logarithmic units: frequency from 40 Hz to 12.5 kHz on a log axis and sound pressure level from 0 to 140 dB SPL, between dashed lines for the threshold of hearing at 0 dB SPL and the threshold of pain at 120 dB SPL. The same sounds spread out evenly across the plot, from rustling leaves at 3 kHz and 15 dB SPL, through conversation at 700 Hz and 60 dB SPL, to a jet engine at 1 kHz and 130 dB SPL.

Fig. 120 Familiar sounds in linear units (left) and logarithmic units (right). In linear units, nearly every sound is crowded near zero. In logarithmic units, they spread evenly across the range of human hearing. The placements are approximate, since each sound spans a range of frequencies and levels.#

Next, let’s take a closer look at each of these two dimensions.

Perception of amplitude#

We first studied our perception of amplitude when we introduced the decibel in Chapter 7. As a reminder, the decibel measures an amplitude \(a\) relative to a reference amplitude \(a_0\):

\[\text{dB} = 20 \log_{10}\!\left(\frac{a}{a_0}\right).\]

We will use two conventions for the reference:

  • dBFS (decibels relative to full scale) uses the loudest amplitude that a digital system can represent, \(a_0 = 1\) (unitless). Well-behaved digital audio has negative dBFS values.

  • dB SPL (sound pressure level) uses the quietest pressure that a human can perceive, \(p_0 = 20\) μPa, so \(\text{dB SPL} = 20 \log_{10}(p / p_0)\). Perceivable sounds have positive dB SPL values.

How much dynamic range do your own ears have? The test below estimates it (note: this is not a proper hearing test and is sensitive to your current listening environment). Put in your headphones and start with your volume low. First, press the \(-3\) dB and \(+3\) dB buttons until the reference tone (\(1000\) Hz) is loud, but not uncomfortable. Then, press start, and answer whether you hear each tone. Behind the scenes, the test uses a binary search to find the quietest level you can hear:

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

import io
import os
import numpy as np
from scipy.io import wavfile
import plotly.graph_objects as go
import ipywidgets as widgets
from IPython.display import Audio
import pyquist as pq
import icm_plotly
from icm_plotly import RED, BLUE, GOLD, IRON, STEEL
# hide
# autorun
SR = 44100
REF_HZ = 1000.0                          # the calibration (and test) tone
REF_START, REF_MIN, REF_MAX = -20.0, -40.0, -6.0    # reference level (dBFS)
DB_STEP = 3.0                            # one press of -3 dB / +3 dB
DB_FLOOR = -120.0                        # assume nobody hears a tone this quiet
DB_TOLERANCE = 2.0                       # stop once the interval is narrower (dB)
MAX_TRIALS = 8                           # room on the x axis


def tone(level_db: float, freq: float, sr: int = SR) -> np.ndarray:
    """One second of sine at an exact level: peak amplitude 10 ** (dBFS / 20)."""
    t = np.arange(sr) / sr
    x = 10 ** (level_db / 20) * np.sin(2 * np.pi * freq * t)
    n = int(0.02 * sr)                   # 20 ms raised-cosine fades: no clicks
    fade = 0.5 - 0.5 * np.cos(np.pi * np.arange(n) / n)
    x[:n] *= fade
    x[-n:] *= fade[::-1]
    return x


def float_wav(audio: pq.Audio) -> bytes:
    """A 32-bit floating point WAV file of the audio. (IPython's audio player, which
    pq.play uses, would quantize to 16 bits, which distorts very quiet tones.)"""
    buffer = io.BytesIO()
    wavfile.write(buffer, audio.sample_rate, audio.samples.astype(np.float32))
    return buffer.getvalue()


def audio_output(x: np.ndarray, sr: int = SR) -> dict:
    """The audio card for x as an output record, at x's own level (no normalizing)."""
    audio = Audio(data=float_wav(pq.Audio(x, sample_rate=sr)))
    data, metadata = get_ipython().display_formatter.format(audio)
    return {"output_type": "display_data", "data": data, "metadata": metadata}


class Bisection:
    """Binary search for a listener's threshold between a value they hear and one they don't."""

    def __init__(self, heard: float, missed: float, tolerance: float):
        self.heard = heard               # closest value known to be audible
        self.missed = missed             # closest value known to be inaudible
        self.tolerance = tolerance
        self.trials: list[tuple[float, bool]] = []

    @property
    def probe(self) -> float:
        """The value to test next: the middle of the remaining interval."""
        return (self.heard + self.missed) / 2

    @property
    def done(self) -> bool:
        return abs(self.heard - self.missed) < self.tolerance

    def answer(self, heard: bool) -> None:
        probe = self.probe
        self.trials.append((probe, heard))
        if heard:
            self.heard = probe
        else:
            self.missed = probe


def dbfs(level: float) -> str:
    return f"{level:.0f} dBFS".replace("-", "−")


def figure():
    fig = go.Figure()
    # 0: the remaining interval, a gold band (empty until the test starts)
    fig.add_scatter(x=[], y=[], mode="lines", fill="toself", fillcolor=GOLD,
                    opacity=0.3, line=dict(width=0), name="remaining interval",
                    hoverinfo="skip")
    # 1: the reference level
    fig.add_scatter(x=[0, MAX_TRIALS + 0.5], y=[REF_START] * 2, mode="lines+text",
                    line=dict(color=STEEL, width=2, dash="dash"), text=["", "reference"],
                    textposition="top left", textfont=dict(color=IRON), cliponaxis=False,
                    name="reference", showlegend=False, hoverinfo="skip")
    # 2, 3: the levels tested so far
    fig.add_scatter(x=[], y=[], mode="markers", marker=dict(color=BLUE, size=10),
                    name="heard", hovertemplate="trial %{x}: %{y:.1f} dBFS<extra>heard</extra>")
    fig.add_scatter(x=[], y=[], mode="markers", marker=dict(color=RED, size=10),
                    name="not heard",
                    hovertemplate="trial %{x}: %{y:.1f} dBFS<extra>not heard</extra>")
    # 4: the level on the audio card now
    fig.add_scatter(x=[], y=[], mode="markers",
                    marker=dict(color="white", size=10, line=dict(color=GOLD, width=3)),
                    name="next tone", hovertemplate="trial %{x}: %{y:.1f} dBFS<extra>next</extra>")
    fig.update_xaxes(range=[0, MAX_TRIALS + 0.5], dtick=1, title_text="Trial",
                     fixedrange=True)
    fig.update_yaxes(range=[-125, 0], title_text="Level (dBFS)", fixedrange=True)
    fig.update_layout(showlegend=True)
    return fig


def controls(fig):
    state = {"ref": REF_START, "search": None}
    text = widgets.HTML()
    down = widgets.Button(description="−3 dB")
    up = widgets.Button(description="+3 dB")
    start = widgets.Button(description="Start test", button_style="primary")
    heard = widgets.Button(description="I heard it")
    missed = widgets.Button(description="I didn't hear it")
    restart = widgets.Button(description="Restart", icon="refresh")
    for button, color in ((heard, BLUE), (missed, RED)):
        button.style.button_color = color
        button.style.text_color = "white"
    row = widgets.HBox()
    out = widgets.Output()

    # the defaults snapshot the helpers; the page's notebooks share one kernel
    def redraw(MAX_TRIALS=MAX_TRIALS):
        search = state["search"]
        with fig.batch_update():
            fig.data[1].y = [state["ref"]] * 2
            if search is None:
                for k in (0, 2, 3, 4):
                    fig.data[k].x, fig.data[k].y = [], []
                return
            right = MAX_TRIALS + 0.5
            lo, hi = sorted((search.heard, search.missed))
            fig.data[0].x = [0, right, right, 0, 0]
            fig.data[0].y = [lo, lo, hi, hi, lo]
            for k, answer in ((2, True), (3, False)):
                fig.data[k].x = [i + 1 for i, (_, a) in enumerate(search.trials) if a == answer]
                fig.data[k].y = [v for v, a in search.trials if a == answer]
            n = len(search.trials)
            fig.data[4].x, fig.data[4].y = ([], []) if search.done else ([n + 1], [search.probe])

    def render(tone=tone, audio_output=audio_output, REF_HZ=REF_HZ):
        search = state["search"]
        level = state["ref"] if search is None else search.probe
        out.outputs = (audio_output(tone(level, REF_HZ)),)

    def paint(dbfs=dbfs, REF_MIN=REF_MIN, REF_MAX=REF_MAX):
        search = state["search"]
        ref = state["ref"]
        if search is None:
            text.value = (
                "<b>Step 1: calibrate.</b> Put in headphones and play the tone. Use the "
                "buttons until it is loud but comfortable, then press <b>Start test</b>. "
                f"Reference: <b>{dbfs(ref)}</b>.")
            down.disabled = ref <= REF_MIN
            up.disabled = ref >= REF_MAX
            row.children = (down, up, start)
        elif not search.done:
            text.value = (
                f"<b>Step 2, trial {len(search.trials) + 1}.</b> Play the tone (it may be very "
                "quiet, so listen closely). Did you hear it?")
            row.children = (heard, missed)
        else:
            threshold = search.probe
            text.value = (
                f"<b>Result.</b> Your dynamic range is about <b>{ref - threshold:.0f} dB</b> "
                f"({dbfs(ref)} down to {dbfs(threshold)}).")
            row.children = (restart,)

    def changed():
        paint()
        redraw()
        render()

    def nudge(step, REF_MIN=REF_MIN, REF_MAX=REF_MAX):
        state["ref"] = float(np.clip(state["ref"] + step, REF_MIN, REF_MAX))
        changed()

    def begin(_, Bisection=Bisection, DB_FLOOR=DB_FLOOR, DB_TOLERANCE=DB_TOLERANCE):
        state["search"] = Bisection(state["ref"], DB_FLOOR, DB_TOLERANCE)
        changed()

    def respond(answer):
        state["search"].answer(answer)
        changed()

    def reset(_):
        state["search"] = None           # keep the calibrated reference level
        changed()

    down.on_click(lambda _, step=DB_STEP: nudge(-step))
    up.on_click(lambda _, step=DB_STEP: nudge(step))
    start.on_click(begin)
    heard.on_click(lambda _: respond(True))
    missed.on_click(lambda _: respond(False))
    restart.on_click(reset)
    paint()
    if not os.environ.get("ICM_BOOK_BUILD"):   # the build bakes no card
        render()
    return widgets.VBox([text, row, out])


icm_plotly.show(figure, controls)
Step 1: calibrate. Put in headphones and play the tone. Use the buttons until it is loud but comfortable, then press Start test. Reference: −20 dBFS.
−3 dB
+3 dB
Start test

Your result depends on your ears, but also on your headphones, your volume setting, and the background noise of your room.

Perception of frequency#

The range of human hearing in frequency is roughly \(20\) Hz to \(20\) kHz. This range varies across species. For example, elephants can hear (and produce) sounds well below \(20\) Hz, dogs can hear up to about \(45\) kHz, and some bats can hear up to about \(200\) kHz.

Within this range, our perception of pitch is organized around the octave, a doubling of frequency, as we discussed in Chapter 4. How many octaves does human hearing span? If \(N\) is the number of octaves, then

\[20 \cdot 2^N = 20{,}000 \implies 2^N = 1000 \implies N = \log_2(1000) \approx 10.\]

Here are tones at each octave from \(20\) Hz upwards. Many headphones and laptop speakers can’t reproduce the lowest few, and many listeners (especially adults) can’t hear the highest:

20 Hz

40 Hz

80 Hz

160 Hz

320 Hz

640 Hz

1280 Hz

2560 Hz

5120 Hz

10240 Hz

20480 Hz

Sine tones spaced by octaves, spanning the range of human hearing, at about \(-18\) dBFS.

What is the highest frequency that you can hear? The test below estimates it, again with a binary search. As before, put in your headphones, set the reference tone (\(1000\) Hz) to a comfortable level, and then answer whether you hear each tone. Avoid headphones with active noise cancellation (such as AirPods), which can unintentionally distort the test tones.

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

import io
import os
import numpy as np
from scipy.io import wavfile
import plotly.graph_objects as go
import ipywidgets as widgets
from IPython.display import Audio
import pyquist as pq
import icm_plotly
from icm_plotly import RED, BLUE, GOLD, IRON, STEEL
# hide
# autorun
SR = 44100
REF_HZ = 1000.0                          # the calibration tone
REF_START, REF_MIN, REF_MAX = -20.0, -40.0, -6.0    # reference level (dBFS)
DB_STEP = 3.0                            # one press of -3 dB / +3 dB
HZ_HEARD = 5000.0                        # assume everyone hears this
HZ_MISSED = 22000.0                      # assume nobody hears this (just under f_s / 2)
HZ_TOLERANCE = 250.0                     # stop once the interval is narrower (Hz)
MAX_TRIALS = 8                           # room on the x axis


def tone(level_db: float, freq: float, sr: int = SR) -> np.ndarray:
    """One second of sine at an exact level: peak amplitude 10 ** (dBFS / 20)."""
    t = np.arange(sr) / sr
    x = 10 ** (level_db / 20) * np.sin(2 * np.pi * freq * t)
    n = int(0.02 * sr)                   # 20 ms raised-cosine fades: no clicks
    fade = 0.5 - 0.5 * np.cos(np.pi * np.arange(n) / n)
    x[:n] *= fade
    x[-n:] *= fade[::-1]
    return x


def float_wav(audio: pq.Audio) -> bytes:
    """A 32-bit floating point WAV file of the audio. (IPython's audio player, which
    pq.play uses, would quantize to 16 bits, which distorts very quiet tones.)"""
    buffer = io.BytesIO()
    wavfile.write(buffer, audio.sample_rate, audio.samples.astype(np.float32))
    return buffer.getvalue()


def audio_output(x: np.ndarray, sr: int = SR) -> dict:
    """The audio card for x as an output record, at x's own level (no normalizing)."""
    audio = Audio(data=float_wav(pq.Audio(x, sample_rate=sr)))
    data, metadata = get_ipython().display_formatter.format(audio)
    return {"output_type": "display_data", "data": data, "metadata": metadata}


class Bisection:
    """Binary search for a listener's threshold between a value they hear and one they don't."""

    def __init__(self, heard: float, missed: float, tolerance: float):
        self.heard = heard               # closest value known to be audible
        self.missed = missed             # closest value known to be inaudible
        self.tolerance = tolerance
        self.trials: list[tuple[float, bool]] = []

    @property
    def probe(self) -> float:
        """The value to test next: the middle of the remaining interval."""
        return (self.heard + self.missed) / 2

    @property
    def done(self) -> bool:
        return abs(self.heard - self.missed) < self.tolerance

    def answer(self, heard: bool) -> None:
        probe = self.probe
        self.trials.append((probe, heard))
        if heard:
            self.heard = probe
        else:
            self.missed = probe


def dbfs(level: float) -> str:
    return f"{level:.0f} dBFS".replace("-", "−")


def figure():
    fig = go.Figure()
    right = MAX_TRIALS + 0.5
    # 0: the remaining interval, a gold band (empty until the test starts)
    fig.add_scatter(x=[], y=[], mode="lines", fill="toself", fillcolor=GOLD,
                    opacity=0.3, line=dict(width=0), name="remaining interval",
                    hoverinfo="skip")
    # 1: the textbook upper limit of hearing
    fig.add_scatter(x=[0, right], y=[20, 20], mode="lines+text",
                    line=dict(color=STEEL, width=2, dash="dash"),
                    text=["", "textbook upper limit, 20 kHz"], textposition="top left",
                    textfont=dict(color=IRON), cliponaxis=False, showlegend=False, hoverinfo="skip")
    # 2, 3: the frequencies tested so far
    fig.add_scatter(x=[], y=[], mode="markers", marker=dict(color=BLUE, size=10),
                    name="heard", hovertemplate="trial %{x}: %{y:.2f} kHz<extra>heard</extra>")
    fig.add_scatter(x=[], y=[], mode="markers", marker=dict(color=RED, size=10),
                    name="not heard",
                    hovertemplate="trial %{x}: %{y:.2f} kHz<extra>not heard</extra>")
    # 4: the frequency on the audio card now
    fig.add_scatter(x=[], y=[], mode="markers",
                    marker=dict(color="white", size=10, line=dict(color=GOLD, width=3)),
                    name="next tone", hovertemplate="trial %{x}: %{y:.2f} kHz<extra>next</extra>")
    fig.update_xaxes(range=[0, right], dtick=1, title_text="Trial", fixedrange=True)
    fig.update_yaxes(range=[4, 23], title_text="Frequency (kHz)", fixedrange=True)
    fig.update_layout(showlegend=True)
    return fig


def controls(fig):
    state = {"ref": REF_START, "search": None}
    text = widgets.HTML()
    down = widgets.Button(description="−3 dB")
    up = widgets.Button(description="+3 dB")
    start = widgets.Button(description="Start test", button_style="primary")
    heard = widgets.Button(description="I heard it")
    missed = widgets.Button(description="I didn't hear it")
    restart = widgets.Button(description="Restart", icon="refresh")
    for button, color in ((heard, BLUE), (missed, RED)):
        button.style.button_color = color
        button.style.text_color = "white"
    row = widgets.HBox()
    out = widgets.Output()

    # the defaults snapshot the helpers; the page's notebooks share one kernel
    def redraw(MAX_TRIALS=MAX_TRIALS):
        search = state["search"]
        with fig.batch_update():
            if search is None:
                for k in (0, 2, 3, 4):
                    fig.data[k].x, fig.data[k].y = [], []
                return
            right = MAX_TRIALS + 0.5
            lo, hi = sorted((search.heard / 1000, search.missed / 1000))
            fig.data[0].x = [0, right, right, 0, 0]
            fig.data[0].y = [lo, lo, hi, hi, lo]
            for k, answer in ((2, True), (3, False)):
                fig.data[k].x = [i + 1 for i, (_, a) in enumerate(search.trials) if a == answer]
                fig.data[k].y = [f / 1000 for f, a in search.trials if a == answer]
            n = len(search.trials)
            fig.data[4].x, fig.data[4].y = ([], []) if search.done else ([n + 1], [search.probe / 1000])

    def render(tone=tone, audio_output=audio_output, REF_HZ=REF_HZ):
        search = state["search"]
        freq = REF_HZ if search is None else search.probe
        out.outputs = (audio_output(tone(state["ref"], freq)),)

    def paint(dbfs=dbfs, REF_MIN=REF_MIN, REF_MAX=REF_MAX):
        search = state["search"]
        ref = state["ref"]
        if search is None:
            text.value = (
                "<b>Step 1: calibrate.</b> Put in headphones and play the 1000 Hz tone. Use the "
                "buttons until it is loud but comfortable, then press <b>Start test</b>. "
                f"Level: <b>{dbfs(ref)}</b>.")
            down.disabled = ref <= REF_MIN
            up.disabled = ref >= REF_MAX
            row.children = (down, up, start)
        elif not search.done:
            text.value = (
                f"<b>Step 2, trial {len(search.trials) + 1}.</b> Play the tone (same level, "
                "higher frequency, so listen closely). Did you hear it?")
            row.children = (heard, missed)
        else:
            limit = search.probe
            text.value = (
                f"<b>Result.</b> The highest frequency you can hear is about "
                f"<b>{limit / 1000:.1f} kHz</b>.")
            row.children = (restart,)

    def changed():
        paint()
        redraw()
        render()

    def nudge(step, REF_MIN=REF_MIN, REF_MAX=REF_MAX):
        state["ref"] = float(np.clip(state["ref"] + step, REF_MIN, REF_MAX))
        changed()

    def begin(_, Bisection=Bisection, HZ_HEARD=HZ_HEARD, HZ_MISSED=HZ_MISSED,
              HZ_TOLERANCE=HZ_TOLERANCE):
        state["search"] = Bisection(HZ_HEARD, HZ_MISSED, HZ_TOLERANCE)
        changed()

    def respond(answer):
        state["search"].answer(answer)
        changed()

    def reset(_):
        state["search"] = None           # keep the calibrated level
        changed()

    down.on_click(lambda _, step=DB_STEP: nudge(-step))
    up.on_click(lambda _, step=DB_STEP: nudge(step))
    start.on_click(begin)
    heard.on_click(lambda _: respond(True))
    missed.on_click(lambda _: respond(False))
    restart.on_click(reset)
    paint()
    if not os.environ.get("ICM_BOOK_BUILD"):   # the build bakes no card
        render()
    return widgets.VBox([text, row, out])


icm_plotly.show(figure, controls)
Step 1: calibrate. Put in headphones and play the 1000 Hz tone. Use the buttons until it is loud but comfortable, then press Start test. Level: −20 dBFS.
−3 dB
+3 dB
Start test

The upper limit of hearing declines with age, typically starting in our twenties. And as with the previous test, your headphones or speakers may roll off before your ears do.

Equal-loudness contours#

Psychoacoustics goes well beyond the logarithmic relationships of pitch and loudness to frequency and amplitude. For example, these dimensions also interact with one another. Two tones with the same amplitude, but different frequencies, can have very different perceived loudness.

Equal-loudness contours describe these interactions. They chart the perceived loudness of a sine tone, measured in a unit called the phon, as a function of both its frequency and its sound pressure level (in dB SPL). Each contour connects the frequency and level combinations that listeners perceive as equally loud. They were first measured by Harvey Fletcher and Wilden Munson in 1933 [FM33], and are sometimes called Fletcher-Munson curves. The contours below are from a more recent international standard [InternationalOfStandardization03]:

Six curves of sound pressure level in dB SPL versus frequency from 20 Hz to 12.5 kHz on a logarithmic axis. A dashed gray curve, the threshold of hearing, falls from about 78 dB SPL at 20 Hz to about 2 dB SPL at 1 kHz, dips to a minimum of about minus 6 dB SPL near 3 to 4 kHz, and rises again above 6 kHz. Five blue curves for 20, 40, 60, 80, and 100 phons follow the same general shape at increasing levels, each passing through its own number of dB SPL at 1 kHz, and each flatter than the curve below it.

Fig. 121 Equal-loudness contours, following ISO 226:2003. Each curve connects the levels at which sine tones of different frequencies sound equally loud. The dashed curve is the threshold of hearing, the quietest audible level at each frequency.#

By construction, at \(1000\) Hz, the loudness in phons equals the level in dB SPL. But at other frequencies, achieving the same loudness requires a different level. For example, a \(100\) Hz tone needs about \(64\) dB SPL to sound as loud as a \(1000\) Hz tone at \(40\) dB SPL. Meanwhile, our ears are most sensitive around \(3\) to \(4\) kHz, where the contours reach their minima.

Listen for yourself. Set your volume so that the \(1000\) Hz tone at \(-20\) dBFS is comfortable, then compare the other frequencies at the same levels. Here, the reference (\(1000\) Hz at \(-20\) dBFS) stands in for \(+0\) phons, so \(1000\) Hz at \(-40\) dBFS is \(-20\) phons. Beside each tone is its approximate loudness relative to the reference, read from the contours (assuming the reference plays at a typical listening level of about \(60\) dB SPL):

Frequency

\(-20\) dBFS

\(-40\) dBFS

\(1000\) Hz (reference)

1000 Hz, -20 dBFS \(+0\) phons

1000 Hz, -40 dBFS \(-20\) phons

\(3150\) Hz

3150 Hz, -20 dBFS \(+3\) phons

3150 Hz, -40 dBFS \(-16\) phons

\(250\) Hz

250 Hz, -20 dBFS \(-9\) phons

250 Hz, -40 dBFS \(-32\) phons

\(100\) Hz

100 Hz, -20 dBFS \(-26\) phons

100 Hz, -40 dBFS \(-48\) phons

\(10000\) Hz

10000 Hz, -20 dBFS \(-14\) phons

10000 Hz, -40 dBFS \(-35\) phons

The \(3150\) Hz tones should sound slightly louder than the reference, while the low and high tones should sound much quieter. Notice that the effect is stronger at the quieter level, where the contours are steeper. (Your headphones also have their own frequency response, which colors this comparison.)

Important

We are more sensitive to amplitude at some frequencies than at others. As we will see later in this chapter, codecs can spend fewer bits on the frequencies where we are less sensitive.

Perception of intervals#

Another, more sophisticated psychoacoustic phenomenon is our perception of combinations of frequencies. Consider two sine tones with frequencies \(f_a < f_b\). Their interval is the ratio of their frequencies, \(\frac{f_b}{f_a}\). We encountered several intervals in Chapter 3 and Chapter 4. Depending on the interval, we perceive very different phenomena:

  • Harmonics (timbre): when \(\frac{f_b}{f_a} = k \in \mathbb{Z}^+\), \(f_b\) is an integer multiple of \(f_a\). The two tones tend to fuse into a single tone, with a richer timbre.

  • Steps: when \(\frac{f_b}{f_a} = 2^{\frac{k}{12}}\), where \(k \in \mathbb{Z}\), the two tones are \(k\) steps apart, the basic unit of pitch in Western music.

  • Polyphony: more generally, when \(\frac{f_b}{f_a} \notin \mathbb{Z}^+\), we tend to hear two distinct tones at once. (Most steps are a special case of polyphony.)

  • Beating: when the two frequencies are very close, for example, \(f_b - f_a < 10\) Hz, we hear a single tone whose loudness pulses \(f_b - f_a\) times per second. This is the same phenomenon as ring modulation, from Chapter 6.

Here are examples of each, with \(f_a = 220\) Hz. In each, we play the lower tone, then the upper tone, then both together:

Harmonic: 220 Hz and 440 Hz

Steps: 220 Hz and 277.2 Hz

Polyphony: 220 Hz and 330 Hz

Beating: 220 Hz and 224 Hz

Four combinations of two sine tones. When played together, the beating example pulses \(224 - 220 = 4\) times per second.

Just intervals#

So far, we have studied the scenario where \(\frac{f_b}{f_a} \in \mathbb{Z}^+\) (harmonics). We haven’t discussed the more general scenario where the interval is a rational number, \(\frac{f_b}{f_a} = \frac{n}{m} \in \mathbb{Q}\), where \(n, m \in \mathbb{Z}^+\). A rational interval means that \(f_b\) completes \(n\) cycles for every \(m\) cycles of \(f_a\), so the two waves line up again every \(m\) periods of \(f_a\) (assuming \(\frac{n}{m}\) is in lowest terms).

We often write a rational interval \(\frac{n}{m}\) as \(n{:}m\). Rational intervals with small, co-prime \(n\) and \(m\) form the basis of musical harmony (not to be confused with harmonics). These are called just intervals. Several of them have names, which we list in the table below. Here are three, played with tones that have a few harmonics each (lower, upper, then both), followed by a ratio with larger \(n\) and \(m\) for contrast:

5:4 (a major third), 220 Hz and 275 Hz

3:2 (a perfect fifth), 220 Hz and 330 Hz

4:3 (a perfect fourth), 220 Hz and 293.3 Hz

16:15 (a minor second), 220 Hz and 234.7 Hz

Three just intervals above \(220\) Hz, which sound pleasant, or consonant, and a more complex ratio, which sounds rough, or dissonant.

Tuning#

In Chapter 4, we saw that the Western system of steps divides the octave into \(12\) equal steps. This system is organized around two psychoacoustic principles: (1) the equal division of the octave, and (2) the incorporation of just intervals. But these two principles are at odds. Just intervals are rational, while equal divisions of the octave are irrational, since \(2^{\frac{k}{n}}\) is irrational unless \(n\) divides \(k\). How can we reconcile them?

Definition 51 (Equal divisions of the octave)

An \(n\) equal divisions of the octave (\(n\)-EDO) tuning system divides the octave into \(n\) equal steps, each of which multiplies frequency by \(2^{\frac{1}{n}}\). So raising a frequency by \(k\) steps multiplies it by \(2^{\frac{k}{n}}\).

The idea is to pick \(n\) so that some step of the system lands close to each important just interval. We measure “close” with the relative difference between the two ratios. For example, the perfect fourth is \(\frac{4}{3} \approx 1.3333\), and \(5\) steps of 12-EDO is \(2^{\frac{5}{12}} \approx 1.3348\), a relative difference of just \(100 \cdot \frac{1.3348 - 1.3333}{1.3333} \approx +0.11\%\).

The figure below measures, for each \(n\), the mean relative difference between five important just intervals (\(6{:}5\), \(5{:}4\), \(4{:}3\), \(3{:}2\), and \(5{:}3\)) and their nearest steps in \(n\)-EDO:

A plot of mean distance to just intervals in percent versus steps per octave n, from 3 to 36. The distance falls irregularly as n grows. Highlighted points show local minima at n equals 12, about 0.6 percent, at n equals 19, about 0.3 percent, and at n equals 31, also about 0.3 percent. Neighbors of 12, such as 11 and 13, are much worse, about 2 percent.

Fig. 122 The mean relative difference (in percent) between the just intervals \(6{:}5\), \(5{:}4\), \(4{:}3\), \(3{:}2\), and \(5{:}3\) and the nearest steps of \(n\)-EDO.#

\(n = 12\) is a local minimum. It is not the global minimum, since larger values like \(19\) and \(31\) approximate these intervals even better. But \(12\) strikes a good balance between closely approximating important intervals and keeping the number of steps per octave small, which makes instruments practical to build and play. In particular, 12-EDO approximates the perfect fifth and fourth extremely well, within about \(0.1\%\):

Interval

Just ratio

Nearest 12-EDO steps

12-EDO ratio

Difference

Unison

\(1{:}1 = 1\)

\(0\)

\(2^{0/12} = 1\)

\(0\%\)

Minor third

\(6{:}5 = 1.2\)

\(3\)

\(2^{3/12} \approx 1.189\)

\(-0.90\%\)

Major third

\(5{:}4 = 1.25\)

\(4\)

\(2^{4/12} \approx 1.260\)

\(+0.79\%\)

Perfect fourth

\(4{:}3 \approx 1.333\)

\(5\)

\(2^{5/12} \approx 1.335\)

\(+0.11\%\)

Perfect fifth

\(3{:}2 = 1.5\)

\(7\)

\(2^{7/12} \approx 1.498\)

\(-0.11\%\)

Major sixth

\(5{:}3 \approx 1.667\)

\(9\)

\(2^{9/12} \approx 1.682\)

\(+0.91\%\)

Octave

\(2{:}1 = 2\)

\(12\)

\(2^{12/12} = 2\)

\(0\%\)

The thirds and sixth are off by nearly \(1\%\). Can you hear the difference? Below are a just major third and a 12-EDO major third, played with tones that have a few harmonics. The waveforms show just the sum of the two fundamentals. The just third repeats exactly every \(4\) periods of \(f_a\) (dashed lines), while the 12-EDO third never quite repeats:

Just major third, 220 Hz and 275 Hz The sum of sine waves at 220 Hz and 275 Hz over 50 milliseconds. The waveform repeats exactly every 18.2 milliseconds, marked by dashed red lines.

12-EDO major third, 220 Hz and 277.2 Hz The sum of sine waves at 220 Hz and 277.2 Hz over 50 milliseconds. The waveform looks similar to the just third, but drifts slightly from one repetition to the next.

A just major third (left) and a 12-EDO major third (right). In the 12-EDO version, listen for slow beating, which comes from the 5th harmonic of \(220\) Hz (\(1100\) Hz) and the 4th harmonic of \(277.2\) Hz (\(1108.7\) Hz).