Audio Signal Processing in Python: Panning and Stereo Width

Math
DSP
Python
TDD
Annoying Precision

In this post I’ll go into the math of panning mono audio signals and stereo width adjustment. Then I’ll demonstrate test driven development by implementing the mathematical aspects in Python. This is also the first part of a series of posts which I like to call “Annoying Precision” where I will go into abstract mathematical underpinnings of common techniques used in applied fields such as digital signal processing or deep learning.

Author

Stefan Behrens

Published

October 6, 2025

Tools and Conventions

Before I get started, I’ll mention my go to libraries and conventions for working with audio in Python.

Libraries:

  • As most people, I use NumPy and SciPy for low-level processing and analysis.
  • I like to use librosa for reading audio and high-level analysis.
  • I’ve been using sounddevice for playback.

Data Conventions:

  • Mono audio is modeled by 1d NumPy Arrays.
  • Stereo (or multi-channel) audio is modeled by 2d NumPy arrays of shape (num_channels, num_samples).
  • In both cases, the sample values are 32-bit floating point numbers between -1 and 1.

I’ve adopted these conventions from librosa.

Caution: Other audio library might deviates from these conventions. For example, sounddevice requires the multi-channel arrays to have shape (num_samples, num_channels). This necessitates an occasional transposition.

The Math of Panning and Stereo Width

Before going into code, I like to be clear about the math that runs the show. Let’s start with a sampled audio signal in mono. There are at least three common ways to model this mathematically:

  • A finite sequence of real numbers \(x_0, x_1, \dots, x_{N-1}\in\R\) of length \(N\ge0\).
  • A vector \(x = (x_0,x_1,\dots,x_{N-1})\in\R^N\)
  • A function \(x\colon \Z\to\R\) with values \(x[n] = x_n\) for \(0\le n <N\) and \(x[n]=0\) otherwise.

I prefer the last option, because in my mind a signal is a function of time - in this case modeled by the integers \(\Z\) - and the notation \(x[n]\) for the sample values is the same in Python code. This also frees up the subscript notation and allows me to write stereo signals as tuples \(x = (x_L, x_R)\) of functions \(x_L, x_R\colon \Z\to\R\), each of which can be viewed as a mono signal.

In my mathematically wired brain, I also like to think of operations that transform a given signal into another one (e.g. filtering, panning, etc.) as a map between sets of signals. I’ll use the notation \(\mathcal S_1\) and \(\mathcal S_2\) for the sets of mono and stereo signals, respectively. Here’s a formal definition: \[\begin{align*} \mathcal{S}_1 &= \{x\colon \Z\to\R \mid \text{$x[n] = 0$ for $n<0$ and $n\gg 0$} \} \\ \mathcal{S}_2 &= \{x = (x_L,x_R) \mid x_L, x_R\in\mathcal{S_1} \}. \end{align*}\] Just to give an example of an operation, the process of extracting only the left channel from a stereo signal is formally given by the map \[ T\colon \mathcal S_2 \to \mathcal S_1,\quad x = (x_L, x_R) \mapsto x_L = T(x). \]

Equal Power Panning of Mono Signals

Say I have a mono signal \(x_0\) and I want to play it back on my stereo system. How can I do that? There are at three obvious ways:

  • Hard Left Panning: I could only use the left speaker and play back the stereo signal (x_0, 0)$.
  • Hard Right Panning: I could only use the right speaker and play back the stereo signal \((0, x_0)\).
  • Naive Center Panning: I could use both speaker to play back the stereo signal \((x_0, x_0)\).

Unfortunately, neither of them is perfect. Obviously, the stereo system is designed to have equal amounts of sound coming out of both speaker, which is just not happening for the hard panned signals. On the plus side, the hard panned signals have the advantage that they are guaranteed to be played back at the correct loudness, as they are equivalent to playing back the mono signal \(x_0\) on a mono system. In contrast, the naive center panning turns out to be a little too loud.

This is actually a somewhat subtle point, since the perceived loudness depends on many unknown factors (e.g. speaker placement, room acoustics, listener position, etc) and the dependence is a lot more complicated for stereo systems due to interferences between the left and right speakers. As it turns out, the perceived loudness of a signal is proportional to the signal’s power, which is, in turn, proportional to the point-wise square norm of the signal. This goes for mono and stereo signals under a diffuse sound field assumption which is reasonably well justified for realistic stereo playback scenarios.

Coming back to the three obvious panning options for the mono signal \(x_0\), we find that the signal power is proportional to \[ \|(x_0, 0)\|^2 = \|(0, x_0)\|^2 = x_0^2 \quad\text{while}\quad \|(x_0, x_0)\|^2 = x_0^2+x_0^2 = 2 x_0^2. \] On the one hand, this quantifies how much too loud the naive center panning is. On the other hand, it suggests a way out of this problem:

  • The (equal power) center panning of a mono signal \(x_0\) is the stereo signal \((x_0/\sqrt2, x_0/\sqrt2)\).

As a side note, multiplication with \(1/\sqrt2 = 0.707\dots\) corresponds to a decrease in level by roughly 3 dB, and the equal power condition is sometimes also called the 3 dB panning law. Another fun fact is the observation that \(1/\sqrt2 = \cos(\pi/4) = \sin(\pi/4)\), which can be seen geometrically by viewing the line from the origin in \(\R^2\) to the unit circle at a 45° angle as the diagonal in a square whose side length are given by the sine and cosine values at \(\pi/4\).

The geometry also helps to find more ways to distribute the mono signal \(x_0\) between the left and right channels of a stereo system:

  • An equal power panning of a mono signal \(x_0\) has the form \((\lambda x_0, \rho x_0)\) with \(\lambda,\rho\ge0\) satisfying \(\lambda^2 + \rho^2 = 1\).

Geometrically, the pairs \((\lambda, \rho)\) above are precisely the points on the unit circle in the first quadrant of \(\R^2\). Hence, for each such pair \((\lambda, \rho)\) there is exactly one angle \(\alpha\in [0,\pi/2]\) such that \[ \lambda = \cos(\alpha) \quad\text{and}\quad \rho = \sin(\alpha). \] For $= 0 $ and \(\alpha = \pi/2\) this gives back the hard left and right pannings, and the above detour about \(1/\sqrt2\) shows that the center panning arises for \(\alpha = \pi/4\).

The equal power pannings can be parameterized by an interval and continuously interpolate between the hard left and right pannings. In practice, it is more convenient to re-parameterize the interval \([0,\pi/2]\) to the unit interval \([-1,1]\) using the affine rescaling function \[ [-1,1] \to [0,\pi/2],\quad p \mapsto \frac\pi4 (p + 1) =: \alpha_p. \] In terms of the position parameter \(p\), the hard left, center, and hard right pannings correspond to \(p=-1\), \(p=0\), and \(p=1\), respectively.

In summary, the the family of equal power pannings of \(x_0\) can be written as follows: \[ P_p\colon \mathcal S_1 \to\mathcal S_2, \quad P_p(x_0) = x_0\cdot \big( \cos(\alpha_p), \sin(\alpha_p) \big), \quad p \in[-1,1]. \]

Adjusting Stereo Width

Now let \(x=(x_L,x_R)\) be a stereo signal. Mathematically, the mono signals \(x_L\) and \(x_R\) in the left and right channels are the point-wise coordinates with respect to the canonical basis of \(\R^2\) given by \(e_L=(1,0)\) and \(e_R=(0,1)\). This happens to be an orthonormal basis. Another orthonormal basis is given by \(e_M=\frac1{\sqrt2}(1, 1)\) and \(e_S=\frac1{\sqrt2}(-1, 1)\), and the corresponding coordinates of \(x\) are the mid channel \(x_M\) and the side channels \(x_S\) given by \[x_M = \frac{x_L + x_R}{\sqrt 2} \quad\text{and}\quad x_S = \frac{x_L - x_R}{\sqrt 2}.\] Formally, we can write the entire stereo signal as \[\begin{align*} x %&= x_L\cdot e_L + x_R\cdot e_R = (x_L, 0) + (0, x_R) \\ &= x_M\cdot e_M + x_S\cdot e_S = \underbrace{\tfrac12(x_L+x_R,x_L+x_R)}_{\text{mid signal}} + \underbrace{\tfrac12(x_L-x_R,x_R-x_L)}_{\text{side signal}} \end{align*}\] The mid signal is nothing but the center panning of the mid channel \(x_M\), which is also called the mixdown of the stereo signal. The side signal is the part that make the signal truly stereo.

As far as I can tell, the concept of “stereo width” used in most digital audio workstatsions (DAWs) is largely based on the level of the side signal. More precisely, the stereo width adjustment appears to be modeled by introducing width parameter \(w\in[0,1]\) used to linearly scale the side signal: \[ W_w(x) = x_M\cdot e_M + w\cdot (x_S\cdot e_S),\quad w\in[0,1]. \] For \(w=1\) this recovers the original signal, while for \(w=0\) only the mid signal is returned: \[ W_1(x) = x_M\cdot e_M + x_S\cdot e_S = x,\quad W_0(x) = x_M\cdot e_M. \] As the width parameter decreases from 1 to 0, the signal sounds narrower and narrower until it becomes a center panned mono signal. Typically, the perceived loudness decreases along with the width. Indeed, the power decreases: \[ \|W_w(x)\|^2 = x_M^2 + w^2x_S^2 \le x_M^2 + x_S^2 = \|x\|^2 \] Equality holds if and only \(x_S=0\), meaning that \(x\) was a center panned mono signal..

A Python Implementation

I’ll begin by loading the libraries and defining some constants that popped up in the mathematical discussion.

import numpy as np 

# sample rate
SR = 44100
# some pre-computed constants
PI = np.pi
SQRT12 = np.sqrt(0.5)

For reasons that I won’t get into here, I want to define the panning and stereo width adjustment within a class StereoControl. Here’s a template:

class StereoControl:
    """Provides various audio effects related to the stereo field."""

    def __init__(self, position=0, width=1):
        self.position = position 
        self.width = width 
    
    def mono_pan(self, mono_audio:np.ndarray) -> np.ndarray:
        """Pans a mono signal according to the position attribute"""
        raise NotImplementedError("mono_pan is missing")
    
    def adjust_width(self, stereo_audio:np.ndarray) -> np.ndarray:
        """Adjusts the width of a stereo signal according to the 
        width attribute"""
        raise NotImplementedError()

While working on the code, I’ll try out of few things that I’ve learned:

  • First, I’ll taks a test driven approach and begin by translating the insights and requirements from the mathematical discussion into test cases.
  • Second, to run the test cases without nasty error messages in this notebook, I’ll use a decorator for error handling.

Here’s the decorator which just print a short message when a NotImplementedError:

def handle_errors(func):
    def wrapper(*args, **kwargs):
        try:
            func(*args,**kwargs)
        except AssertionError as e:
            print("❌ AssertionError:")
            print("-"*80)
            print(e)
        except NotImplementedError:
            print("❌ Something is missing!")
        else:
            print("✅ All tests passed!")
    return wrapper

Equal Power Panning

The upshot of the mathematical discussion is that the equal power pannings with position parameter \(p\in[1,1]\) interpolate between the hard left and right pannings of a mono signal according to the formula \(P_p(x_0) = (\lambda x_0, \rho x_0)\) where \(\lambda,\rho\ge0\) are determined from \(p\) and satisfy \(\lambda^2 + \rho^2=1\). For \(p=0\) we find \(\lambda = \rho = 1 /\sqrt2\). These conditions are easily translated into code for test cases. I’ll use NumPy’s internal testing features from the np.testing module.

@handle_errors
def test_mono_pan():
    """Test cases for mono panning."""
    # load StereoControl instance
    sc = StereoControl()
    # one second of mono white noise
    x_0 = np.random.sample(SR) * 2 - 1
    # one second of silence
    z = np.zeros(SR)
    # TEST: hard left panning should be all left, no right
    sc.position = -1
    np.testing.assert_equal(sc.mono_pan(x_0)[0], x_0,
        "Hard left panning fails in left channel")
    np.testing.assert_equal(sc.mono_pan(x_0)[1], z,
        "Hard left panning fails in right channel")
    # TEST: hard left panning should be all left, no right
    sc.position = 1
    np.testing.assert_equal(sc.mono_pan(x_0)[0], z, 
        "Hard right panning fails in left channel")
    np.testing.assert_equal(sc.mono_pan(x_0)[1], x_0, 
        "Hard right panning fails in right channel")
    # TEST: Center panning should be equal on both sides
    sc.position = 0
    np.testing.assert_equal(sc.mono_pan(x_0)[0], sc.mono_pan(x_0)[1], 
        "Center panning fails")
    # TEST: Equal power condition should be satisfied
    for pos in np.linspace(-1, 1, 1000):
        # update the positon attribute
        sc.position = pos
        # get the panning coefficients
        lam, rho = sc.mono_pan(x_0)[:, 0] / x_0[0]
        # test positivity
        assert lam >= 0, "Positivity fails for left channel"
        assert rho >= 0, "Positivity fails for right channel"
        # test equal intensity
        np.testing.assert_almost_equal(lam**2 + rho**2, 1), \
            f"Equal intensity fails for pos={sc.position}"

# run the test just for fun
test_mono_pan()
❌ Something is missing!

With the tests cases in place, I can now start implementing the mono_pan method in StereoControl. Normally, the code for the test cases and the implementation would be in different Python modules and the tests would be run with pytest. Here I’ll take a different approach for demonstrational purposes. I will iteratively update StereoControl by defining panning functions mono_pan_v1(self, x_0) and dynamically replacing the previous implementation using StereoControl.mono_pan = mono_pan_v1.

In the first iteration, I will simply translate the mathematical formulas for the panning coefficients $$ and \(\rho\) into code. However, this direct implementation does not pass the rather strict tests:

# define a new mono_pan function
def mono_pan_v1(self:StereoControl, x_0:np.ndarray) -> np.ndarray:
    # compute angle from position
    alpha = (self.position + 1) / 4 * PI
    # infer coefficients
    lam = np.cos(alpha)
    rho = np.sin(alpha)
    # compute panned signal (using NumPy's broadcasting)
    x_panned = np.array([[lam],[rho]]) * x_0
    return x_panned

# update the corresponding method in StereoControl
StereoControl.mono_pan = mono_pan_v1

# run the tests
test_mono_pan()
❌ AssertionError:
--------------------------------------------------------------------------------

Arrays are not equal
Hard right panning fails in left channel
Mismatched elements: 44100 / 44100 (100%)
Max absolute difference: 6.12317517e-17
Max relative difference: inf
 x: array([ 3.256694e-17, -5.332062e-17,  4.840199e-17, ..., -1.997129e-17,
        4.517397e-17, -4.837799e-17])
 y: array([0., 0., 0., ..., 0., 0., 0.])

As it turns out, the hard right panning is not quite hard enough. The reason is that np.cos(np.pi / 2) is not exactly \(\cos(\pi/2)=0\), but only approximately. While this will not make an audible difference, it’s easy to better by adding a case distinction. Here’s another version which passes all the tests:

def mono_pan_v2(self:StereoControl, x_0:np.ndarray) -> np.ndarray:
    """Places a mono audio signal in the stereo field. The 
    position is encoded as a floating point number between
    -1 and 1. 
    
    Arguments:
    - x_m: Mono audio signal modeled as a 1d NumPy array"""
    # make sure x_m is a mono signal
    if not (type(x_0) == np.ndarray and x_0.ndim == 1):
        raise ValueError("Input must be a 1d NumPy array.")
    pos = self.position
    # compute coefficients
    if pos == 0:        # center panning
        rho = lam = SQRT12
    elif pos == -1:     # hard left panning
        lam, rho = 1, 0
    elif pos == 1:      # hard left panning
        lam, rho = 0, 1
    else:               # intermediate panning
        # compute angle
        alpha = (pos + 1) / 4 * PI
        # infer coefficients
        lam = np.cos(alpha)
        rho = np.sin(alpha)
    # compute panned signal (using NumPy's broadcasting)
    x_panned = np.array([[lam],[rho]]) * x_0
    return x_panned

# update `StereoControl.mono_pan` and run tests 
StereoControl.mono_pan = mono_pan_v2
test_mono_pan()
✅ All tests passed!

Stereo Width Adjustment

Next up is stereo width adjustment. Again, I’ll first write some test cases relying on white noise. I’ll check the following expected properties:

  • For width 1 the original signal should be returned, that is, \(W_1(x)=x\).
  • For width 0 the signal should be centered, that is, the left and right channel contain the same information.
  • For width \(0 < w < 1\) the signal power satisfies \(\|W_w(x)\|^2 \le \|x\|^2\) with a strict inequality if the side channel is non-zero.
@handle_errors
def test_adjust_width():
    """Test cases for mono panning."""
    # load StereoControl instance
    sc = StereoControl()
    # mono white noise for mid/side-channels (~6 dB headroom)
    x_M = np.random.sample(SR) * - 0.5
    x_S = np.random.sample(SR) * - 0.5
    # left and right channels
    x_L = x_M + 0.1 * x_S
    x_R = x_M - 0.1 * x_S
    # combine to stereo signal
    x = np.stack([x_L, x_R], axis=0)
    # TEST: width 1 gives the original signal
    sc.width  = 1
    y_1 = sc.adjust_width(x)
    np.testing.assert_equal(y_1,x,
        "Width 1 does not give the original signal")
    # TEST: width 0 has no side signal
    sc.width  = 0
    y_0 = sc.adjust_width(x)
    np.testing.assert_equal(y_0[0], y_0[1],
        "Side signal is non-zero for width 0")
    # TEST: power descreases with width
    intermediate_widths = np.linspace(0, 1, 1000)[1:-1]
    x_pow = np.sum(x ** 2, axis=0)
    for w in intermediate_widths:
        sc.width = w
        y_w = sc.adjust_width(x)
        y_pow = np.sum(y_w, axis=0)
        assert (y_pow <= x_pow).all(),\
            f"Signal power does not decrease for width {w}"

# run the test just for fun
test_adjust_width()
❌ Something is missing!

The direct implementation is again straight forward, but fails the tests due to a computation error:

# define new stereo width adjustment function
def adjust_width_v1(self:StereoControl, x:np.ndarray) -> np.ndarray:
    # get width
    width = self.width
    # get left and right channels
    x_L, x_R = x[0], x[1]
    # compute mid and side channels
    x_M = (x_L + x_R) * SQRT12
    x_S = (x_L - x_R) * SQRT12
    # compute mid-side orthonormal bases
    e_M = np.array([[1],[1]]) * SQRT12
    e_S = np.array([[1],[-1]]) * SQRT12
    # compute mid and side signals
    x_mid = x_M * e_M 
    x_side = x_S * e_S 
    # compute adjusted signal
    x_adjusted = x_mid + width * x_side
    return x_adjusted

# replace the method in StereoControl
StereoControl.adjust_width = adjust_width_v1

# run the tests
test_adjust_width()
❌ AssertionError:
--------------------------------------------------------------------------------

Arrays are not equal
Width 1 does not give the original signal
Mismatched elements: 73641 / 88200 (83.5%)
Max absolute difference: 1.66533454e-16
Max relative difference: 4.28501513e-13
 x: array([[-0.349459, -0.18888 , -0.375362, ..., -0.400089, -0.450615,
        -0.177556],
       [-0.285409, -0.157688, -0.290068, ..., -0.313816, -0.363305,
        -0.170586]])
 y: array([[-0.349459, -0.18888 , -0.375362, ..., -0.400089, -0.450615,
        -0.177556],
       [-0.285409, -0.157688, -0.290068, ..., -0.313816, -0.363305,
        -0.170586]])

At first glance, the error might be caused by the fact that np.sqrt(0.5) is not exactly \(1/\sqrt2\). This is easily removed by rescanling the mid-side basis to \((1,1)\) and \((1,-1)\).

# define new stereo width adjustment function
def adjust_width_v2(self:StereoControl, x:np.ndarray) -> np.ndarray:
    # get width
    width = self.width
    # get left and right channels
    x_L, x_R = x[0], x[1]
    # compute rescaled mid and side channels
    x_M = (x_L + x_R) * 0.5
    x_S = (x_L - x_R) * 0.5
    # compute rescaled mid-side basis
    e_M = np.array([[1],[1]])
    e_S = np.array([[1],[-1]])
    # compute mid and side signals
    x_mid = x_M * e_M 
    x_side = x_S * e_S 
    # compute adjusted signal
    x_adjusted = x_mid + width * x_side
    return x_adjusted

# replace the method in StereoControl
StereoControl.adjust_width = adjust_width_v2

# run the tests
test_adjust_width()
❌ AssertionError:
--------------------------------------------------------------------------------

Arrays are not equal
Width 1 does not give the original signal
Mismatched elements: 6379 / 88200 (7.23%)
Max absolute difference: 5.55111512e-17
Max relative difference: 2.35077547e-11
 x: array([[-0.249163, -0.05575 , -0.106219, ..., -0.228352, -0.039073,
        -0.102983],
       [-0.224188, -0.002711, -0.084127, ..., -0.173466,  0.01435 ,
        -0.031522]])
 y: array([[-0.249163, -0.05575 , -0.106219, ..., -0.228352, -0.039073,
        -0.102983],
       [-0.224188, -0.002711, -0.084127, ..., -0.173466,  0.01435 ,
        -0.031522]])

Unfortunately, this did not resolve the issue entirely, although there are significantly fewer “mismatched elements”. In addition, the math got simpler which may have improved the performance efficiency. But given the white noise test input, even simple linear operations are not perfectly precise. The way out is another case distinction to bypass the computations for width 1 - which will also improve performance.

# define new stereo width adjustment function
def adjust_width_v3(self:StereoControl, x:np.ndarray) -> np.ndarray:
    # get width
    width = self.width
    # bypass for width 1
    if width == 1:
        return x
    # get left and right channels
    x_L, x_R = x[0], x[1]
    # compute rescaled mid and side channels
    x_M = (x_L + x_R) * 0.5
    x_S = (x_L - x_R) * 0.5
    # compute rescaled mid-side basis
    e_M = np.array([[1],[1]])
    e_S = np.array([[1],[-1]])
    # compute mid and side signals
    x_mid = x_M * e_M 
    x_side = x_S * e_S 
    # compute adjusted signal
    x_adjusted = x_mid + width * x_side
    return x_adjusted

# replace the method in StereoControl
StereoControl.adjust_width = adjust_width_v3

# run the tests
test_adjust_width()
✅ All tests passed!

Alright, this did it.

Refactoring and Optimizing the Code

Now that all functions are there, I’ll refactor the code. First, let’s get the complete definition of StereoControl in one place.

class StereoControl:
    """Provides various audio effects related to the stereo field."""

    def __init__(self, position=0, width=1):
        self.position = position 
        self.width = width 
    
    def mono_pan(self, x_0:np.ndarray) -> np.ndarray:
        """Places a mono audio signal in the stereo field. The 
        position is encoded as a floating point number between
        -1 and 1. 
        
        Arguments:
        - x_m: Mono audio signal modeled as a 1d NumPy array"""
        # make sure x_m is a mono signal
        if not (type(x_0) == np.ndarray and x_0.ndim == 1):
            raise ValueError("Input must be a 1d NumPy array.")
        pos = self.position
        # compute coefficients
        if pos == 0:        # center panning
            rho = lam = SQRT12
        elif pos == -1:     # hard left panning
            lam, rho = 1, 0
        elif pos == 1:      # hard left panning
            lam, rho = 0, 1
        else:               # intermediate panning
            # compute angle
            alpha = (pos + 1) / 4 * PI
            # infer coefficients
            lam = np.cos(alpha)
            rho = np.sin(alpha)
        # compute panned signal (using NumPy's broadcasting)
        x_panned = np.array([[lam],[rho]]) * x_0
        return x_panned
    
    def adjust_width(self, x:np.ndarray) -> np.ndarray:
        # get width
        width = self.width
        # bypass for width 1
        if width == 1:
            return x
        # get left and right channels
        x_L, x_R = x[0], x[1]
        # compute rescaled mid and side channels
        x_M = (x_L + x_R) * 0.5
        x_S = (x_L - x_R) * 0.5
        # compute rescaled mid-side basis
        e_M = np.array([[1],[1]])
        e_S = np.array([[1],[-1]])
        # compute mid and side signals
        x_mid = x_M * e_M 
        x_side = x_S * e_S 
        # compute adjusted signal
        x_adjusted = x_mid + width * x_side
        return x_adjusted

# run the tests
test_mono_pan()
test_adjust_width()
✅ All tests passed!
✅ All tests passed!

There is one thing I’d like to do to improve performance for real-time applications. In an audio stream the data is not read in full, but rather in small blocks (e.g. 1024 samples at a time). If mono_pan is called, then the coefficients \(\lambda\) and \(\rho\) are computed again for every block. This creates unnecessary overhead, since the coefficients don’t change unless the position parameter is changed. So it would be much more efficient to store the coefficients as attributes.

Here’s an implementation of this idea using private attributes _position and _panning_coefficients, along with a property attribute position whose setter updates both _position and _panning_coefficients. At this point, it pays off to have the test cases in place. While they don’t guarantee that everything is perfect, they can help expose sloppy refactoring error.

class StereoControl:
    """Provides various audio effects related to the stereo field."""

    def __init__(self, position=0, width=1):
        self._position = position 
        self.width = width 
        self._panning_coeffs = self._compute_panning_coeffs()
    
    @property
    def position(self):
        return self._position
    @position.setter
    def position(self, new_pos):
        self._position = new_pos
        self._panning_coeffs = self._compute_panning_coeffs()
    
    def _compute_panning_coeffs(self):
        # get position parameter
        pos = self.position
        # compute coefficients
        if pos == 0:        # center panning
            rho = lam = SQRT12
        elif pos == -1:     # hard left panning
            lam, rho = 1, 0
        elif pos == 1:      # hard left panning
            lam, rho = 0, 1
        else:               # intermediate panning
            # compute angle
            alpha = (pos + 1) / 4 * PI
            # infer coefficients
            lam = np.cos(alpha)
            rho = np.sin(alpha)
        return lam, rho

    def mono_pan(self, x_0:np.ndarray) -> np.ndarray:
        """Places a mono audio signal in the stereo field. The 
        position is encoded as a floating point number between
        -1 and 1. 
        
        Arguments:
        - x_m: Mono audio signal modeled as a 1d NumPy array"""
        # make sure x_m is a mono signal
        if not (type(x_0) == np.ndarray and x_0.ndim == 1):
            raise ValueError("Input must be a 1d NumPy array.")
        # get panning coefficients
        lam, rho = self._panning_coeffs
        # compute panned signal (using NumPy's broadcasting)
        x_panned = np.array([[lam],[rho]]) * x_0
        return x_panned
    
    def adjust_width(self, x:np.ndarray) -> np.ndarray:
        # get width
        width = self.width
        # bypass for width 1
        if width == 1:
            return x
        # get left and right channels
        x_L, x_R = x[0], x[1]
        # compute rescaled mid and side channels
        x_M = (x_L + x_R) * 0.5
        x_S = (x_L - x_R) * 0.5
        # compute rescaled mid-side basis
        e_M = np.array([[1],[1]])
        e_S = np.array([[1],[-1]])
        # compute mid and side signals
        x_mid = x_M * e_M 
        x_side = x_S * e_S 
        # compute adjusted signal
        x_adjusted = x_mid + width * x_side
        return x_adjusted

# run the tests
test_mono_pan()
test_adjust_width()
✅ All tests passed!
✅ All tests passed!