🖥️はじめに
※この記事は「リアルタイム再生するボイスチェンジャーを作ろう!」の続きになります。
今回は、pythonのフレームワークの一つであるfletを使ったGUIによるリアルタイムボイスチェンジャーを作成したいと思います!
この記事では、fletの基本的な機能から実装までを紹介したいと思います。
なお、本GUIアプリケーションの動作環境はWindows向けに作られています。
⚠️免責事項
本記事の目的は声を加工して通話を楽しむことです。
声を偽装して他人を欺くなど、悪用は絶対におやめください。
本記事の内容を悪用して発生したいかなる損害にも、作者は一切の責任を負いません。
📚目次
- fletの機能
- 実装
- 最後に
🍋🟩fletの機能
fletのチュートリアル(カウンターアプリ)
プログラムの流れ
-
main(page)という関数で画面の中身を作る - Fletが
mainを呼び出してアプリ画面を表示 - ボタンを押すと、数字が変わって画面が更新される
import flet as ft
def main(page: ft.Page):
page.title = "Flet counter example"
page.vertical_alignment = ft.MainAxisAlignment.CENTER
txt_number = ft.TextField(value="0", text_align=ft.TextAlign.RIGHT, width=100)
def minus_click(e):
txt_number.value = str(int(txt_number.value) - 1)
page.update()
def plus_click(e):
txt_number.value = str(int(txt_number.value) + 1)
page.update()
page.add(
ft.Row(
[
ft.IconButton(ft.Icons.REMOVE, on_click=minus_click),
txt_number,
ft.IconButton(ft.Icons.ADD, on_click=plus_click),
],
alignment=ft.MainAxisAlignment.CENTER,
)
)
ft.app(main)
📚初心者向け解説
メイン画面を作る関数
def main(page: ft.Page):
👉 Fletはプログラム開始時にmainを呼んでpage (画面)を渡す。
👉 pageの中に、ボタンやテキストなどのUIを追加していく。
画面の設定
page.title = "Flet counter example"
page.vertical_alignment = ft.MainAxisAlignment.CENTER
👉 画面タイトルを設定
👉 中身を上下左右方向の中央に揃える
数字を表示するテキストボックス
txt_number = ft.TextField(value="0", text_align=ft.TextAlign.RIGHT, width=100)
👉 value="0":アプリケーション起動時の初期値が0
👉 text_align=ft.TextAlign.RIGHT:テキストボックス内の数字を右寄せにする
👉 width=100:幅が100px
マイナスボタンが押された時の処理
def minus_click(e):
txt_number.value = str(int(txt_number.value) - 1)
page.update()
👉 txt_number.value(文字列)をintに変換し、数値を1減らす
👉 page.update():新しい値を画面へ反映する時に必須(重要)
プラスボタンが押された時の処理
マイナスバタンを押したときの逆なので説明は割愛
ボタンと数字表示を横に並べて画面に追加
page.add(
ft.Row(
[
ft.IconButton(ft.Icons.REMOVE, on_click=minus_click),
txt_number,
ft.IconButton(ft.Icons.ADD, on_click=plus_click),
],
alignment=ft.MainAxisAlignment.CENTER,
)
)
👉 Rowで横並び
👉 左に「-」、入力する数字、右に「+」
👉 ボタンを押したときに呼ぶ関数をon_clickで指定
アプリを起動
ft.app(main)
実行結果
🏹fletのポイント
どうでしょうか?何となく流れはつかめたんじゃないでしょうか?
・ ウィジェット(部品)をpage.add()で画面へ追加する
・ ボタンを押すときの動きをon_clickで指定する
・ 値を置き換えたらpage.update()で画面が更新される
実装
どのようなUIにするか
以下のようなUIを想定しています。
見やすいかと言われると微妙なところですが、、、
➡️処理の流れ
- ユーザーが「開始」ボタンをクリック
↓ - start_voice_changer() が実行
↓ - デバイスチェック → エラーなし
↓ - 別スレッドで dev_play() が実行開始
↓ - 画面に「起動中...」表示
↓ - マイク入力 → 音声変換 → スピーカー出力
↓ - ユーザーが「停止」ボタンをクリック
↓ - stop_voice_changer() が実行
↓ - stop_event.set() で処理に停止シグナル
↓ - dev_play() が停止
↓ - 画面に「停止しました」表示
デバイス取得
import flet as ft
import sounddevice as sd
import threading
from vchange import dev_play
current_thread = None
is_running = False
def home(page: ft.Page):
global current_thread, is_running
page.title = "リアルタイムボイスチェンジャー"
page.scroll = "adaptive"
stop_event = None
# === 🎧 デバイス一覧取得(MME優先) ===
devices = sd.query_devices()
hostapis = sd.query_hostapis()
# MME > DirectSound > WASAPI の優先順位でAPIを選択
target_api_index = None
target_api_name = ""
for idx, api in enumerate(hostapis):
if 'MME' in api['name']:
target_api_index = idx
target_api_name = "MME"
break
if target_api_index is None:
for idx, api in enumerate(hostapis):
if 'DirectSound' in api['name']:
target_api_index = idx
target_api_name = "DirectSound"
break
if target_api_index is None:
for idx, api in enumerate(hostapis):
if 'WASAPI' in api['name']:
target_api_index = idx
target_api_name = "WASAPI"
break
input_devices = []
output_devices = []
seen_input_names = set()
seen_output_names = set()
for i, d in enumerate(devices):
if d['hostapi'] == target_api_index:
device_name = d['name']
# 16chデバイスは除外(通常使用しない)
if '16ch' in device_name.lower():
continue
if d['max_input_channels'] > 0 and device_name not in seen_input_names:
input_devices.append(f"{i}: {device_name}")
seen_input_names.add(device_name)
if d['max_output_channels'] > 0 and device_name not in seen_output_names:
output_devices.append(f"{i}: {device_name}")
seen_output_names.add(device_name)
def on_formant_change(e):
# 小数点以下1位まで表示
e.control.label = f"{e.control.value:.1f}"
e.control.update()
input_dropdown = ft.Dropdown(
label="🎤 入力デバイス(マイク)",
options=[ft.dropdown.Option(name) for name in input_devices],
width=500
)
output_dropdown = ft.Dropdown(
label="🔈 出力デバイス(スピーカー・VB-CABLEなど)",
options=[ft.dropdown.Option(name) for name in output_devices],
width=500
)
📚実装説明
・ マイクやスピーカーなどのオーディオデバイス一覧を取得する
・ Windowsでよく使われるMME → DirectSound → WASAPIの順で優先して選ぶ
・ マイク用とスピーカー用の選択肢ドロップダウンをGUIに表示する
グローバル変数(録音スレッド管理)
current_thread = None
is_running = False
👉 current_thread:録音・変換処理を走らせるスレッドを保持
👉 is_running:動作中かどうか示すフラグ
デバイス一覧を取得
devices = sd.query_devices()
hostapis = sd.query_hostapis()
👉 devices = sd.query_devices():マイク/スピーカーの一覧取得
👉 hostapis = sd.query_hostapis():WindowsのオーディオAPI(MME/DirectSound/WASAPI)の取得
for idx, api in enumerate(hostapis):
if 'MME' in api['name']:
target_api_index = idx
target_api_name = "MME"
break
...
👉 取得したhostapisを用いてMMEを最優先で選択
👉 なければDirectSound
👉 さらに無ければWASAPI
input_devices = []
output_devices = []
seen_input_names = set()
seen_output_names = set()
👉 同じ名前が何回も出てこないように 重複排除
オーディオデバイスを一覧
for i, d in enumerate(devices):
if d['hostapi'] == target_api_index:
device_name = d['name']
# 16chデバイスは除外(通常使用しない)
if '16ch' in device_name.lower():
continue
if d['max_input_channels'] > 0 and device_name not in seen_input_names:
input_devices.append(f"{i}: {device_name}")
seen_input_names.add(device_name)
if d['max_output_channels'] > 0 and device_name not in seen_output_names:
output_devices.append(f"{i}: {device_name}")
seen_output_names.add(device_name)
👉for i, d in enumerate(devices):devicesに入ってるすべてのデバイスを一つずつチェックする。
i:デバイスの番号
d:デバイスの詳細情報
👉if d['hostapi'] == target_api_index:先ほど選んだAPI(MME/DirectSound/WASAPI)に対応しているデバイスだけを対象にする。
👉if '16ch' in device_name.lower():デバイス名に「16ch」が含まれていたらスキップ
👉if d['max_input_channels'] > 0 and device_name not in seen_input_names:
max_input_channels > 0:入力対応デバイス(マイク機能がある)
device_name not in seen_input_names:同じ名前のデバイスが既に登録されていない
input_devices:入力デバイス用のリスト
seen_input_names:「この名前は既に使った」という記録帳(重複防止)
UI構築
def on_formant_change(e):
# 小数点以下1位まで表示
e.control.label = f"{e.control.value:.1f}"
e.control.update()
input_dropdown = ft.Dropdown(
label="🎤 入力デバイス(マイク)",
options=[ft.dropdown.Option(name) for name in input_devices],
width=500
)
output_dropdown = ft.Dropdown(
label="🔈 出力デバイス(スピーカー・VB-CABLEなど)",
options=[ft.dropdown.Option(name) for name in output_devices],
width=500
)
sample_rate_slider = ft.Slider(
min=8000, max=48000, divisions=10, value=44100, width=500,
label="{value} Hz"
)
block_size_slider = ft.Slider(
min=512, max=4096, divisions=8, value=2048, width=500,
label="{value}"
)
pitch_slider = ft.Slider(
min=-12, max=12, divisions=24, value=0, width=500,
label="0.0",
on_change=on_formant_change
)
formant_slider = ft.Slider(
min=0.5, max=2.0, divisions=30, value=1.0, width=500,
label="1.0",
on_change=on_formant_change
)
status_text = ft.Text("", color=ft.Colors.BLUE_700)
# === 警告テキスト ===
warning_text = ft.Container(
content=ft.Text(
"⚠️ 注意: ボイスチェンジャーを使用する場合、入力デバイス(マイク)と出力デバイス(スピーカー)は"
"異なるデバイスを選択してください。\n"
"推奨設定: マイク → VB-Audio CABLE Input → Discord/Zoom等では CABLE Output を選択",
size=12,
color=ft.Colors.ORANGE_700,
italic=True
),
bgcolor=ft.Colors.ORANGE_50,
padding=10,
border_radius=5,
visible=False
)
# === API情報表示 ===
api_info_text = ft.Text(
f"使用中のオーディオAPI: {target_api_name}",
size=12,
color=ft.Colors.GREY_600,
italic=True
)
# === デバイス選択時のチェック ===
def check_device_conflict(_):
if input_dropdown.value and output_dropdown.value:
in_name = input_dropdown.value.split(": ", 1)[1] if ": " in input_dropdown.value else input_dropdown.value
out_name = output_dropdown.value.split(": ", 1)[1] if ": " in output_dropdown.value else output_dropdown.value
# デバイス名の一部が重複している場合も警告
if in_name == out_name or (in_name in out_name) or (out_name in in_name):
warning_text.visible = True
else:
warning_text.visible = False
page.update()
input_dropdown.on_change = check_device_conflict
output_dropdown.on_change = check_device_conflict
# === スライダーの目盛り表示を作成する関数 ===
def create_slider_scale(min_val, max_val, width=500):
mid_val = (min_val + max_val) / 2
return ft.Row([
ft.Text(f"{min_val}", size=12, color=ft.Colors.GREY_600),
ft.Text(f"{mid_val:.1f}", size=12, color=ft.Colors.GREY_600),
ft.Text(f"{max_val}", size=12, color=ft.Colors.GREY_600),
], alignment=ft.MainAxisAlignment.SPACE_BETWEEN, width=width)
# === サンプリングレートの自動選択関数 ===
def find_compatible_sample_rate(in_dev, out_dev, preferred_rate):
"""デバイスに対応したサンプリングレートを見つける"""
sample_rates_to_try = [
preferred_rate,
48000,
44100,
32000,
24000,
22050,
16000,
8000
]
sample_rates_to_try = list(dict.fromkeys(sample_rates_to_try))
for rate in sample_rates_to_try:
try:
sd.check_input_settings(
device=in_dev,
channels=1,
dtype='float32',
samplerate=rate
)
sd.check_output_settings(
device=out_dev,
channels=1,
dtype='float32',
samplerate=rate
)
return rate
except Exception:
continue
input_info = sd.query_devices(in_dev)
return int(input_info['default_samplerate'])
# === プリセットボタン ===
def set_high(_):
pitch_slider.value = 10
pitch_slider.label = "10"
formant_slider.value = 1.6
formant_slider.label = "1.60"
page.update()
def set_low(_):
pitch_slider.value = -6
pitch_slider.label = "-6"
formant_slider.value = 0.8
formant_slider.label = "0.80"
page.update()
def set_normal(_):
pitch_slider.value = 0
pitch_slider.label = "0"
formant_slider.value = 1.0
formant_slider.label = "1.00"
page.update()
説明は省略
開始・停止処理
# === 停止処理 ===
def stop_current_thread():
nonlocal stop_event
global current_thread, is_running
if is_running and stop_event:
print("🛑 ストリーム停止中...")
stop_event.set()
# スレッドの終了を待つ(最大3秒)
if current_thread and current_thread.is_alive():
current_thread.join(timeout=3.0)
is_running = False
stop_event = None
status_text.value = "🛑 ボイスチェンジャーを停止しました。"
status_text.color = ft.Colors.GREY_700
page.update()
# === 開始処理 ===
def start_voice_changer(_):
nonlocal stop_event
global current_thread, is_running
try:
stop_current_thread() # 前回の処理を停止
if not input_dropdown.value or not output_dropdown.value:
status_text.value = "⚠ 入力または出力デバイスが選択されていません。"
status_text.color = ft.Colors.RED_600
page.update()
return
in_dev = int(input_dropdown.value.split(":")[0])
out_dev = int(output_dropdown.value.split(":")[0])
# デバイス名を取得
in_name = input_dropdown.value.split(": ", 1)[1] if ": " in input_dropdown.value else input_dropdown.value
out_name = output_dropdown.value.split(": ", 1)[1] if ": " in output_dropdown.value else output_dropdown.value
# 同一デバイスまたは類似デバイスチェック
if in_name == out_name or (in_name in out_name and len(in_name) > 10) or (out_name in in_name and len(out_name) > 10):
status_text.value = "❌ エラー: 入力と出力に同じデバイス(または同一ハードウェア)は使用できません。\n別のデバイス(例: VB-Audio CABLE Input)を出力に選択してください。"
status_text.color = ft.Colors.RED_600
page.update()
return
preferred_sample_rate = int(sample_rate_slider.value)
sample_rate = find_compatible_sample_rate(in_dev, out_dev, preferred_sample_rate)
rate_message = ""
if sample_rate != preferred_sample_rate:
rate_message = f"ℹ️ サンプリングレートを {sample_rate}Hz に調整しました。\n"
block_size = int(block_size_slider.value)
pitch = float(pitch_slider.value)
formant = float(formant_slider.value)
status_text.value = f"{rate_message}🎙️ 起動中...\n入力: {in_name}\n出力: {out_name}\nサンプリングレート: {sample_rate}Hz"
status_text.color = ft.Colors.BLUE_700
page.update()
stop_event = threading.Event()
def run():
global is_running
is_running = True
try:
dev_play(
input_device=in_dev,
output_device=out_dev,
sample_rate=sample_rate,
block_size=block_size,
formant_shift=formant,
pitch_semitones=pitch,
stop_event=stop_event,
)
status_text.value = "✅ ボイスチェンジャーが正常に動作しました。"
status_text.color = ft.Colors.GREEN_700
except Exception as e:
status_text.value = f"⚠️ 再生エラー: {e}"
status_text.color = ft.Colors.RED_600
finally:
is_running = False
page.update()
current_thread = threading.Thread(target=run, daemon=True)
current_thread.start()
except Exception as e:
status_text.value = f"⚠ エラー: {e}"
status_text.color = ft.Colors.RED_600
page.update()
# === 停止ボタン ===
def stop_voice_changer(_):
stop_current_thread()
# === UI配置 ===
page.add(
ft.Text("🎧 リアルタイムボイスチェンジャー", size=24, weight=ft.FontWeight.BOLD),
api_info_text,
ft.Divider(),
input_dropdown,
output_dropdown,
warning_text,
ft.Divider(),
ft.Row([
ft.ElevatedButton("変換しない 🙂", on_click=set_normal, bgcolor=ft.Colors.BLUE_300),
ft.ElevatedButton("高音VOICE ⬆️", on_click=set_high, bgcolor=ft.Colors.PINK_300),
ft.ElevatedButton("低音VOICE ⬇️", on_click=set_low, bgcolor=ft.Colors.GREY_600),
], alignment=ft.MainAxisAlignment.CENTER),
ft.Divider(),
ft.Text("サンプリングレート(Hz)", size=14, weight=ft.FontWeight.BOLD),
sample_rate_slider,
create_slider_scale(8000, 48000),
ft.Text("ブロックサイズ", size=14, weight=ft.FontWeight.BOLD),
block_size_slider,
create_slider_scale(512, 4096),
ft.Text("ピッチシフト(半音)", size=14, weight=ft.FontWeight.BOLD),
pitch_slider,
create_slider_scale(-12, 12),
ft.Text("フォルマントシフト(声質)", size=14, weight=ft.FontWeight.BOLD),
formant_slider,
create_slider_scale(0.5, 2.0),
ft.Divider(),
ft.Row([
ft.ElevatedButton("▶ 開始 / 再起動", on_click=start_voice_changer, bgcolor=ft.Colors.GREEN_400),
ft.ElevatedButton("⏹ 停止", on_click=stop_voice_changer, bgcolor=ft.Colors.RED_400),
], spacing=20),
ft.Divider(),
status_text
)
全体構成
- 開始処理(
start_voice_changer):ユーザーが「開始」ボタンを押したときの処理 - 停止処理(
stop_voice_changer):ユーザーが「停止」ボタンを押したときの処理 - UI配置(
page.add):画面に表示されるボタンやスライダーの配置
実行例
if __name__ == "__main__":
import multiprocessing
import sys
import os
# ★★★ 環境変数で子プロセスからの実行を防ぐ ★★★
if os.environ.get('FLET_MAIN_PROCESS') != '1':
os.environ['FLET_MAIN_PROCESS'] = '1'
multiprocessing.freeze_support()
if sys.platform.startswith('win'):
try:
multiprocessing.set_start_method('spawn', force=True)
except RuntimeError:
pass
ft.app(target=home)
最後に
ここまで読んでくださりありがとうございました!
次回はiot(ラズパイ監視カメラ)について書こうと思います!
お楽しみ👋
参考

