リアルタイム音声配信のUXを高める - tanh関数による音量ビジュアライゼーション
SKIYAKI Tech Blog Advent Calendar 2025 の4日目記事です!
はじめに
ライブ配信機能を開発していると、「話している自分の音量をリアルタイムで可視化する」という要件に直面することがあります。一見シンプルに思えるこの機能ですが、実装してみると意外な落とし穴がありました。
本記事では、双曲線正接(tanh)関数を使った音量ビジュアライゼーションの実装について、アナログオーディオ機器の知見を活かしたUX改善の過程をご紹介します。
問題点: 単純な線形変換では何がダメなのか?
最初は、音量データをそのままゲージの高さに反映させる、いわゆる線形変換で実装しました。
// 最初の実装(問題あり)
const volume = rtcRemoteUser.audioTrack.getVolumeLevel(); // 0.0 ~ 1.0
setClientVolume(volume); // そのまま使う
しかし、この実装には大きな問題がありました:
-
小さな音でもゲージが大きく反応してしまう
- 息遣いや環境音でバーが大きく動く
- 実際の聴覚体験よりも「うるさく」見える
-
ユーザーに不安を与える
- 「こんなに音が大きいの?」と心配になる
- 配信者が萎縮してしまう可能性
-
実際の音量感と視覚表現が乖離
- 聞いている感覚とゲージの動きが一致しない
- 違和感のあるUIになってしまう
線形変換のグラフ (y = x)
1.0 | ●
| /
| /
0.5 | ● ← 小さい音でもかなり大きく表示される
| /
| /
0.0 | ●________________
0.0 0.5 1.0
(実際の音量)

関数グラフ(https://www.geogebra.org/graphing?lang=ja) を利用しました。
解決のヒント: オーディオ機器の「Aカーブ」
ここで思い出したのが、可変抵抗器(ボリュームつまみ)のAカーブです。
オーディオ機器のボリュームコントロールには、主に以下のカーブ特性があります:
- Aカーブ(Audio taper): オーディオ用途。対数的な変化で人間の聴覚特性に合わせる
- Bカーブ(Linear taper): 線形変化。測定器などに使用
- Cカーブ(Reverse Audio taper): 逆対数
人間の聴覚は対数的に音を感じるため、Aカーブを使うことで:
- つまみを少し回す → 音量が大きく変わる(小音量帯)
- つまみを大きく回す → 音量の変化は控えめ(大音量帯)
という自然な操作感を実現しています。
より詳しい説明(音源関係のハード開発の話なので、もしかしたらおもしろいかも) → https://detail-infomation.com/variable-resistor-curve/
「この考え方を音量ビジュアライゼーションにも応用できないか?」
解決策: tanh関数による非線形変換
なぜtanh関数なのか?
音量の変換に使える関数はいくつか候補がありました:
| 関数 | メリット | デメリット |
|---|---|---|
| 線形(y=x) | シンプル | 小音量で過敏に反応 |
| 対数(log) | 聴覚特性に近い | 0付近で発散、パラメータ調整が難しい |
| 指数(exp) | 滑らか | 範囲制御が難しい |
| tanh | S字カーブ、滑らか、範囲が[0,1] | 特になし |
tanh(双曲線正接)関数を選んだ理由:
- S字カーブ: 中央で最も感度が高く、両端で収束する
- 滑らかな変化: 微分可能で自然なアニメーション
- 範囲が明確: -1 ~ 1 に収束するため扱いやすい
- パラメータ調整が容易: 勾配と中心点を簡単に変えられる
- 計算コストが低い: Math.tanh()で実装可能
tanh関数のグラフ
1.0 | ______
| /
| |
0.5 | ● ← 中央で最も感度が高い
| |
| ____/
0.0 | _________
0.0 0.5 1.0
(実際の音量)

関数グラフ(https://www.geogebra.org/graphing?lang=ja) を利用しました。
実装コード
/** 音量の表示の変化を、よりわかりやすく機敏に動かすために調整する関数 */
const volumeToInteractive = (volume) => {
const deg = 6 // 変化の度合い(勾配)
const changedPoint = 0.5 // 変化の起点(0<=x<=1になるように調整してみた値)
return changedPoint * (Math.tanh(deg * (volume - changedPoint)) + 1)
}
// 使用例
getVolumeIntervalRef.current = setInterval(() => {
if(rtcRemoteUser) {
const volume = rtcRemoteUser.audioTrack.getVolumeLevel(); // 生の音量データ
setClientVolume(volumeToInteractive(volume)); // 変換後の値
}
}, 100); // 100msごとに更新
パラメータの意味
deg = 6: カーブの急峻さ
- 値が大きいほど、S字カーブが急になる
- 小さいほど、線形に近づく
- 6という値は試行錯誤の結果、最も自然に見えた値
deg = 1 → 緩やかなS字
deg = 6 → 適度な急峻さ ★採用
deg = 10 → ほぼ段階的な変化
changedPoint = 0.5: S字カーブの中心点
- この点を中心にS字カーブが形成される
- 0.5に設定することで、音量の中間地点で最も感度が高くなる
- 0 ~ 1の範囲内に正規化
changedPoint = 0.3 → 低音量域で敏感
changedPoint = 0.5 → 中間域で敏感 ★採用
changedPoint = 0.7 → 高音量域で敏感
Math.tanh(deg * (volume - changedPoint)) + 1
-
volume - changedPoint: 中心点をシフト -
deg *: 勾配を調整 -
Math.tanh(): -1 ~ 1 の範囲に変換 -
+ 1: 0 ~ 2 の範囲にシフト -
changedPoint *: 0 ~ 1 の範囲に正規化
さらなる工夫: 3段階のバーで視覚的にも配慮
単一のバーではなく、反応速度が異なる3本のバーを実装することで、より直感的な音量表現を実現しました。
<div className="p-liveRadio__micVolume">
{/* 最も敏感なバー: 0.7で上限 */}
<div className='p-liveRadio__micVolume__bar'>
<div className='p-liveRadio__micVolume__bar__inner'
style={{height: `${(clientVolume < 0.7 ? clientVolume / 0.7 : 1) * 100}%`}}
/>
</div>
{/* 中程度の敏感さ: 0.5で上限 */}
<div className='p-liveRadio__micVolume__bar'>
<div className='p-liveRadio__micVolume__bar__inner'
style={{height: `${(clientVolume <= 0.5 ? clientVolume / 0.5 : 1) * 100}%`}}
/>
</div>
{/* 最も鈍感なバー: 1.0で上限 */}
<div className='p-liveRadio__micVolume__bar'>
<div className='p-liveRadio__micVolume__bar__inner'
style={{height: `${clientVolume * 100}%`}}
/>
</div>
</div>
この実装により:
- 小さな音: 左のバーだけが反応 → 「少し声が出てるな」
- 普通の音: 左と中央のバーが反応 → 「しっかり声が出てるな」
- 大きな音: 3本すべてが反応 → 「大きめの声だな」
段階的な反応で、ユーザーは音量の強弱を直感的に把握できます。
パフォーマンスへの配慮
更新頻度: 100ms
setInterval(() => {
// 音量取得と更新処理
}, 100); // 100ms = 秒間10回更新
更新頻度の選定理由:
- 60fps (16.6ms): 滑らかだが、音量データの更新頻度とミスマッチ、CPU負荷が高い
- 100ms (10fps): 人間の知覚に十分な滑らかさ、CPU負荷も適切 ★採用
- 250ms (4fps): カクカクして見える
メモリリーク対策
useEffect(() => {
// setIntervalを開始
getVolumeIntervalRef.current = setInterval(() => { /* ... */ }, 100);
return () => {
// コンポーネントのアンマウント時にクリーンアップ
if(getVolumeIntervalRef.current) {
clearInterval(getVolumeIntervalRef.current);
}
}
}, []);
Reactのクリーンアップ処理を確実に行うことで、メモリリークを防止しています。
結果: ユーザー体験の向上
ビフォー・アフター比較
| 項目 | 線形変換(変更前) | tanh変換(変更後) |
|---|---|---|
| 小さな息遣い | ゲージが大きく動く ⚠️ | 控えめに表示 ✅ |
| 普通の会話 | やや過敏に反応 ⚠️ | 自然な動き ✅ |
| 大きな声 | 上限に張り付く ⚠️ | 適度に上限近くを表示 ✅ |
| 主観的な印象 | 「うるさそう」 | 「ちょうど良さそう」 ✅ |
| 配信者の心理 | 不安になる | 安心できる ✅ |
実際のグラフの変化はこんな感じ

実際にサービスで使われているゲージでは無いのですが、別ページにマイクから拾った音量をゲージにする表示を作りました。ゲージの変化はサービスで表示されているゲージと同じになっています。オレンジの方が、マイクの値をそのままゲージに反映するもので、青い方がtanhを使って加工した値をゲージ化しています。
上のスクショはPCの前で特に何もしていない状態です。環境音やキーボードの音を拾って線形でゲージを表示している方はかなり大きい変化が表示されています。かたやtanhは0表示です。
厳密に表示するのであれば線形ゲージの方が正しいのですが、UXとして考えると青の方が直感的ですね。

ちょっとぼそっと喋ってみました。

PCの前でかなり本気を出して叫びました。(ファルセットだ)
比例の方はゲージの表示ではさっきのボソッと喋ったのと比べ そこまでの変化はありませんが、tanhの方はS字曲線の変化が大きいところを越えたのか 大きく変化しています。
ビクアライゼーションが上手く動いているのがわかります。
余談ですが、自宅でこのスクショを撮るためにたくさん奇声を上げる等するのが楽しかったです。
定量的な改善
実際の音量データに対する変換結果:
// 実際の音量 → 表示される値
volumeToInteractive(0.1) // → 約0.15 (少し抑えめ)
volumeToInteractive(0.3) // → 約0.35 (ほぼそのまま)
volumeToInteractive(0.5) // → 約0.50 (中央は変化なし)
volumeToInteractive(0.7) // → 約0.65 (やや抑えめ)
volumeToInteractive(0.9) // → 約0.75 (かなり抑えめ)
小音量域と大音量域を適度に抑えることで、「実際の聴覚体験」と「視覚表現」が一致するようになりました。
他の分野からの学びを活かす
今回の実装では:
- アナログオーディオ機器の知見(Aカーブ)
- 人間工学の理解(聴覚の対数特性)
- 数学的なアプローチ(tanh関数)
という、一見関係なさそうな領域の知識を組み合わせました。
「なぜそうなっているのか?」を考える習慣が、より良い実装につながると実感した事例です。
まとめ
リアルタイム音声配信における音量ビジュアライゼーションを、tanh関数を使って改善した事例をご紹介しました。
ポイントの振り返り
- 単純な線形変換では不十分 - 人間の感覚とのギャップが生まれる
- アナログ機器の知見を活かす - Aカーブの考え方を応用
- tanh関数の特性を活用 - S字カーブで自然な変化を実現
- パラメータを調整 - deg=6, changedPoint=0.5 が最適
- 視覚的な工夫も追加 - 3段階のバーで直感的に
- パフォーマンスにも配慮 - 100ms更新で十分な滑らかさ
明日はアドカレ5日目 nakamuraさんの「PrometheusのService Discoveryで、スケールアウト用EC2インスタンスを監視対象に動的追加/削除をする」の予定です! PrometheusってDBの種類なんかな~~とか思ってたんですけど 監視のためのツール?なんですかね? そこらへんも含めてワクワクですね、5日目
参考資料
この記事が、より良いUX実装の参考になれば幸いです!
株式会社SKIYAKIのテックブログです。ファンクラブプラットフォームBitfanの開発・運用にまつわる知見や調べたことなどを発信します。主な技術スタックは Ruby on Rails / React / AWS / Swift / Kotlin などです。 recruit.skiyaki.com/
Discussion