Last active
December 1, 2022 06:03
-
-
Save tam17aki/ca324af1c4c727561b5512e42aab003b to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
"""PySimpleGUIにより音声を録音し、音声の分析合成を実施する. | |
波形とスペクトログラムもプロット可能. | |
""" | |
import librosa # 音声分析のライブラリ | |
import matplotlib.pyplot as plt # グラフ描画のライブラリ | |
import numpy as np | |
import PySimpleGUI as sg # GUI構築のライブラリ | |
import pysptk # 音声分析合成のライブラリ | |
import sounddevice as sd # 録音・再生系のライブラリ | |
import soundfile as sf # 読み込み・書き出しのライブラリ | |
import speech_recognition as sr # 音声認識のライブラリ | |
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg | |
from pysptk.synthesis import MLSADF, Synthesizer # 音声分析合成のライブラリ | |
# Windowのサイズ (横, 縦) 単位ピクセル | |
WINDOW_SIZE = (1000, 650) | |
# 出力先の音声ファイル名 | |
OUTPUT_FILE = "/tmp/record.wav" | |
CHUNK = 256 # チャンクサイズ | |
SAMPLE_RATE = 16000 # サンプリング周波数 | |
N_CHANNEL = 1 # チャンネル数 モノラルは1, ステレオは2 | |
DURATION = 3 # 収録秒数 | |
BUFFER = 0.2 | |
# 音声の分析条件(デフォルト) | |
FRAME_LENGTH = 1024 # フレーム長 (point) | |
FFT_LENGTH = FRAME_LENGTH # FFT長 (point) | |
HOP_LENGTH = 80 # フレームシフト (point) | |
MIN_F0 = 60 # 基本周波数の最小値 (Hz) | |
MAX_F0 = 240 # 基本周波数の最大値 (Hz) | |
ORDER = 25 # メルケプストラムの分析次数 | |
ALPHA = 0.41 # 周波数ワーピングのパラメタ | |
# Canvasオブジェクト生成 | |
CANVAS = sg.Canvas() # PySimpleGUIのCanvasオブジェクト | |
# PySimpleGUI 初期化 | |
FONT = "Any 16" | |
sg.theme("SystemDefault1") | |
# ボタンとスライダの設定 | |
TEXT_CONFIG = {"pitch": None, "alpha": None, "tempo": None} | |
SLIDER_CONFIG = {"pitch": None, "alpha": None, "tempo": None} | |
BUTTON_CONFIG = {"pitch": None, "alpha": None, "tempo": None} | |
INTEXT_CONFIG = {"pitch": None, "alpha": None, "tempo": None} | |
TEXT_CONFIG["pitch"] = sg.Text("声の高さ", font=("Ricty", 15)) | |
SLIDER_CONFIG["pitch"] = sg.Slider( | |
range=(0.5, 2.0), | |
default_value=1.0, | |
resolution=0.1, | |
orientation="h", | |
size=(35, None), | |
pad=((6, 0), (0, 10)), | |
key="-PITCH-", | |
enable_events=True, | |
) | |
BUTTON_CONFIG["pitch"] = sg.Button("設定", font=FONT, key="-PITCH_SET-") | |
INTEXT_CONFIG["pitch"] = sg.InputText( | |
default_text="1.0", | |
size=(4, 1), | |
font="Any 14", | |
key="pitch_val", | |
justification="center", | |
) | |
TEXT_CONFIG["alpha"] = sg.Text("声色", font=("Ricty", 15)) | |
SLIDER_CONFIG["alpha"] = sg.Slider( | |
range=(0.0, 1.0), | |
default_value=ALPHA, | |
resolution=0.01, | |
orientation="h", | |
size=(35, None), | |
pad=((36, 0), (0, 10)), | |
key="-ALPHA-", | |
enable_events=True, | |
) | |
BUTTON_CONFIG["alpha"] = sg.Button("設定", font=FONT, key="-ALPHA_SET-") | |
INTEXT_CONFIG["alpha"] = sg.InputText( | |
default_text="{}".format(ALPHA), | |
size=(4, 1), | |
font="Any 14", | |
key="alpha_val", | |
justification="center", | |
) | |
TEXT_CONFIG["tempo"] = sg.Text("話速", font=("Ricty", 15)) | |
SLIDER_CONFIG["tempo"] = sg.Slider( | |
range=(0.5, 2.0), | |
default_value=1.0, | |
resolution=0.1, | |
orientation="h", | |
size=(35, None), | |
pad=((36, 0), (0, 10)), | |
key="-TEMPO-", | |
enable_events=True, | |
) | |
BUTTON_CONFIG["tempo"] = sg.Button("設定", font=FONT, key="-RATE_SET-") | |
INTEXT_CONFIG["tempo"] = sg.InputText( | |
default_text="1.0", | |
size=(4, 1), | |
font="Any 14", | |
key="tempo_val", | |
justification="center", | |
) | |
# ボタンとスライダをフレームにまとめる→Frameの戻り値は一つのレイアウト | |
FRAME_ANASYN = sg.Frame( | |
layout=[ | |
[ | |
TEXT_CONFIG["pitch"], | |
SLIDER_CONFIG["pitch"], | |
INTEXT_CONFIG["pitch"], | |
BUTTON_CONFIG["pitch"], | |
], | |
[ | |
TEXT_CONFIG["alpha"], | |
SLIDER_CONFIG["alpha"], | |
INTEXT_CONFIG["alpha"], | |
BUTTON_CONFIG["alpha"], | |
], | |
[ | |
TEXT_CONFIG["tempo"], | |
SLIDER_CONFIG["tempo"], | |
INTEXT_CONFIG["tempo"], | |
BUTTON_CONFIG["tempo"], | |
], | |
[ | |
sg.Button("分析再合成", font=FONT, key="-ANASYN-"), | |
sg.Button("分析再合成音を聞く", font=FONT, key="-PLAY_ANASYN-"), | |
], | |
], | |
title="声の高さ・声色・話速を調整", | |
font=("Ricty", 20), | |
element_justification="left", | |
) | |
# 各パーツのレイアウトを設定 | |
# ウィンドウの下側に向かって、先頭から順に配置される | |
LAYOUT = [ | |
[ | |
sg.Text( | |
"早口言葉を{}秒間:赤巻紙 青巻紙 黄巻紙".format(DURATION), | |
font=("Ricty", 22), | |
text_color="#000000", | |
background_color="#839496", | |
), | |
], | |
[ | |
sg.Button("再生", font=FONT, key="-PLAY-"), | |
sg.Button("録音", font=FONT, key="-REC-"), | |
sg.Button("停止", font=FONT, key="-STOP-"), | |
sg.Button("波形表示", font=FONT, key="-PLTWAV-"), | |
sg.Button("スペクトログラム表示", font=FONT, key="-PLTSPEC-"), | |
sg.Button("保存", font=FONT, key="-SAVE-"), | |
sg.Button("認識", font=FONT, key="-RECOG-"), | |
sg.Button("終了", font=FONT, key="-EXIT-"), | |
], | |
[ | |
sg.FileBrowse( | |
"ファイルを開いて波形表示", | |
font=FONT, | |
key="-FILES-", | |
target="-FILES-", | |
file_types=((("WAVEファイル", "*.wav"),)), | |
enable_events=True, | |
), | |
sg.Text( | |
"ここに音声認識結果が表示されます", | |
font=("Ricty", 22), | |
text_color="#000000", | |
background_color="#eee8d5", | |
size=(40, 1), | |
key="-RECOG_TEXT-", | |
), | |
], | |
# 描画領域 | |
[CANVAS], | |
# 分析再合成まわり | |
[FRAME_ANASYN], | |
] | |
# 各関数からアクセスするグローバル変数 | |
VARS = { | |
"window": sg.Window("音声分析のGUIサンプルプログラム", LAYOUT, finalize=True, size=WINDOW_SIZE), | |
"audio": None, # 収録済み音声(もしくはロードした音声) | |
"anasyn": None, # 分析再合成音 | |
"alpha": ALPHA, | |
} | |
# Figure領域の確保 (Windowオブジェクト作成後) | |
FIGURE = plt.figure(figsize=(9, 3)) | |
AXES = FIGURE.add_subplot(1, 1, 1) | |
TK_CANVAS = CANVAS.TKCanvas | |
FAG = FigureCanvasTkAgg(FIGURE, TK_CANVAS) | |
def load_wav(file_name): | |
"""WAVファイルをロードする""" | |
audio, _ = sf.read(file_name) | |
VARS["audio"] = audio | |
# 保存しておく | |
sf.write( | |
file=OUTPUT_FILE, | |
data=audio, | |
samplerate=SAMPLE_RATE, | |
format="WAV", | |
subtype="PCM_16", | |
) | |
def save_wav(file_name): | |
"""WAVファイルを保存する""" | |
if VARS["audio"] is None or len(VARS["audio"]) == 0: | |
raise ValueError("Audio data must not be empty!") | |
# 振幅の正規化 | |
audio = VARS["audio"] / np.abs(VARS["audio"]).max() | |
audio = audio * (np.iinfo(np.int16).max / 2 - 1) | |
audio = audio.astype(np.int16) | |
sf.write( | |
file=file_name, | |
data=audio, | |
samplerate=SAMPLE_RATE, | |
format="WAV", | |
subtype="PCM_16", | |
) | |
def play_wav(): | |
"""WAVを再生する""" | |
if VARS["audio"] is None or len(VARS["audio"]) == 0: | |
raise ValueError("Audio data must not be empty!") | |
# 振幅の正規化 | |
audio = VARS["audio"] / np.abs(VARS["audio"]).max() | |
audio = audio * (np.iinfo(np.int16).max / 2 - 1) | |
audio = audio.astype(np.int16) | |
sd.play(VARS["audio"], SAMPLE_RATE) | |
# 再生は非同期に行われるので、明示的にsleepさせる | |
sd.sleep(int(1000 * len(VARS["audio"]) / SAMPLE_RATE)) | |
def play_stop(): | |
"""WAV再生を停止する""" | |
if VARS["audio"] is None or len(VARS["audio"]) == 0: | |
raise ValueError("Audio data must not be empty!") | |
sd.stop() | |
def listen(): | |
"""リッスンする関数""" | |
# 音声録音実行 | |
VARS["audio"] = sd.rec( | |
int(DURATION * SAMPLE_RATE), samplerate=SAMPLE_RATE, channels=N_CHANNEL | |
) | |
sd.wait() | |
# 1ch(モノラル)のnumpy配列に変換 | |
VARS["audio"] = VARS["audio"][int(BUFFER * SAMPLE_RATE) :, 0] | |
def recog(): | |
"""音声認識する関数(音声データをテキストに変換)""" | |
r = sr.Recognizer() | |
with sr.AudioFile(OUTPUT_FILE) as source: # ファイルから音声取得 | |
audio = r.listen(source) | |
try: | |
text = r.recognize_google(audio, language="ja-JP") | |
VARS["window"]["-RECOG_TEXT-"].Update(text) | |
except sr.UnknownValueError: | |
VARS["window"]["-RECOG_TEXT-"].Update("認識に失敗しました") | |
def plot_waveform(): | |
"""波形をプロットする関数""" | |
if VARS["audio"] is None or len(VARS["audio"]) == 0: | |
raise ValueError("Audio data must not be empty!") | |
audio = VARS["audio"] / np.abs(VARS["audio"]).max() | |
audio = audio * (np.iinfo(np.int16).max / 2 - 1) | |
audio = audio.astype(np.int16) | |
# 継続時間に等しい標本点の作成 | |
times = DURATION * (np.arange(0, len(VARS["audio"])) / len(VARS["audio"])) | |
# 波形プロット | |
AXES.cla() # plotのクリア | |
AXES.set_xlabel("Time (sec)") | |
AXES.set_ylabel("Amplitude") | |
AXES.plot(times, audio) | |
FIGURE.tight_layout() | |
# Canvasへ描画する | |
FAG.draw() # TkinterのCanvasに描画 | |
FAG.get_tk_widget().pack() # TkinterのCanvasをレイアウトに反映 | |
def plot_specgram(): | |
"""スペクトログラムを表示する関数""" | |
if VARS["audio"] is None or len(VARS["audio"]) == 0: | |
raise ValueError("Audio data must not be empty!") | |
audio = VARS["audio"] / np.abs(VARS["audio"]).max() | |
audio = audio * (np.iinfo(np.int16).max / 2 - 1) | |
audio = audio.astype(np.int16) | |
n_overlap = FFT_LENGTH - HOP_LENGTH # オーバーラップ幅 | |
# スペクトログラムをプロット | |
AXES.cla() # plotのクリア | |
AXES.specgram( | |
audio, | |
NFFT=FFT_LENGTH, | |
noverlap=n_overlap, | |
Fs=SAMPLE_RATE, | |
cmap="jet", | |
) | |
AXES.set_xlabel("Time (sec)") | |
AXES.set_ylabel("Frequency (Hz)") | |
FIGURE.tight_layout() | |
# Canvasへ描画する | |
FAG.draw() # TkinterのCanvasに描画 | |
FAG.get_tk_widget().pack() # TkinterのCanvasをレイアウトに反映 | |
def analysis_synthesis(pitch_shift=1.0, alpha=ALPHA, tempo=1.0): | |
"""音声の分析再合成 | |
Parameters | |
---------- | |
pitch_shift: float in (0.0, 1] (default=1.0) | |
ピッチシフト。声の高さを何倍にするかを指定。 | |
alpha: float in (0.0, 1]) (default=0.42) | |
声道長(喉の長さ)パラメータ。 | |
大人は喉が長い。子供は喉が短い。 | |
tempo: float in (0.0, 2) (default=1.0) | |
話速パラメータ。1.0は話速変更なし。 | |
0.5は話速が2倍、2.0は話速が半分に対応 | |
""" | |
hop_length = int(HOP_LENGTH * tempo) | |
# 音声の切り出しと窓掛け | |
frames = ( | |
librosa.util.frame( | |
VARS["audio"], frame_length=FRAME_LENGTH, hop_length=HOP_LENGTH | |
) | |
.astype(np.float64) | |
.T | |
) | |
frames *= pysptk.blackman(FRAME_LENGTH) # 窓掛け(ブラックマン窓) | |
# ピッチ抽出 | |
pitch = pysptk.swipe( | |
VARS["audio"].astype(np.float64), | |
fs=SAMPLE_RATE, | |
hopsize=HOP_LENGTH, | |
min=MIN_F0, | |
max=MAX_F0, | |
otype="pitch", | |
) | |
# 励振源信号(声帯音源)の生成 | |
source_excitation = pysptk.excite(pitch * (1.0 / pitch_shift), hop_length) | |
# メルケプストラム分析(=スペクトル包絡の抽出) | |
mc = pysptk.mcep(frames, ORDER, ALPHA) # このときのalphaはデフォルト値 | |
# メルケプストラム係数からMLSAディジタルフィルタ係数に変換 | |
mlsa_coef = pysptk.mc2b(mc, alpha) | |
# MLSAフィルタの作成 | |
synthesizer = Synthesizer(MLSADF(order=ORDER, alpha=alpha), hop_length) | |
# 音声の再合成 | |
VARS["anasyn"] = synthesizer.synthesis(source_excitation, mlsa_coef) | |
def play_anasyn(): | |
"""分析再合成音を再生する""" | |
if VARS["anasyn"] is None or len(VARS["anasyn"]) == 0: | |
raise ValueError("Audio data must not be empty!") | |
sd.play(VARS["anasyn"], SAMPLE_RATE) | |
sd.sleep(int(1000 * len(VARS["anasyn"]) / SAMPLE_RATE)) | |
def event_play_record(event): | |
"""再生・録音系のイベント処理""" | |
# 音声を再生 | |
if event == "-PLAY-": | |
play_wav() | |
# 再生を停止 | |
elif event == "-STOP-": | |
play_stop() | |
# 録音 | |
elif event == "-REC-": | |
listen() | |
save_wav(OUTPUT_FILE) | |
def event_plot_graph(event, values): | |
"""プロット系のイベント処理""" | |
# 波形プロット | |
if event == "-PLTWAV-": | |
if values["-FILES-"] != "": | |
load_wav(values["-FILES-"]) | |
plot_waveform() | |
# スペクトログラムプロット | |
elif event == "-PLTSPEC-": | |
if values["-FILES-"] != "": | |
load_wav(values["-FILES-"]) | |
plot_specgram() | |
# ファイルを開いて波形プロット | |
elif event == "-FILES-": | |
load_wav(values["-FILES-"]) | |
plot_waveform() | |
def event_anasyn(event, values): | |
"""分析合成系のイベント処理""" | |
# 音声の分析再合成 | |
if event == "-ANASYN-": | |
analysis_synthesis(values["-PITCH-"], values["-ALPHA-"], values["-TEMPO-"]) | |
# テキストボックスの値をスライダーに反映(ピッチ) | |
elif event == "-PITCH_SET-": # 「設定」ボタンを押す | |
VARS["window"]["-PITCH-"].Update(values["pitch_val"]) | |
# スライダーの値をテキストボックスに反映(ピッチ) | |
elif event == "-PITCH-": # スライダーを動かす | |
VARS["window"]["pitch_val"].Update(values["-PITCH-"]) | |
# テキストボックスの値をスライダーに反映(alpha) | |
elif event == "-ALPHA_SET-": | |
VARS["window"]["-ALPHA-"].Update(values["alpha_val"]) | |
# スライダーの値をテキストボックスに反映(alpha) | |
elif event == "-ALPHA-": # スライダーを動かす | |
VARS["window"]["alpha_val"].Update(values["-ALPHA-"]) | |
# テキストボックスの値をスライダーに反映(テンポ) | |
elif event == "-TEMPO_SET-": | |
VARS["window"]["-TEMPO-"].Update(values["tempo_val"]) | |
# スライダーの値をテキストボックスに反映(テンポ) | |
elif event == "-TEMPO-": # スライダーを動かす | |
VARS["window"]["tempo_val"].Update(values["-TEMPO-"]) | |
# 分析再合成を聞く | |
elif event == "-PLAY_ANASYN-": | |
play_anasyn() | |
def event_recog(event, values): | |
"""音声認識系のイベント処理""" | |
if event == "-RECOG-": | |
recog() | |
def finalize(): | |
"""終了処理""" | |
# Windowを閉じる | |
VARS["window"].close() | |
def mainloop(): | |
"""メインのループ""" | |
while True: # 無限ループにすることでGUIは起動しつづける | |
event, values = VARS["window"].read() # イベントと「値」を取得 | |
# windowを閉じるか 終了ボタンを押したら終了 | |
if event in (sg.WIN_CLOSED, "-EXIT-"): | |
finalize() | |
break | |
# 再生・録音系イベント | |
if event in ("-PLAY-", "-STOP-", "-REC-"): | |
event_play_record(event) | |
# プロット系イベント | |
elif event in ("-PLTWAV-", "-PLTSPEC-", "-FILES-"): | |
event_plot_graph(event, values) | |
# 分析再合成系イベント | |
elif event in ( | |
"-ANASYN-", | |
"-PITCH-", | |
"-ALPHA-", | |
"-TEMPO-", | |
"-PITCH_SET-", | |
"-ALPHA_SET-", | |
"-TEMPO_SET-", | |
"-PLAY_ANASYN-", | |
): | |
event_anasyn(event, values) | |
# 音声認識系イベント | |
elif event in ("-RECOG-"): | |
event_recog(event, values) | |
if __name__ == "__main__": | |
# GUI起動 | |
mainloop() | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment