import numpy as np
# sample rate
= 44100
SR # some pre-computed constants
= np.pi
PI = np.sqrt(0.5) SQRT12
Audio Signal Processing in Python: Panning and Stereo Width
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.
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.
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:
*args,**kwargs)
func(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
= StereoControl()
sc # one second of mono white noise
= np.random.sample(SR) * 2 - 1
x_0 # one second of silence
= np.zeros(SR)
z # TEST: hard left panning should be all left, no right
= -1
sc.position 0], x_0,
np.testing.assert_equal(sc.mono_pan(x_0)["Hard left panning fails in left channel")
1], z,
np.testing.assert_equal(sc.mono_pan(x_0)["Hard left panning fails in right channel")
# TEST: hard left panning should be all left, no right
= 1
sc.position 0], z,
np.testing.assert_equal(sc.mono_pan(x_0)["Hard right panning fails in left channel")
1], x_0,
np.testing.assert_equal(sc.mono_pan(x_0)["Hard right panning fails in right channel")
# TEST: Center panning should be equal on both sides
= 0
sc.position 0], sc.mono_pan(x_0)[1],
np.testing.assert_equal(sc.mono_pan(x_0)["Center panning fails")
# TEST: Equal power condition should be satisfied
for pos in np.linspace(-1, 1, 1000):
# update the positon attribute
= pos
sc.position # get the panning coefficients
= sc.mono_pan(x_0)[:, 0] / x_0[0]
lam, rho # test positivity
assert lam >= 0, "Positivity fails for left channel"
assert rho >= 0, "Positivity fails for right channel"
# test equal intensity
**2 + rho**2, 1), \
np.testing.assert_almost_equal(lamf"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
= (self.position + 1) / 4 * PI
alpha # infer coefficients
= np.cos(alpha)
lam = np.sin(alpha)
rho # compute panned signal (using NumPy's broadcasting)
= np.array([[lam],[rho]]) * x_0
x_panned return x_panned
# update the corresponding method in StereoControl
= mono_pan_v1
StereoControl.mono_pan
# 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.")
= self.position
pos # compute coefficients
if pos == 0: # center panning
= lam = SQRT12
rho elif pos == -1: # hard left panning
= 1, 0
lam, rho elif pos == 1: # hard left panning
= 0, 1
lam, rho else: # intermediate panning
# compute angle
= (pos + 1) / 4 * PI
alpha # infer coefficients
= np.cos(alpha)
lam = np.sin(alpha)
rho # compute panned signal (using NumPy's broadcasting)
= np.array([[lam],[rho]]) * x_0
x_panned return x_panned
# update `StereoControl.mono_pan` and run tests
= mono_pan_v2
StereoControl.mono_pan 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
= StereoControl()
sc # mono white noise for mid/side-channels (~6 dB headroom)
= np.random.sample(SR) * - 0.5
x_M = np.random.sample(SR) * - 0.5
x_S # left and right channels
= x_M + 0.1 * x_S
x_L = x_M - 0.1 * x_S
x_R # combine to stereo signal
= np.stack([x_L, x_R], axis=0)
x # TEST: width 1 gives the original signal
= 1
sc.width = sc.adjust_width(x)
y_1
np.testing.assert_equal(y_1,x,"Width 1 does not give the original signal")
# TEST: width 0 has no side signal
= 0
sc.width = sc.adjust_width(x)
y_0 0], y_0[1],
np.testing.assert_equal(y_0["Side signal is non-zero for width 0")
# TEST: power descreases with width
= np.linspace(0, 1, 1000)[1:-1]
intermediate_widths = np.sum(x ** 2, axis=0)
x_pow for w in intermediate_widths:
= w
sc.width = sc.adjust_width(x)
y_w = np.sum(y_w, axis=0)
y_pow 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
= self.width
width # get left and right channels
= x[0], x[1]
x_L, x_R # compute mid and side channels
= (x_L + x_R) * SQRT12
x_M = (x_L - x_R) * SQRT12
x_S # compute mid-side orthonormal bases
= np.array([[1],[1]]) * SQRT12
e_M = np.array([[1],[-1]]) * SQRT12
e_S # compute mid and side signals
= x_M * e_M
x_mid = x_S * e_S
x_side # compute adjusted signal
= x_mid + width * x_side
x_adjusted return x_adjusted
# replace the method in StereoControl
= adjust_width_v1
StereoControl.adjust_width
# 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
= self.width
width # get left and right channels
= x[0], x[1]
x_L, x_R # compute rescaled mid and side channels
= (x_L + x_R) * 0.5
x_M = (x_L - x_R) * 0.5
x_S # compute rescaled mid-side basis
= np.array([[1],[1]])
e_M = np.array([[1],[-1]])
e_S # compute mid and side signals
= x_M * e_M
x_mid = x_S * e_S
x_side # compute adjusted signal
= x_mid + width * x_side
x_adjusted return x_adjusted
# replace the method in StereoControl
= adjust_width_v2
StereoControl.adjust_width
# 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
= self.width
width # bypass for width 1
if width == 1:
return x
# get left and right channels
= x[0], x[1]
x_L, x_R # compute rescaled mid and side channels
= (x_L + x_R) * 0.5
x_M = (x_L - x_R) * 0.5
x_S # compute rescaled mid-side basis
= np.array([[1],[1]])
e_M = np.array([[1],[-1]])
e_S # compute mid and side signals
= x_M * e_M
x_mid = x_S * e_S
x_side # compute adjusted signal
= x_mid + width * x_side
x_adjusted return x_adjusted
# replace the method in StereoControl
= adjust_width_v3
StereoControl.adjust_width
# 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.")
= self.position
pos # compute coefficients
if pos == 0: # center panning
= lam = SQRT12
rho elif pos == -1: # hard left panning
= 1, 0
lam, rho elif pos == 1: # hard left panning
= 0, 1
lam, rho else: # intermediate panning
# compute angle
= (pos + 1) / 4 * PI
alpha # infer coefficients
= np.cos(alpha)
lam = np.sin(alpha)
rho # compute panned signal (using NumPy's broadcasting)
= np.array([[lam],[rho]]) * x_0
x_panned return x_panned
def adjust_width(self, x:np.ndarray) -> np.ndarray:
# get width
= self.width
width # bypass for width 1
if width == 1:
return x
# get left and right channels
= x[0], x[1]
x_L, x_R # compute rescaled mid and side channels
= (x_L + x_R) * 0.5
x_M = (x_L - x_R) * 0.5
x_S # compute rescaled mid-side basis
= np.array([[1],[1]])
e_M = np.array([[1],[-1]])
e_S # compute mid and side signals
= x_M * e_M
x_mid = x_S * e_S
x_side # compute adjusted signal
= x_mid + width * x_side
x_adjusted 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
= self.position
pos # compute coefficients
if pos == 0: # center panning
= lam = SQRT12
rho elif pos == -1: # hard left panning
= 1, 0
lam, rho elif pos == 1: # hard left panning
= 0, 1
lam, rho else: # intermediate panning
# compute angle
= (pos + 1) / 4 * PI
alpha # infer coefficients
= np.cos(alpha)
lam = np.sin(alpha)
rho 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
= self._panning_coeffs
lam, rho # compute panned signal (using NumPy's broadcasting)
= np.array([[lam],[rho]]) * x_0
x_panned return x_panned
def adjust_width(self, x:np.ndarray) -> np.ndarray:
# get width
= self.width
width # bypass for width 1
if width == 1:
return x
# get left and right channels
= x[0], x[1]
x_L, x_R # compute rescaled mid and side channels
= (x_L + x_R) * 0.5
x_M = (x_L - x_R) * 0.5
x_S # compute rescaled mid-side basis
= np.array([[1],[1]])
e_M = np.array([[1],[-1]])
e_S # compute mid and side signals
= x_M * e_M
x_mid = x_S * e_S
x_side # compute adjusted signal
= x_mid + width * x_side
x_adjusted return x_adjusted
# run the tests
test_mono_pan() test_adjust_width()
✅ All tests passed!
✅ All tests passed!