はじめに ― なぜフォルマント分析に挑戦したのか?
理学療法士として日々の臨床に関わる中で、歩行や立ち上がりといった動作の評価は、速度や関節角度といった「物理量」で比較的客観的に評価できる場面が多いと感じています。たとえば、転倒=失敗というように、実用性の判定も明確です。
一方で、構音(音声の明瞭さや発声の質)となると、評価が一気に難しくなります。聴覚的な印象や主観に頼らざるを得ない場面も多く、リハビリの進捗や効果判定を客観的に表現する手段が限られているように感じていました。
「こうした曖昧さを、何とか数値として捉えられないだろうか?」
そんな思いから始まったのが、今回紹介するフォルマント分析アプリの自作です。
どんなアプリ?
作成したのは以下のようなアプリです。
マイクで声を入力するとリアルタイムに音声データを分析し、第1・第2フォルマントを抽出して画面上にプロットします。
グラフ上の青のプロットは標準的な男性の「あいうえお」のフォルマントを示しており、オレンジのプロットは女性のものを示しています。
赤い点がマイク入力からのデータをプロットしたものです。
「あいうえお」と発音すると第1第2フォルマントが抽出され、標準的な男性のフォルマント近くに赤い点が移動するのが確認できます。
※動画を再生すると音声が出ます。注意してください。
使用したツールと実装の工夫
使用したマイクと性能の違い
自宅にあった Logicool G433 のマイクで音声を録音しましたが、職場の方に借りた ECM-PCV80U(ソニー製の単一指向性コンデンサマイク)でも同様に録音・分析を試しました。
G433の方が若干ノイズを拾いやすい印象でしたが、フォルマントの抽出結果自体に大きな違いはありませんでした。この経験から、最低限のマイク性能があれば、試作レベルのフォルマント分析には十分対応可能だと感じました。
使用ライブラリと処理フロー
- sounddevice:音声のリアルタイム録音
- parselmouth:Praatベースの音声分析ライブラリ(フォルマント抽出)
- matplotlib:フォルマントプロットの可視化
フォルマントとは?
フォルマントとは、音声スペクトルの中で強調される周波数帯域のことで、**母音の識別に大きく関係するF1(第1フォルマント)とF2(第2フォルマント)**がよく用いられます。
- F1:口の開き具合に関係(開口大 → F1高い)
- F2:舌の前後位置に関係(舌が前 → F2高い)
これらを観察することで、発音運動の特徴を数値的に間接評価できる可能性があります。
フォルマント分析の難しさと工夫
短時間音声では不安定になる
最初に作成したプログラムでは、フォルマント分析に使用するデータブロックが短すぎて推定が安定せず、グラフが大きく揺れ動くという課題がありました。これは、同じように発声しているつもりでも微妙な声の揺らぎや強さの違いがあり、結果に影響を与えていたためです。
分析の安定化と視覚的補助
この問題に対しては、以下のような対策を講じました:
- 分析単位を約0.25秒に延長
- 過去データとの移動平均を描画
「お」の音声入力で第2フォルマントが不安定
試作中、とくに「お」の音で第2フォルマント(F2)の検出が不安定になることが多く見られました。
生音声をフーリエ変換し、パワースペクトルを観察してみたところ、本来800〜1000Hzに出るはずのF2のピークを検出できず、それより高い帯域を誤ってF2として検出しているケースが見られました。
実際の母音ごとのF1/F2の平均値は以下の通りです:
母音 | 男性(Hz) | 女性(Hz) |
あ | 790 / 1180 | 950 / 1450 |
い | 250 / 2300 | 290 / 2930 |
う | 340 / 1180 | 400 / 1430 |
え | 460 / 2060 | 590 / 2430 |
お | 500 / 800 | 610 / 950 |
※引用:言語聴覚士のための運動障害性構音障害学
「お」ではF1とF2が接近しており、ピークの識別が難しく、誤検出しやすい構造であることがわかります。
この部分についてはまだ対策が立てられていません。今後手を加えていきたい部分です。
リハビリでの活用可能性
このアプリには、以下のような臨床的な応用の可能性が考えられます
- 介入効果の可視化
リハビリ前後でのフォルマントの変化を数値で確認することで、進捗を患者さんと共有しやすくなります。 - 舌の位置を間接的にフィードバック
患者自身が気づきにくい発音の癖(舌の前方化、開口不足など)を、グラフとして提示することで自覚を促すことができます。
現状の限界点
ただし、いくつかの課題や限界も明確になっています:
- 背景ノイズに弱い:静かな環境でないと解析精度が下がる
- 声の低い男性の「い」と「え」がグラフ上で距離が近く区別しにくい
- 「お」の音声入力で第2フォルマントが不安定となり易い
- 嗄声・失調性構音障害などには不向きな可能性
→ 周波数成分が安定せず、ピークが現れにくいため
これらの特性から、現段階では診断用途ではなく、補助的な観察ツールとして活用するのが適切です。
今後の展望
今後は以下のような方向での拡張を検討しています:
- Webアプリ化によるアクセス性向上
現在のコードではPythonがインストールされているパソコンでないと使うことが出来ません。誰でも簡単に使用できるようにすることで、より多くの臨床現場で使ってもらえる可能性があります。
サンプルコート
参考までに、今回のコードを記載しておきます。
Python・ライブラリのバージョンは以下の通りです。
Python 3.9.23
sounddevice 0.5.2
numpy 1.26.4
matplotlib 3.9.4
praat-parselmouth 0.4.3
import sounddevice as sd
import numpy as np
import matplotlib.pyplot as plt
import parselmouth
import math
# === 設定 ===
FS = 44100 # サンプリング周波数
CHUNK = 11000 # 約0.25秒分
DEVICE = None # 既定デバイスを使用
def is_valid(value):
return value is not None and not math.isnan(value)
def get_formants(audio, sr=FS):
snd = parselmouth.Sound(audio, sampling_frequency=sr)
duration = snd.get_total_duration()
formant = snd.to_formant_burg()
center_time = duration / 2.0
f1 = formant.get_value_at_time(1, center_time)
f2 = formant.get_value_at_time(2, center_time)
return f1, f2
# 最新と前回のF1,F2を保持
latest_formant = {"f1": None, "f2": None}
prev_formant = {"f1": None, "f2": None}
# === 標準データ(男性・女性あいうえお) ===
reference_points = [
("man_a", 790, 1180),
("man_i", 250, 2300),
("man_u", 340, 1180),
("man_e", 460, 2060),
("man_o", 500, 800),
("woman_a", 950, 1450),
("woman_i", 290, 2930),
("woman_u", 400, 1430),
("woman_e", 590, 2430),
("woman_o", 610, 950),
]
def audio_callback(indata, frames, time, status):
if status:
print(status)
mono = indata[:, 0]
f1, f2 = get_formants(mono)
if is_valid(f1) and is_valid(f2):
# 最新値を更新する前に前回値を保存
if latest_formant["f1"] is not None and latest_formant["f2"] is not None:
prev_formant["f1"] = latest_formant["f1"]
prev_formant["f2"] = latest_formant["f2"]
latest_formant["f1"] = f1
latest_formant["f2"] = f2
# === Matplotlib準備 ===
plt.ion()
fig, ax = plt.subplots()
# F1は通常低い→高い、F2は高い→低いが多いので、見やすさに合わせて軸を反転するかも
ax.set_xlim(200, 1200) # F1軸
ax.set_ylim(500, 3000) # F2軸
ax.set_xlabel("F1 (Hz)")
ax.set_ylabel("F2 (Hz)")
# 標準データをプロット(男性:青、女性:オレンジ)
for label, f1, f2 in reference_points:
color = "orange" if label.startswith("w") else "blue"
ax.plot(f1, f2, 'o', color=color, markersize=6)
ax.text(f1 + 10, f2 + 10, label, fontsize=9, color=color)
# リアルタイムの現在位置
point, = ax.plot([], [], 'ro', markersize=10)
# === 音声ストリーム開始 ===
stream = sd.InputStream(callback=audio_callback,
channels=1,
samplerate=FS,
blocksize=CHUNK,
device=DEVICE)
stream.start()
print("マイク入力をリアルタイム処理中... Ctrl+Cで終了")
try:
while True:
# 両方の値が揃っているときだけ平均を計算
if (latest_formant["f1"] is not None and prev_formant["f1"] is not None):
avg_f1 = (latest_formant["f1"] + prev_formant["f1"]) / 2.0
avg_f2 = (latest_formant["f2"] + prev_formant["f2"]) / 2.0
# 平均値でプロット更新
point.set_xdata([avg_f1])
point.set_ydata([avg_f2])
plt.draw()
else:
# 最初の1回だけは最新値をそのまま表示
if latest_formant["f1"] is not None and prev_formant["f1"] is None:
point.set_xdata([latest_formant["f1"]])
point.set_ydata([latest_formant["f2"]])
plt.draw()
plt.pause(0.05)
except KeyboardInterrupt:
print("終了します")
finally:
stream.stop()
stream.close()
コメント