0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

GUIでリアルタイム再生するボイスチェンジャーを作ろう!

Posted at

🖥️はじめに

※この記事は「リアルタイム再生するボイスチェンジャーを作ろう!」の続きになります。

今回は、pythonのフレームワークの一つであるfletを使ったGUIによるリアルタイムボイスチェンジャーを作成したいと思います!
この記事では、fletの基本的な機能から実装までを紹介したいと思います。

なお、本GUIアプリケーションの動作環境はWindows向けに作られています。

⚠️免責事項

本記事の目的は声を加工して通話を楽しむことです。
声を偽装して他人を欺くなど、悪用は絶対におやめください。
本記事の内容を悪用して発生したいかなる損害にも、作者は一切の責任を負いません。

📚目次

  1. fletの機能
  2. 実装
  3. 最後に

🍋‍🟩fletの機能

fletのチュートリアル(カウンターアプリ)

プログラムの流れ
  1. main(page) という関数で画面の中身を作る
  2. Fletがmain を呼び出してアプリ画面を表示
  3. ボタンを押すと、数字が変わって画面が更新される
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)

実行結果

image.png

🏹fletのポイント

どうでしょうか?何となく流れはつかめたんじゃないでしょうか?
・ ウィジェット(部品)をpage.add()で画面へ追加する
・ ボタンを押すときの動きをon_clickで指定する
・ 値を置き換えたらpage.update()で画面が更新される

実装

どのようなUIにするか

以下のようなUIを想定しています。
見やすいかと言われると微妙なところですが、、、

image.png

➡️処理の流れ

  1. ユーザーが「開始」ボタンをクリック
  2. start_voice_changer() が実行
  3. デバイスチェック → エラーなし
  4. 別スレッドで dev_play() が実行開始
  5. 画面に「起動中...」表示
  6. マイク入力 → 音声変換 → スピーカー出力
  7. ユーザーが「停止」ボタンをクリック
  8. stop_voice_changer() が実行
  9. stop_event.set() で処理に停止シグナル
  10. dev_play() が停止
  11. 画面に「停止しました」表示

デバイス取得

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
    )

全体構成

  1. 開始処理(start_voice_changer):ユーザーが「開始」ボタンを押したときの処理
  2. 停止処理(stop_voice_changer):ユーザーが「停止」ボタンを押したときの処理
  3. 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(ラズパイ監視カメラ)について書こうと思います!
お楽しみ👋

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?