何かあれば GitHub のリポジトリに issue を作るか ryukau@gmail.com までお気軽にどうぞ。
Update: 2024-08-06
Loudness Concepts & Panning Laws で紹介されているモノラル → ステレオのパンニング法 (panning law) を基にして、ステレオ → ステレオのパンニングの計算式をいくつか作ります。
以下はモノラル → ステレオパンニングのブロック線図です。
以下の記号を使います。
入力信号を \(x\) 、出力信号を \(y_L, y_R\) とすると、モノラル → ステレオのパンニングは以下の式で計算できます。
\[ \begin{aligned} y_L &= L \cdot x\\ y_R &= R \cdot x\\ \end{aligned} \]
\(L^2 + R^2\) で音のパワーを計算できます。パワーは、スピーカから人間の耳に至るまでの反響や位相のずれなどを加味した、大まかな音量を表しています。
線形パンニングは \(p\) の値をそのまま使って直線的にゲインを変えるパンニングです。利点はパンを振った後でも左右のチャンネルを足し合わせると元のモノラル信号に戻ることです。欠点は中央にパンを振ると左右にパンを振ったときよりも音が小さく聞こえることです。
線形パンニングの計算式です。
\[ \begin{aligned} L &= 1 - p\\ R &= p \end{aligned} \]
コードにします。 pan == 0.0
で左いっぱい、
pan == 0.5
でセンター、 pan == 1.0
で右いっぱいです。戻り値は
(左チャンネルのゲイン, 右チャンネルのゲイン)
となっています。
def panMonoLinear(pan):
return (1 - pan, pan)
以下の図は線形パンニングのゲイン特性のプロットです。左から順に各チャンネルのゲイン、左右を足し合わせたときのゲイン、パワーを表しています。パンが 0.5 のときパワーが下がっているので再生環境によっては音が小さくなることが確認できます。
等パワーパンニングは音の大きさがパンによらず均一に聞こえるようにしたパンニングです。欠点は左右のチャンネルを足し合わせたときに最大で 3 dB 振幅が大きくなることです。
等パワーパンニングの計算式です。
\[ \begin{aligned} \theta &= \frac{\pi}{2} p\\ L &= \cos(\theta)\\ R &= \sin(\theta) \end{aligned} \]
\(L^2 + R^2 = \cos^2(\theta) + \sin^2(\theta)\) となるので三角関数の Pythagorean identity よりパワーは常に \(1\) です。
コードにします。
def panMonoConstantPower(pan):
= (0.5 * np.pi) * pan
theta return (np.cos(theta), np.sin(theta))
以下の図は等パワーパンニングのゲイン特性のプロットです。パンが 0.5 のとき \(L + R\) が上がっていることが確認できます。
-4.5 dB パンニングは線形パンニングと等パワーパンニングの中間的なパンニングです。左右のスピーカの間隔が狭いテレビなどでは等パワーパンニングよりも自然に聞こえることがあるそうです。
-4.5 dB パンニングの計算式です。
\[ \begin{aligned} \theta &= \frac{\pi}{2} p\\ L &= \sqrt{(1 - p)\cos(\theta)}\\ R &= \sqrt{p \sin(\theta)} \end{aligned} \]
コードにします。
def panMonoIntermediatePower(pan):
= (0.5 * np.pi) * pan
theta return (
1 - pan) * np.cos(theta)),
np.sqrt((* np.sin(theta)),
np.sqrt(pan )
以下の図は -4.5 dB パンニングのゲイン特性のプロットです。 \(L + R\) は等パワーパンニングの半分、パワーは線形パンニングの半分の値になっています。
以下はステレオ → ステレオパンニングのブロック線図です。
ステレオ → ステレオパンニングでは、モノラル → ステレオパンニングのゲイン \(L, R\) に加えて、入力と出力のチャンネル数を掛け合わせた 4 つのゲインを計算します。それぞれのゲインに記号を割り当てます。
入力信号を \(x\) 、出力信号を \(y\) とします。チャンネルは下付き文字で \(x_L\) や \(y_R\) のように表すことにします。このとき出力信号は以下の式で計算できます。中点 \(\cdot\) は乗算を表しています。
\[ \begin{aligned} y_L &= L \cdot (G_{LL} \cdot x_L + G_{RL} \cdot x_R)\\ y_R &= R \cdot (G_{LR} \cdot x_L + G_{RR} \cdot x_R)\\ \end{aligned} \]
左右で同じ信号が入力されたときにモノラル → ステレオパンニングと同じ振る舞いをさせたいので、 \(L, R\) をかける前のゲインの和が 1 になるようにします。
\[ \begin{aligned} G_{LL} + G_{RL} &= 1\\ G_{LR} + G_{RR} &= 1\\ \end{aligned} \]
すると左右で同じ信号 \(x\) が入力されたとき、出力信号の式を以下のように変形できます。
\[ \begin{aligned} y_L = L \cdot x\\ y_R = R \cdot x\\ \end{aligned} \]
モノラル → ステレオパンニングの計算式と同じになっています。ゲイン \(L, R\) はモノラル → ステレオパンニングの式がそのまま使えます。
ステレオ → ステレオパンニングで導入された 4 つのゲイン \(G_{LL}, G_{LR}, G_{RR}, G_{RL}\) をどう決めればいいのか見ていきます。
出力の計算式を再掲します。
\[ \begin{aligned} y_L &= L \cdot (G_{LL} \cdot x_L + G_{RL} \cdot x_R) ,\qquad G_{LL} + G_{RL} = 1\\ y_R &= R \cdot (G_{LR} \cdot x_L + G_{RR} \cdot x_R) ,\qquad G_{LR} + G_{RR} = 1\\ \end{aligned} \]
和を 1 以下にする条件から \(G_{RL}\) と \(G_{LR}\) は以下のように計算できます。
\[ \begin{aligned} G_{RL} &= 1 - G_{LL}\\ G_{LR} &= 1 - G_{RR}\\ \end{aligned} \]
つまり \(G_{LL}\) と \(G_{RR}\) のゲインだけを決めればいいわけです。
また、パンの位置 \(p\) によって \(G_{LL}\) に以下の条件をつけます。
同様に \(G_{RR}\) に以下の条件をつけます。
上の条件を直線でつなぐと直線的なフェードになります。以下は直線的なフェードの図です。
式にします。
\[ \begin{aligned} G_{LL} &= \begin{cases} 0.5 + p & \text{if} \enspace p < 0.5\\ 1 & \text{if} \enspace p \geq 0.5\\ \end{cases}\\ G_{RR} &= \begin{cases} 1 & \text{if} \enspace p \leq 0.5\\ 1.5 - p & \text{if} \enspace p > 0.5\\ \end{cases}\\ G_{RL} &= 1 - G_{LL}\\ G_{LR} &= 1 - G_{RR}\\ \end{aligned} \]
\(G_{LL}\) と \(G_{RR}\) は 0.5 を境に線対称になっているので \(G_{RR}(p) = G_{LL}(1 - p)\) と書くこともできます。以降では同様の対称性があるものとして \(G_{LL}\) だけを求めます。
コードにします。
def getStereoGain(gainFunc, param):
= gainFunc(pan, param)
LL = gainFunc(1 - pan, param)
RR = 1 - LL
RL = 1 - RR
LR return (LL, RL, LR, RR)
def panStereoLinear(pan, param=None):
def gain(pan, unused):
return np.where(pan >= 0.5, 1, 0.5 + pan)
return getStereoGain(gain, param)
np.where(a, b, c)
は C++ の a ? b : c
に訳せます。 getStereoGain
は後で繰り返し使うので分けています。
以下は panStereoLinear
の使用例です。
# source は [左チャンネル, 右チャンネル] の 2 次元配列。
= panStereoLinear(pan)
LL, RL, LR, RR = panMonoConstantPower(pan)
gainL, gainR
= gainL * (LL * source[0] + RL * source[1])
sigL = gainR * (LR * source[0] + RR * source[1]) sigR
以下の図は直線的なフェード曲線のプロットです。
以下の図のような \(G_{LL}\) の式を作ります。黒い部分は直線、オレンジの部分は 2 次曲線です。
上の図の \(G_{LL}\) を \(p\) について微分すると以下のような形になります。
\(\dfrac{dG_{LL}}{dp}\) の図の斜線の領域の面積は 0.5 です。これは横軸 \(p\) の 0 から 0.5 の区間での \(G_{LL}\) の増分です。台形の面積の式から以下の等式が立ちます。
\[ \frac{(a + 0.5)}{2} h = 0.5 \]
\(h\) について解きます。
\[ h = \frac{1}{a + 0.5} \]
これで \(\dfrac{dG_{LL}}{dp}\) が計算できます。
\[ \frac{dG_{LL}}{dp} = \begin{cases} h & \text{if} \enspace p \leq a\\ \dfrac{h\,(p - 0.5)}{a - 0.5} & \text{if} \enspace a < p < 0.5\\ 0 & \text{if} \enspace 0.5 \leq p\\ \end{cases} \]
\(p\) について積分すると \(dG_{LL}\) の計算式が得られます。
\[ \begin{aligned} G_{LL}(p) &= \begin{cases} hp + 0.5 & \text{if} \enspace p \leq a & \text{(linear region)}\\ \dfrac{h(p - 0.5)^2}{2a - 1} + 1 & \text{if} \enspace a < p < 0.5 & \text{(2nd order region)}\\ 1 & \text{if} \enspace 0.5 \leq p & \text{(constant region)}\\ \end{cases}\\ h &= \frac{1}{a + 0.5} \end{aligned} \]
2 次領域の式は以下の Maxima のコードの出力を整形したものです。
-C + 1
が積分定数です。
G: integrate(h*(p-0.5)/(a-0.5), p);
C: subst(0.5, p, G);
factorout(G - C + 1, p);
コードにします。 param
は値が [0.0, 1.0]
で正規化されたパラメータで、デフォルト値は適当に決めています。
def panStereoPartial2ndOrder(pan, param=0.8):
"""`param` is in [0.0, 1.0]."""
def gain(p, a):
= 1 / (a + 0.5)
h return np.where(
<= a,
p * p + 0.5,
h
np.where(>= 0.5,
p 1,
* (p - 0.5)**2 / (2 * a - 1) + 1,
h
),
)
return getStereoGain(gain, 0.5 * param)
以下の図は部分的に 2 次曲線を使ったフェード曲線のプロットです。 \(a = 0.4\) です。
部分的に 2 次曲線を使うフェードの 2 次曲線を \(\sin\) に置き換えたフェードを作ります。
以下のような \(\dfrac{dG_{LL}}{dp}\) の曲線を使います。オレンジの線の部分には \(\cos\) を使います。
\(\dfrac{dG_{LL}}{dp}\) の式を立てます。
\[ \begin{aligned} \frac{dG_{LL}}{dp} &= \begin{cases} h & \text{if} \enspace p \leq a\\ \displaystyle \frac{h}{2} \left( \cos \left( \frac{p - a}{0.5 - a} \pi \right) + 1 \right) & \text{if} \enspace a < p < 0.5\\ 0 & \text{if} \enspace 0.5 \leq p\\ \end{cases}\\ h &= \frac{2} {2a + 1} \end{aligned} \]
\(h\) は以下の Maxima のコードで求めました。
S: integrate(h/2 * (cos((p - a) / (1/2 - a) * %pi) + 1), p, a, 1/2);
A: ratsimp(a * h + S);
solve(1/2 = A, h);
\(\dfrac{dG_{LL}}{dp}\) の式を積分します。
\[ \begin{aligned} G_{LL}(p) &= \begin{cases} hp + 0.5 & \text{if} \enspace p \leq a & \text{(linear region)}\\ \displaystyle 0.5 h \left( \frac{0.5 - a}{\pi} \sin \left( \frac{p - a}{0.5 - a} \pi \right) + p - 0.5 \right) + 1 & \text{if} \enspace a < p < 0.5 & \text{(sin region)}\\ 1 & \text{if} \enspace 0.5 \leq p & \text{(constant region)}\\ \end{cases}\\ h &= \frac{2} {2a + 1} \end{aligned} \]
\(\sin\) 領域の式は以下の Maxima
のコードの expr
を整形したものです。
S: integrate(h/2 * (cos((p - a) / (1/2 - a) * %pi) + 1), p);
C: subst(1/2, p, S);
expr: S - C + 1;
コードにします。
def panStereoPartialSin(pan, param=0.8):
"""`param` is in [0.0, 1.0]."""
def gain(p, a):
= 2 / (2 * a + 1)
h return np.where(
<= a,
p * p + 0.5,
h
np.where(>= 0.5,
p 1,
0.5 * h * ((0.5 - a) / np.pi * np.sin(
- a) / (0.5 - a) * np.pi) + p - 0.5) + 1,
(p
),
)
return getStereoGain(gain, 0.5 * param)
以下の図は部分的に \(\sin\) を使ったフェード曲線のプロットです。 \(a = 0.4\) です。
円の式です。
\[ x^2 + y^2 = r^2 \]
\(r\) を 1 として \(y\) について解きます。値が正になる解だけを使います。
\[ y = \sqrt{1 - x^2}\\ \]
\(x\) は範囲 \((0, 1]\) のパラメータにして、フェードに使う弧の長さを変えられるようにします。 \(x = 0\) のときは \(G_{LL}\) の式で 0 除算が起こるので範囲に含めません。
\(G_{LL}\) の計算式です。
\[ G_{LL}(p) = \begin{cases} 0.5 + 0.5 \dfrac{\sqrt{1 - (x - 2xp)^2} - y}{1 - y} & \text{if} \enspace p \leq 0.5\\ 1 & \text{if} \enspace 0.5 \leq p\\ \end{cases}\\ \]
コードにします。
def panStereoCircle(pan, param=1):
"""`param` is in (0.0, 1.0]."""
def gain(p, x):
= np.sqrt(1 - x * x)
y return np.where(
<= 0.5,
p 0.5 + 0.5 * np.sqrt(1 - (x - 2 * x * p)**2) - y / (1 - y),
1,
)
return getStereoGain(gain, param)
以下の図は円の式を使ったフェード曲線のプロットです。 \(x = 1\) です。
\(G_{LL}\) の式を立てます。 \(n\) はフェード曲線を変えるパラメータで、曲線の次数です。
\[ G_{LL}(p) = \begin{cases} 1 - 0.5(1 - 2p)^n & \text{if} \enspace p \leq 0.5\\ 1 & \text{if} \enspace 0.5 \leq p\\ \end{cases}\\ \]
コードにします。
def panStereoPoly(pan, param=0.25):
"""`param` is in [0.0, 1.0]."""
def gain(p, n):
return np.where(
<= 0.5,
p 1 - 0.5 * np.power(1 - 2 * p, n),
1,
)
= (1 + 4 * param)
order return getStereoGain(gain, order)
以下の図は \(n\) 次曲線を使ったフェード曲線のプロットです。 \(n = 2\) です。
\(G_{LL}\) の式を立てます。
\[ G_{LL}(p) = \begin{cases} 0.5 + 0.5\sin(\pi p) & \text{if} \enspace p \leq 0.5\\ 1 & \text{if} \enspace 0.5 \leq p\\ \end{cases}\\ \]
コードにします。
def panStereoSin(pan, param=None):
def gain(p, unused):
return np.where(
<= 0.5,
p 0.5 + 0.5 * np.sin(np.pi * p),
1,
)
return getStereoGain(gain, param)
以下の図は \(\sin\) を使ったフェード曲線のプロットです。
\(G_{LL}\) の式を立てます。 \(n\) はフェード曲線を変えるパラメータで、曲線の次数です。
\[ G_{LL}(p) = \begin{cases} 0.75 - 0.25\cos^n(2 \pi p) & \text{if} \enspace p \leq 0.5\\ 1 & \text{if} \enspace 0.5 \leq p\\ \end{cases}\\ \]
コードにします。 \(n\) の範囲は適当に \([1, 4]\) としています。
def panStereoSCurve(pan, param=0):
def gain(p, n):
return np.where(
<= 0.5,
p 0.75 - 0.25 * np.cos(2 * np.pi * p)**n,
1,
)
= 1 + 3 * param
order return getStereoGain(gain, order)
以下の図は S 字のフェード曲線のプロットです。 \(n = 1\) です。
\(G_{LL}\) の式を立てます。 \(k\) はフェード曲線を変えるパラメータです。範囲は \([1, 100]\) くらいで指数スケールを使うと良さそうです。
\[ \begin{aligned} G_{LL}(p) &= 0.5 + 0.5 \, \frac{v_{\max} - \mathrm{softplus}(k\,(0.5-p))}{v_{\max} - v_{\min}}\\ \mathrm{softplus}(x) &= \ln (1 + \exp(x))\\ v_{\min} &= \mathrm{softplus}(-0.5 k)\\ v_{\max} &= \mathrm{softplus}(0.5 k)\\ \end{aligned} \]
コードにします。
def panStereoSoftplus(pan, param=0.5):
"""`param` is in [0.0, 1.0]."""
def softplus(x):
return np.log(1 + np.exp(x))
def gain(p, k):
= softplus(-0.5 * k)
v_min = softplus(0.5 * k)
v_max return 0.5 + 0.5 * (v_max - softplus(k * (0.5 - p))) / (v_max - v_min)
= np.power(10, 2 * param)
saturation return getStereoGain(gain, saturation)
以下の図は softplus による漏れのあるフェード曲線のプロットです。 \(k = 10\) です。
\(G_{LL}\) の式を立てます。
\[ G_{LL}(p) = \begin{cases} 0.5 + 0.5 \, \mathrm{sinc}(k (1 - 2p)) & \text{if} \enspace p \leq 0.5\\ 1 & \text{if} \enspace 0.5 \leq p\\ \end{cases}\\ \]
コードにします。
def panStereoSinc(pan, param=0.5):
"""`param` is in [0.0, 1.0]."""
def gain(p, k):
return np.where(
<= 0.5,
p 0.5 + 0.5 * np.sinc(k * (1 - 2 * p)),
1,
)
= 1 + np.floor(16 * param)
zeroCross return getStereoGain(gain, zeroCross)
以下の図は波打つフェード曲線のプロットです。 \(k = 9\) です。
データ量を減らすため圧縮に opus を使っています。