librosa による音声スペクトログラムと CREPE によるF0時間変化(ソースコードと実行結果)

Python開発環境,ライブラリ類

ここでは、最低限の事前準備について説明する。機械学習や深層学習を行う場合は、NVIDIA CUDA、Visual Studio、Cursorなどを追加でインストールすると便利である。これらについては別ページ https://www.kkaneko.jp/cc/dev/aiassist.htmlで詳しく解説しているので、必要に応じて参照してください。

Python 3.12 のインストール

インストール済みの場合は実行不要。

管理者権限でコマンドプロンプトを起動(手順:Windowsキーまたはスタートメニュー > cmd と入力 > 右クリック > 「管理者として実行」)し、以下を実行する。管理者権限は、wingetの--scope machineオプションでシステム全体にソフトウェアをインストールするために必要である。

REM Python をシステム領域にインストール
winget install --scope machine --id Python.Python.3.12 -e --silent
REM Python のパス設定
set "PYTHON_PATH=C:\Program Files\Python312"
set "PYTHON_SCRIPTS_PATH=C:\Program Files\Python312\Scripts"
echo "%PATH%" | find /i "%PYTHON_PATH%" >nul
if errorlevel 1 setx PATH "%PATH%;%PYTHON_PATH%" /M >nul
echo "%PATH%" | find /i "%PYTHON_SCRIPTS_PATH%" >nul
if errorlevel 1 setx PATH "%PATH%;%PYTHON_SCRIPTS_PATH%" /M >nul

関連する外部ページ

Python の公式ページ: https://www.python.org/

AI エディタ Windsurf のインストール

Pythonプログラムの編集・実行には、AI エディタの利用を推奨する。ここでは,Windsurfのインストールを説明する。

管理者権限でコマンドプロンプトを起動(手順:Windowsキーまたはスタートメニュー > cmd と入力 > 右クリック > 「管理者として実行」)し、以下を実行して、Windsurfをシステム全体にインストールする。管理者権限は、wingetの--scope machineオプションでシステム全体にソフトウェアをインストールするために必要となる。

winget install --scope machine Codeium.Windsurf -e --silent

関連する外部ページ

Windsurf の公式ページ: https://windsurf.com/

必要なライブラリのインストール

コマンドプロンプトを管理者として実行(手順:Windowsキーまたはスタートメニュー > cmd と入力 > 右クリック > 「管理者として実行」)し、以下を実行する


pip install crepe librosa matplotlib numpy scipy sounddevice japanize-matplotlib tensorflow

librosa による音声スペクトログラムと CREPE によるF0時間変化プログラム

概要

このプログラムは音声から周波数スペクトログラムを計算し、深層学習モデルCREPEを用いて基本周波数(F0)を推定する。音声の物理的特性を数値化し、時間変化を可視化する。

主要技術

参考文献

[1] Kim, J. W., Salamon, J., Li, P., & Bello, J. P. (2018). CREPE: A Convolutional Representation for Pitch Estimation. In 2018 IEEE International Conference on Acoustics, Speech and Signal Processing (ICASSP) (pp. 161-165). IEEE.

[2] McFee, B., Raffel, C., Liang, D., Ellis, D. P., McVicar, M., Battenberg, E., & Nieto, O. (2015). librosa: Audio and music signal analysis in python. In Proceedings of the 14th python in science conference (Vol. 8, pp. 18-25).


# プログラム名: librosa による音声スペクトログラムと CREPE による基本周波数(F0)時間変化解析プログラム
# 特徴技術名: CREPE (Convolutional Representation for Pitch Estimation)
# 出典: Kim, J. W., Salamon, J., Li, P., & Bello, J. P. (2018). CREPE: A convolutional representation for pitch estimation. In 2018 IEEE International Conference on Acoustics, Speech and Signal Processing (ICASSP) (pp. 161-165). IEEE. doi:10.1109/ICASSP.2018.8461329
# 特徴機能: 時間領域音声波形からの直接的ピッチ推定。深層畳み込みニューラルネットワークが音声波形を直接入力として受け取り、前処理なしに高精度な基本周波数推定を実現。従来の周波数領域解析手法と比較してノイズ耐性が高く、複雑な音響環境でも安定した性能を発揮
# 学習済みモデル: CREPE tiny/small/medium/large/full モデル(容量:tiny-0.9MB、small-1.8MB、medium-3.3MB、large-5.9MB、full-12MB)。GitHub公式リポジトリ(https://github.com/marl/crepe)から提供。TensorFlow SavedModel形式で、初回実行時にPython crepeライブラリを通じて自動ダウンロード
# 方式設計:
#   - 関連利用技術: librosa(音声読み込み・STFT・スペクトログラム変換)、matplotlib(可視化)、numpy(数値計算)、scipy(信号処理・フィルタリング)、sounddevice(音声再生)、tkinter(ファイル選択GUI)、urllib(サンプルファイルダウンロード)
#   - 入力と出力: 入力: 音声ファイル(ユーザは「0:音声ファイル,1:サンプル音声」のメニューで選択.0:音声ファイルの場合はtkinterでファイル選択.1の場合はhttps://github.com/librosa/librosa-test-data/raw/main/test1_44100.wavを使用)、出力: スペクトログラムとF0軌跡の重ね合わせ表示、F0統計情報のコンソール出力、1秒間隔でのF0値表示、result.txtファイルへの結果保存、音声再生機能
#   - 処理手順: 1.音声ファイル読み込み(librosa.load) 2.ハイパスフィルタ適用(scipy.butter, 50Hz以下除去) 3.音声信号正規化 4.STFT計算(librosa.stft) 5.スペクトログラム変換(amplitude_to_db) 6.CREPE F0推定(crepe.predict, Viterbiデコーディング適用) 7.信頼度フィルタリング 8.メディアンフィルタによるスムージング 9.F0統計計算 10.可視化・出力・保存
#   - 前処理、後処理: 前処理: バターワースハイパスフィルタ(5次、50Hz以下除去)による低域ノイズ除去、最大振幅による正規化、後処理: 5点メディアンフィルタによるF0軌跡平滑化、信頼度0.5未満の区間をNaN設定による無音区間処理
#   - 追加処理: 統計情報算出(平均・標準偏差・最小・最大・中央値・範囲)、1秒間隔サンプリングによる時系列F0値出力、結果の自動ファイル保存、処理日時記録、音声再生機能
#   - 調整を必要とする設定値: CREPE_MODEL_CAPACITY(モデルサイズ選択、精度と計算時間のトレードオフ)、CREPE_STEP_SIZE(時間解像度、デフォルト10ms)、HOP_LENGTH(STFT解像度、デフォルト512サンプル)、CONF_THRESH(無音判定閾値、デフォルト0.5)、MED_FILTER_SIZE(スムージング強度、デフォルト5)、HIGHPASS_CUTOFF(ノイズ除去周波数、デフォルト50Hz)
# 将来方策: 音声長に応じたHOP_LENGTHの動的調整機能実装(短時間音声では高解像度、長時間音声では処理効率を優先した自動パラメータ調整)
# その他の重要事項: Windows環境でのNumba/librosaキャッシュディレクトリ問題対応のため環境変数設定実装。CREPEモデルは初回のみダウンロード、以降はローカルキャッシュ使用。処理結果は日時付きでresult.txtに自動保存
# 前準備: pip install crepe librosa matplotlib numpy scipy sounddevice japanize-matplotlib tensorflow

import os
# Windowsでのキャッシュディレクトリ設定
os.environ['NUMBA_CACHE_DIR'] = os.path.join(os.path.expanduser('~'), '.numba_cache')
os.environ['LIBROSA_CACHE_DIR'] = os.path.join(os.path.expanduser('~'), '.librosa_cache')

import crepe
import librosa
import matplotlib.pyplot as plt
import japanize_matplotlib
import numpy as np
from scipy.signal import medfilt, butter, filtfilt
import tkinter as tk
from tkinter import filedialog
import urllib.request
import sounddevice as sd
from datetime import datetime

# 設定値(必要に応じて調整してください)
CREPE_MODEL_CAPACITY = 'tiny'  # CREPEモデルサイズ: 'tiny', 'small', 'medium', 'large', 'full'
CREPE_STEP_SIZE = 10  # CREPEの時間ステップ(ミリ秒)
HOP_LENGTH = 512  # 時間解像度(サンプル数):小さいほど時間解像度が高い
N_FFT = 2048  # FFTウィンドウサイズ
WINDOW_TYPE = 'hann'  # 窓関数の種類
CONF_THRESH = 0.5  # 無音判定の信頼度閾値(0.0-1.0)
MED_FILTER_SIZE = 5  # メディアンフィルタのカーネルサイズ
SAMPLE_AUDIO_URL = 'https://github.com/librosa/librosa-test-data/raw/main/test1_44100.wav'
SPECTROGRAM_MAX_FREQ = 2000  # スペクトログラム表示の最大周波数(Hz)
F0_MAX_FREQ = 800  # F0表示の最大周波数(Hz)
HIGHPASS_CUTOFF = 50  # ハイパスフィルタのカットオフ周波数(Hz)
FILTER_ORDER = 5  # バターワースフィルタの次数
RESULT_FILENAME = 'result.txt'  # 結果保存ファイル名

# 表示関連定数
FIGURE_SIZE_WIDTH = 12
FIGURE_SIZE_HEIGHT = 6
LINE_WIDTH = 4
# 統計表示関連定数
PRINT_INTERVAL_SEC = 1  # 1秒間隔表示用

# ファイル選択関連
AUDIO_FILE_TYPES = [('Audio Files', '*.wav *.mp3 *.flac *.m4a *.ogg')]
FILE_DIALOG_TITLE = '音声ファイルを選択'
TEMP_FILENAME = 'sample_audio.wav'

# プログラムの説明表示
print('=== 音声スペクトログラムとF0時間変化表示プログラム ===')
print('このプログラムは音声ファイルから以下の処理を行います:')
print('1. スペクトログラム(周波数成分の時間変化)の計算')
print('2. 基本周波数(F0/ピッチ)の推定')
print('3. スペクトログラムとF0の重ね合わせ表示')
print('4. F0統計情報の計算と表示')
print('5. 結果をresult.txtに自動保存')
print()

# 音声ファイル選択
print('0: 音声ファイル')
print('1: サンプル音声')

choice = input('選択: ')
temp_file = None
result_data = []  # 結果保存用

try:
    if choice == '0':
        root = tk.Tk()
        root.withdraw()
        audio_path = filedialog.askopenfilename(
            title=FILE_DIALOG_TITLE,
            filetypes=AUDIO_FILE_TYPES
        )
        root.destroy()
        if not audio_path:
            print('ファイルが選択されませんでした。')
            exit()
        print(f'選択されたファイル: {audio_path}')
    elif choice == '1':
        # サンプル音声ダウンロード
        url = SAMPLE_AUDIO_URL
        filename = TEMP_FILENAME
        try:
            print('サンプル音声をダウンロードしています...')
            urllib.request.urlretrieve(url, filename)
            temp_file = filename
            audio_path = filename
            print('サンプル音声のダウンロードが完了しました。')
        except Exception as e:
            print(f'音声のダウンロードに失敗しました: {url}')
            print(f'エラー: {e}')
            exit()
    else:
        print('無効な選択です')
        exit()

    # 結果データの記録開始
    result_data.append(f'処理日時: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}')
    result_data.append(f'音声ファイル: {audio_path}')
    result_data.append('')

    # 音声ファイル読み込み
    print('音声ファイル読み込み中...')
    print(f'ファイルパス: {audio_path}')

    try:
        audio, sr = librosa.load(audio_path, sr=None, mono=True)
        print(f'読み込み成功: サンプリングレート={sr}Hz, 長さ={len(audio)/sr:.1f}秒')
        result_data.append(f'サンプリングレート: {sr} Hz')
        result_data.append(f'音声長: {len(audio)/sr:.1f} 秒')
        result_data.append('')
    except Exception as e:
        print(f'音声ファイルの読み込みに失敗しました: {e}')
        exit()

    # 音声信号の前処理
    print('音声信号前処理中...')

    # ハイパスフィルタの適用(50Hz以下をカット)
    nyquist = sr / 2
    if HIGHPASS_CUTOFF >= nyquist:
        print(f'警告: ハイパスフィルタのカットオフ周波数({HIGHPASS_CUTOFF}Hz)がナイキスト周波数({nyquist}Hz)以上です。フィルタを適用しません。')
        audio_filt = audio
    else:
        normal_cutoff = HIGHPASS_CUTOFF / nyquist
        b, a = butter(FILTER_ORDER, normal_cutoff, btype='high', analog=False)
        audio_filt = filtfilt(b, a, audio)

    # 正規化
    if np.max(np.abs(audio_filt)) > 0:
        audio_norm = audio_filt / np.max(np.abs(audio_filt))
    else:
        print('警告: 音声信号の振幅が0です。')
        audio_norm = audio_filt

    # スペクトログラム計算
    print('スペクトログラム計算中...')

    # librosaによるSTFT計算
    D = librosa.stft(audio_norm, n_fft=N_FFT, hop_length=HOP_LENGTH, window=WINDOW_TYPE)
    S_db = librosa.amplitude_to_db(np.abs(D), ref=np.max)

    # CREPEによるF0推定
    print('F0推定中...')
    print(f'音声データ: shape={audio_norm.shape}, sr={sr}')
    try:
        time, frequency, confidence, activation = crepe.predict(
            audio_norm, sr, viterbi=True,
            model_capacity=CREPE_MODEL_CAPACITY,
            step_size=CREPE_STEP_SIZE
        )
        print('F0推定完了')
    except Exception as e:
        print(f'CREPE F0推定に失敗しました: {e}')
        exit()

    # 無音区間のF0をNaNに設定(グラフで不連続表示)
    frequency[confidence < CONF_THRESH] = np.nan

    # F0のスムージング(メディアンフィルタ、NaNは保持)
    freq_smooth = frequency.copy()
    valid_mask = ~np.isnan(frequency)
    if np.any(valid_mask):
        # 有効な値のみでメディアンフィルタを適用
        valid_indices = np.where(valid_mask)[0]
        for i in valid_indices:
            start = max(0, i - MED_FILTER_SIZE // 2)
            end = min(len(frequency), i + MED_FILTER_SIZE // 2 + 1)
            window_data = frequency[start:end]
            valid_window = window_data[~np.isnan(window_data)]
            if len(valid_window) > 0:
                freq_smooth[i] = np.median(valid_window)

    # F0統計情報の計算(簡素化)
    valid_f0 = freq_smooth[~np.isnan(freq_smooth)]
    if len(valid_f0) > 0:
        stats = {
            '平均F0': np.mean(valid_f0),
            '標準偏差': np.std(valid_f0),
            '最小F0': np.min(valid_f0),
            '最大F0': np.max(valid_f0),
            '中央値': np.median(valid_f0)
        }
        stats['F0範囲'] = stats['最大F0'] - stats['最小F0']
    else:
        stats = {key: 0 for key in ['平均F0', '標準偏差', '最小F0', '最大F0', '中央値', 'F0範囲']}

    # グラフ描画
    fig, ax1 = plt.subplots(figsize=(FIGURE_SIZE_WIDTH, FIGURE_SIZE_HEIGHT))

    # スペクトログラム表示
    freqs = librosa.fft_frequencies(sr=sr, n_fft=N_FFT)
    times = librosa.frames_to_time(np.arange(S_db.shape[1]), sr=sr, hop_length=HOP_LENGTH)
    img = ax1.pcolormesh(times, freqs, S_db, shading='auto', cmap='viridis')
    ax1.set_ylim(0, SPECTROGRAM_MAX_FREQ)

    # F0グラフの重ね合わせ(水平線分として表示)
    ax2 = ax1.twinx()

    # 各測定点で水平線分を描画
    step_size_sec = CREPE_STEP_SIZE / 1000.0  # ミリ秒を秒に変換
    for i in range(len(time)):
        if not np.isnan(freq_smooth[i]):
            # 各測定点を中心とした水平線分
            x_start = time[i] - step_size_sec / 2
            x_end = time[i] + step_size_sec / 2
            ax2.plot([x_start, x_end], [freq_smooth[i], freq_smooth[i]],
                    'r-', linewidth=LINE_WIDTH)

    ax2.set_ylabel('Frequency (Hz)', color='r')
    ax2.tick_params(axis='y', labelcolor='r')
    ax2.set_ylim(0, F0_MAX_FREQ)

    # タイトルとラベル
    ax1.set_xlabel('Time (s)')
    ax1.set_ylabel('Frequency (Hz)')
    plt.title('Spectrogram with F0 Contour')

    # カラーバー追加
    cbar = plt.colorbar(img, ax=ax1, format='%+2.0f dB')

    # F0統計情報の出力と記録(簡素化)
    duration = len(audio) / sr
    print(f'\n音声長: {duration:.1f}秒')
    print('\nF0統計情報:')

    result_data.append('F0統計情報:')
    for key, value in stats.items():
        print(f'{key}: {value:.1f} Hz')
        result_data.append(f'{key}: {value:.1f} Hz')
    result_data.append('')

    # 1秒ごとのF0値出力と記録
    print('\n1秒ごとのF0値:')
    result_data.append('1秒ごとのF0値:')
    for t in range(0, int(duration) + 1, PRINT_INTERVAL_SEC):
        if t < len(time):
            idx = np.argmin(np.abs(time - t))
            if not np.isnan(freq_smooth[idx]):
                print(f'{t:3d}秒: {freq_smooth[idx]:.1f} Hz')
                result_data.append(f'{t:3d}秒: {freq_smooth[idx]:.1f} Hz')
            else:
                print(f'{t:3d}秒: 無音')
                result_data.append(f'{t:3d}秒: 無音')

    # 結果をファイルに保存
    try:
        with open(RESULT_FILENAME, 'w', encoding='utf-8') as f:
            f.write('\n'.join(result_data))
        print(f'\n結果を {RESULT_FILENAME} に保存しました')
    except Exception as e:
        print(f'結果ファイルの保存に失敗しました: {e}')

    print('保存内容:')
    print('- 処理日時と音声ファイル情報')
    print('- F0統計情報(平均、標準偏差、最小、最大、中央値、範囲)')
    print('- 1秒ごとのF0値')

    plt.tight_layout()
    plt.show()

    # 音声再生
    print('\n音声を再生しますか?')
    print('y: 再生')
    print('n: 終了')
    play_choice = input('選択: ')

    if play_choice.lower() == 'y':
        print('音声再生中... (Ctrl+Cで中断)')
        try:
            sd.play(audio, sr)
            sd.wait()
            print('再生完了')
        except KeyboardInterrupt:
            sd.stop()
            print('\n再生を中断しました')
        except Exception as e:
            print(f'音声再生エラー: {e}')

except Exception as e:
    print(f'予期しないエラーが発生しました: {e}')
finally:
    # 一時ファイルの削除
    if temp_file and os.path.exists(temp_file):
        try:
            os.remove(temp_file)
            print('一時ファイルを削除しました。')
        except OSError:
            print('一時ファイルの削除に失敗しました。')