tUeda1223 commited on
Commit
dd9b42b
·
verified ·
1 Parent(s): 96af880

Upload 6 files

Browse files
Files changed (6) hide show
  1. .gitignore +6 -0
  2. README.md +96 -5
  3. app.py +439 -0
  4. auxiva_online.py +169 -0
  5. requirements.txt +6 -0
  6. sample_code_c5_2.py +158 -0
.gitignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ *
2
+ !.gitignore
3
+ !REAMDE.md
4
+ !requirements.txt
5
+ !.gitattributes
6
+ !*.py
README.md CHANGED
@@ -1,13 +1,104 @@
1
  ---
2
- title: IPhoneBlindSourceSeparation
3
- emoji: 🌍
4
- colorFrom: blue
5
- colorTo: blue
6
  sdk: gradio
7
- sdk_version: 6.1.0
8
  app_file: app.py
9
  pinned: false
10
  license: mit
11
  ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
  ---
2
+ title: iPhoneBlindSourceSeparation
3
+ emoji: 🏢
4
+ colorFrom: pink
5
+ colorTo: yellow
6
  sdk: gradio
7
+ sdk_version: 6.0.1
8
  app_file: app.py
9
  pinned: false
10
  license: mit
11
  ---
12
+ # iPhoneBlindSourceSeparation
13
+ このデモでは、iPhoneで撮影したステレオ録音付きの録画から**Independent Vector Analysis (IVA)**によるブラインド音源分離を体験できます。
14
+
15
+ ## ⚠️ 注意
16
+ - 動画アップロードによって生じるトラブルは責任を負いかねます
17
+
18
+ ## 📱 対応機種 (要件)
19
+
20
+ ステレオ録音機能に対応しているiOSデバイス:
21
+ - **iPhone**: XS / XR / SE (第2世代 以降) / 11 以降
22
+ - iOS 16以降
23
+
24
+ > ⚠️ **注記**: 対応機種一覧は参考情報です。
25
+ > 対応機種以外でも,サンプル音声を用いてブラインド音源分離を体験できます.
26
+
27
+ ## 🎥 ステレオ録音の設定方法 (事前準備)
28
+
29
+ 本デモでは標準カメラアプリで録画すると,ステレオ録音が行えることを利用します.
30
+ iPhoneでステレオ音源付きで録画する場合、以下の手順で設定を確認してください:
31
+
32
+ 1. **設定** → **カメラ** を開く
33
+ 2. **ビデオ録画** セクションで **ステレオ** を有効にする
34
+
35
+ 詳細は [Apple公式ガイド](https://support.apple.com/ja-jp/guide/iphone/iph31c1ca6c7/ios#:~:text=%E6%B3%A8%E8%A8%98:%20iPhone%2016%E4%BB%A5%E9%99%8D%E3%81%A7%E3%81%AF,%E3%82%92%E5%8F%82%E7%85%A7%E3%81%97%E3%81%A6%E3%81%8F%E3%81%A0%E3%81%95%E3%81%84%E3%80%82) をご参照ください。
36
+
37
+ ## 🎬 録画時の配置と条件
38
+
39
+ このアプリケーションで高精度に音源分離するため、以下の条件で録画することをお勧めします:
40
+
41
+ ### 推奨される配置
42
+ ```
43
+ 話者B(カメラ側) 話者A(撮影者側)
44
+ △ ---- [iPhone (話者Aが持つ)] ---- △
45
+ ```
46
+ - 各話者の中心にiPhoneがあることが重要です.
47
+ - ステレオ録音で録画する際, {前面上部のマイク, 背面カメラ付近のマイク}の2つが使われてる可能性が高いです (経験則). なので, 上の図を描き変えるとこのようなセットアップになります.
48
+ ```
49
+ 話者B(カメラ側) 話者A(撮影者側)
50
+ △ ---- [前面上部のマイク] -- [背面カメラ付近のマイク] ---- △
51
+ ```
52
+ - 録画ボタンを押して自由に発話してください. お互いが別々のことを話してる形式でも,対話形式でも構いません.
53
+
54
+ ### 音源分離の成功条件
55
+ - ✅ 2つの音源(話者の発話)がステレオの左右チャンネルに比較的均等に分布している.
56
+ - ✅ 話者やiPhone(マイク)の位置がほぼ固定で、大きな動きがない
57
+ - ✅ 背景雑音が少ない環境である(背景雑音があっても音声分離自体は可能ですが,背景雑音自体の抑圧はできてません. マイク数を多くすると雑音抑圧も可能になりますが,デモでは2マイクの場合のみ想定しています)
58
+
59
+ ### 分離が上手くいかない場合
60
+
61
+ 1. **STFT窓長を変える**
62
+ - 特に,残響時間が長い場合は窓長を例えばサンプリング周波数の0.6倍にすると上手く分離できるかもしれません.ただし,窓長を過剰に長くしすぎると分離性能が劣化する可能性があります.
63
+
64
+ 1. **オンライン処理を使用する**
65
+ - 処理モードを「オンライン (Online AuxIVA)」に切り替える
66
+ - これにより、特に話者やiPhoneに大きな動きがある場合を(ある程度)考慮できます.
67
+ - 逆に,時不変(=話者やマイクが動いてない)な状況を想定しているのにオンライン処理で上手く分離ができない場合は,まずオフライン処理で上手く分離できるか確かめると良いでしょう.
68
+ - 話者やマイクの動きが多い場合は忘却係数を調整してみてください. 値が低いほど過去の情報を忘却するようになります.
69
+
70
+ ## 🚀 使い方
71
+
72
+ ### Webアプリケーション (Hugging Face)
73
+ 1. ページを開く
74
+ 2. **上段**: 動画ファイルをアップロード、または **サンプル混合音声を生成・再生** ボタンを押す
75
+ 3. **中央**: 処理パラメータを設定
76
+ - サンプリング周波数を計算してSTFT窓長を自動設定
77
+ - 処理モード(オフライン/オンライン)
78
+ - (オンライン処理時)忘却係数を調整
79
+ 4. **分離開始** ボタンを押す
80
+ 5. **結果**: 混合音声と分離音声の波形、抽出された音声ファイルが表示されます
81
+
82
+ ### ローカルモード (gradioがinstallされてない環境でも動作可)
83
+ ```bash
84
+ python app.py
85
+ ```
86
+ 実行時に以下のパラメータを対話形式で入力します:
87
+ - ビデオファイルパス(またはサンプル���成)
88
+ - STFT窓長
89
+ - 処理モード(オンライン/オフライン)
90
+ - パラメータ(忘却係数)
91
+
92
+ ## License & Credits
93
+
94
+ 以下を使わせて頂きました:
95
+ - pyroomacoustics (https://github.com/LCAV/pyroomacoustics)
96
+ - Pythonで学ぶ音源分離 (https://github.com/masahitotogami/python_source_separation)
97
+ - CMU ARCTIC Concatenated 15s (https://zenodo.org/records/3066489)
98
+ - J. Kominek and A. W. Black, “CMU ARCTIC databases for speech synthesis,” CMU-LTI-03-177, 2003.
99
+
100
+ デモに使用したIVA
101
+ 1. N. Ono, *Stable and fast update rules for independent vector analysis based on auxiliary function technique,* Proc. IEEE, WASPAA, pp. 189-192, Oct. 2011.
102
+ 1. T. Taniguchi, N. Ono, A. Kawamura, and S. Sagayama, *An auxiliary-function approach to online independent vector analysis for real-time blind source separation,* Proc. HSCMA, pp. 107-111, May. 2014.
103
 
104
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
app.py ADDED
@@ -0,0 +1,439 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import subprocess
3
+ import tempfile
4
+ import sample_code_c5_2
5
+ import numpy as np
6
+ import soundfile as sf
7
+ import pyroomacoustics as pra
8
+ from auxiva_online import auxiva_online
9
+ import matplotlib
10
+ matplotlib.use('Agg') # Hugging Face Spaces対応(ディスプレイなし環境)
11
+ import matplotlib.pyplot as plt
12
+ from pdb import set_trace as test
13
+ from scipy import signal
14
+ import uuid
15
+ import shutil
16
+ import atexit
17
+
18
+ # ===== 実行環境判定: Hugging Face / Local =====
19
+ try:
20
+ import gradio as gr
21
+ USE_GRADIO = True
22
+ except ImportError:
23
+ USE_GRADIO = False
24
+
25
+ # ===== デフォルト値(Hugging Faceもローカルも共通) =====
26
+ DEFAULT_WIN_LEN = 1024
27
+ DEFAULT_ALPHA = 0.9
28
+
29
+ # ===== ffmpegで音声抽出(サンプリング周波数は指定しない) =====
30
+ def extract_audio(input_video, output_audio):
31
+ cmd = ["ffmpeg", "-i", input_video, "-vn", output_audio]
32
+ subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
33
+
34
+ # ===== サンプル混合データ作成 =====
35
+ def create_sample_audio(audio_path):
36
+ sample_code_c5_2.main(audio_output=audio_path)
37
+
38
+ # ===== オフライン IVA音源分離 =====
39
+ def separate_sources(audio_path, win_len, alpha=0.95, use_online=False, session_dir=None):
40
+ x, sr = sf.read(audio_path)
41
+ hop = win_len // 2
42
+
43
+ _, _, X = signal.stft(x.T, sr, nperseg=win_len, noverlap=hop)
44
+ if use_online:
45
+ Y = auxiva_online(X.transpose((2,1,0)), alpha=alpha)
46
+ else:
47
+ Y = pra.bss.auxiva(X.transpose((2,1,0)), n_iter=50)
48
+ _, y = signal.istft(Y.transpose((2,1,0)), sr, nperseg=win_len, noverlap=hop)
49
+
50
+ # セッションディレクトリにファイルを保存
51
+ if session_dir is None:
52
+ session_dir = "."
53
+
54
+ out1 = os.path.join(session_dir, "separated_1.wav")
55
+ out2 = os.path.join(session_dir, "separated_2.wav")
56
+ sf.write(out1, y[0], sr)
57
+ sf.write(out2, y[1], sr)
58
+
59
+ return (out1, out2), y, sr
60
+
61
+ # ===== 波形プロット(混合音声と分離音声を一緒に表示) =====
62
+ def plot_waveform(mixed_data, separated_data):
63
+ fig, ax = plt.subplots(2, 2, figsize=(12, 6))
64
+
65
+ # 混合音声
66
+ for i in range(2):
67
+ ax[0, i].plot(mixed_data[:, i], alpha=0.7, label=f"Mixed Channel {i+1}")
68
+ ax[0, i].set_title(f"Mixed Audio - Channel {i+1}")
69
+ ax[0, i].set_ylabel("Amplitude")
70
+ ax[0, i].legend()
71
+
72
+ # 分離音声
73
+ for i in range(2):
74
+ ax[1, i].plot(separated_data[i], alpha=0.7, label=f"Separated {i+1}", color="orange")
75
+ ax[1, i].set_title(f"Separated Audio {i+1}")
76
+ ax[1, i].set_ylabel("Amplitude")
77
+ ax[1, i].set_xlabel("Time")
78
+ ax[1, i].legend()
79
+
80
+ plt.tight_layout()
81
+ return fig
82
+
83
+ # ===== サンプリング周波数を計算(動画優先、サンプルからフォールバック) =====
84
+ def get_sampling_rate(video_file, sample_file):
85
+ """動画ファイルまたはサンプル音声からサンプリング周波数を取得(優先度: video > sample)"""
86
+ # 優先順位: video_file > sample_file
87
+ target = video_file or sample_file
88
+ if not target:
89
+ return "**サンプリング周波数**: 動画またはサンプルが必要です", DEFAULT_WIN_LEN
90
+
91
+ try:
92
+ # Gradio Audio/Video コンポーネントの形式を処理
93
+ target_path = None
94
+
95
+ # Audio コンポーネントの場合、タプル (sample_rate, audio_data) を返すことがある
96
+ if isinstance(target, tuple) and len(target) == 2:
97
+ # (sample_rate, audio_data) の形式
98
+ sr = target[0]
99
+ target_path = None # パスは不要、既にサンプリング周波数がある
100
+ elif isinstance(target, dict):
101
+ # Video/Audio コンポーネントの辞書形式
102
+ target_path = target.get("name") or target.get("path")
103
+ else:
104
+ # ファイルパス文字列
105
+ target_path = target
106
+
107
+ # パスから読み込む場合
108
+ if target_path is not None:
109
+ if not os.path.exists(target_path):
110
+ return "**サンプリング周波数**: ファイルが見つかりません", DEFAULT_WIN_LEN
111
+
112
+ # まず soundfile で読み込みを試みる
113
+ try:
114
+ data, sr = sf.read(target_path)
115
+ except Exception:
116
+ # ffmpegで音声抽出して読み直す
117
+ tmp = "__tmp_extracted_audio.wav"
118
+ try:
119
+ subprocess.run(["ffmpeg", "-y", "-i", target_path, "-vn", "-acodec", "pcm_s16le", tmp], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
120
+ data, sr = sf.read(tmp)
121
+ except Exception as e:
122
+ print(f"Error extracting audio for sampling rate: {e}")
123
+ return "**サンプリング周波数**: 読み取りに失敗しました", DEFAULT_WIN_LEN
124
+ finally:
125
+ if os.path.exists(tmp):
126
+ try:
127
+ os.remove(tmp)
128
+ except Exception:
129
+ pass
130
+
131
+ sr_text = f"**サンプリング周波数**: {sr} Hz"
132
+ candidates = np.array([256, 512, 1024, 2048, 4096, 8192, 16384])
133
+ target_win = int(sr * 0.2)
134
+ closest = int(candidates[np.argmin(np.abs(candidates - target_win))])
135
+ return sr_text, closest
136
+ except Exception as e:
137
+ return f"**サンプリング周波数**: エラー ({str(e)})", DEFAULT_WIN_LEN
138
+
139
+ # ===== メイン処理 (動画 → 分離) =====
140
+ def process(video_file, win_len=DEFAULT_WIN_LEN, alpha=DEFAULT_ALPHA, use_online=False, session_dir=None):
141
+ if session_dir is None:
142
+ session_dir = "."
143
+
144
+ with tempfile.TemporaryDirectory() as tmpdir:
145
+ audio_path = os.path.join(tmpdir, "audio.wav")
146
+
147
+ # video_file が Gradio Audio コンポーネントからのタプル (sr, audio_data) の場合に対応
148
+ if isinstance(video_file, tuple) and len(video_file) == 2:
149
+ sr_audio, audio_data = video_file
150
+ # タプル形式の場合は直接ファイルに書き込む
151
+ sf.write(audio_path, audio_data, sr_audio)
152
+ elif video_file is not None and os.path.exists(video_file):
153
+ extract_audio(video_file, audio_path)
154
+ else:
155
+ create_sample_audio(audio_path)
156
+
157
+ # 混合音声データを読み込む
158
+ mixed_data, sr = sf.read(audio_path)
159
+
160
+ (file1, file2), sep_data, sr = separate_sources(audio_path, win_len, alpha, use_online, session_dir=session_dir)
161
+ fig = plot_waveform(mixed_data, sep_data)
162
+
163
+ # 混合音声をファイルに保存(セッションディレクトリ)
164
+ # - ステレオで保存するファイルと、チャンネルごとのファイルを作成
165
+ mixed_out = os.path.join(session_dir, "mixed_audio.wav")
166
+ mixed_out_ch1 = os.path.join(session_dir, "mixed_audio_ch1.wav")
167
+ mixed_out_ch2 = os.path.join(session_dir, "mixed_audio_ch2.wav")
168
+
169
+ try:
170
+ if mixed_data.ndim == 1:
171
+ # モノラル入力はそのまま保存
172
+ sf.write(mixed_out, mixed_data, sr)
173
+ sf.write(mixed_out_ch1, mixed_data, sr)
174
+ sf.write(mixed_out_ch2, mixed_data, sr)
175
+ else:
176
+ # 複数チャンネルがある場合はステレオとして保存し、各チャンネルを個別に書き出す
177
+ chans = mixed_data.shape[1]
178
+ if chans >= 2:
179
+ sf.write(mixed_out, mixed_data[:, :2], sr)
180
+ sf.write(mixed_out_ch1, mixed_data[:, 0], sr)
181
+ sf.write(mixed_out_ch2, mixed_data[:, 1], sr)
182
+ else:
183
+ # チャンネル数が1の扱い
184
+ sf.write(mixed_out, mixed_data[:, 0], sr)
185
+ sf.write(mixed_out_ch1, mixed_data[:, 0], sr)
186
+ sf.write(mixed_out_ch2, mixed_data[:, 0], sr)
187
+ except Exception as e:
188
+ # 失敗したら最小限の情報を返す(呼び出し側でエラーハンドリング)
189
+ print(f"Warning: failed to write mixed audio files: {e}")
190
+ mixed_out_ch1 = mixed_out_ch2 = None
191
+
192
+ return sr, fig, file1, file2, mixed_out_ch1, mixed_out_ch2
193
+
194
+ # ===== Gradio UI (Hugging Face上のみ) =====
195
+ if USE_GRADIO:
196
+ # セッション管理: ユーザーごとに隔離されたディレクトリ
197
+ session_dir = None
198
+
199
+ def get_session_dir():
200
+ """ユーザーセッション用の一時ディレクトリを取得"""
201
+ global session_dir
202
+ if session_dir is None:
203
+ # セッションディレクトリを作成(アプリ起動時に1回だけ)
204
+ session_id = str(uuid.uuid4())[:8]
205
+ session_dir = os.path.join(tempfile.gettempdir(), f"gradio_session_{session_id}")
206
+ os.makedirs(session_dir, exist_ok=True)
207
+ # パーミッション設定:所有者のみアクセス可能 (mode 0o700)
208
+ os.chmod(session_dir, 0o700)
209
+ print(f"🔹 Session directory created: {session_dir}")
210
+ # プログラム終了時にセッションディレクトリを削除するコールバック登録
211
+ atexit.register(cleanup_session_dir, session_dir)
212
+ return session_dir
213
+
214
+ def cleanup_session_dir(session_dir):
215
+ """セッション終了時にディレクトリを削除"""
216
+ try:
217
+ if os.path.exists(session_dir):
218
+ shutil.rmtree(session_dir)
219
+ print(f"🔹 Session directory cleaned up: {session_dir}")
220
+ except Exception as e:
221
+ print(f"⚠️ Failed to clean up session directory: {e}")
222
+
223
+ with gr.Blocks() as demo:
224
+ gr.Markdown("🎧 **IVAステレオ音源分離デモ (pyroomacoustics)**")
225
+ # 小さい動画プレビューにするCSS(アップロードした大きな動画が邪魔なときに有効)
226
+ # ここで高さを制限し、必要ならブラウザで拡大して見るよう促すUIにできます。
227
+ gr.HTML("<style>video{max-height:240px; max-width:100%;}</style>")
228
+ gr.Markdown(
229
+ """
230
+ このデモでは、動画ファイルから音声を抽出し、2つの音源に分離します。動画をアップロードするか、サンプル混合音声を生成してから「分離開始」ボタンを押してください。
231
+ 注意:
232
+ - 個人情報や機密情報はアップロードしないでください
233
+ - アップロードによって生じるトラブルは責任を負いかねます
234
+ """)
235
+ # 入力ファイル (左:アップローダ, 右:サンプル生成/再生)
236
+ with gr.Row():
237
+ with gr.Column(scale=1):
238
+ gr.Markdown("**📤 入力ファイル**")
239
+ video_input = gr.Video(label="🎥 動画ファイルをアップロード")
240
+ gr.Markdown("**注意:** 動画をアップロードするか、または「🎼 サンプル混合音声を生成・再生」を押してください。どちらかを必ず行ってください。")
241
+
242
+ with gr.Column(scale=1):
243
+ gr.Markdown("**🎵 サンプル生成**")
244
+ gen_sample_btn = gr.Button("🎼 サンプル混合音声を生成・再生", variant="secondary", size="lg")
245
+ sample_audio = gr.Audio(label="生成されたサンプル", interactive=False)
246
+
247
+ # パラメータ / 処理モード
248
+ with gr.Group():
249
+ gr.Markdown("**⚙️ 処理モードとパラメータ**")
250
+
251
+ # 窓長説明(最初)
252
+ gr.Markdown("💡 窓長(サンプル数)は表示されたサンプリング周波数の0.2倍程度を推奨します。下のボタンを押すと自動設定されます。")
253
+
254
+ # サンプリング周波数計算ボタンと表示
255
+ with gr.Row():
256
+ check_sr_btn = gr.Button("▶ サンプリング周波数を計算", variant="secondary", size="sm")
257
+ sr_display = gr.Markdown("**サンプリング周波数**: まだ計算されていません")
258
+
259
+ # STFT 窓長
260
+ win_len = gr.Dropdown(
261
+ [256, 512, 1024, 2048, 4096, 8192, 16384],
262
+ value=DEFAULT_WIN_LEN,
263
+ label="STFT 窓長 (hopは自動で半分)"
264
+ )
265
+
266
+ # 処理モード選択
267
+ use_online = gr.Radio(
268
+ choices=["オフライン (AuxIVA)", "オンライン (Online AuxIVA)"],
269
+ value="オフライン (AuxIVA)",
270
+ label="処理モード",
271
+ interactive=True
272
+ )
273
+
274
+ # オンライン処理専用パラメータ区切り
275
+ gr.Markdown("💡 **以下はオンライン処理の時のみ有効です**")
276
+
277
+ # Online-specific parameters
278
+ forgetting_factor = gr.Slider(0.90, 0.99, value=DEFAULT_ALPHA, step=0.01, label="忘却係数 (高いほど過去の情報を忘れにくくなります)")
279
+
280
+ run_btn = gr.Button("▶ 分離開始", size="lg", variant="primary")
281
+
282
+ # ステータス表示(入力チェック等のメッセージを表示)
283
+ status_output = gr.Markdown("", visible=True)
284
+ # 結果表示 (下部)
285
+ with gr.Group():
286
+ gr.Markdown("**📊 結果**")
287
+ fig_out = gr.Plot(label="混合音声 vs 分離音声の波形比較")
288
+ with gr.Row():
289
+ mixed_audio_ch1 = gr.Audio(label="🎵 混合音 - チャンネル1", type="filepath")
290
+ mixed_audio_ch2 = gr.Audio(label="🎵 混合音 - チャンネル2", type="filepath")
291
+ audio1 = gr.Audio(label="🔊 分離音1", type="filepath")
292
+ audio2 = gr.Audio(label="🔊 分離音2", type="filepath")
293
+
294
+ # コールバック: サンプル生成
295
+ def generate_and_play_sample():
296
+ try:
297
+ out_path = "sample_mixed_ch1.wav"
298
+ # sample_code_c5_2 (若干改変) を使ってサンプルを生成
299
+ sample_code_c5_2.main(out_path)
300
+ data, sr = sf.read(out_path)
301
+ # 生成されたサンプルを保存(分離処理で2chが必要)
302
+ sf.write(out_path, data, sr)
303
+ return out_path
304
+ except Exception as e:
305
+ print(f"Error generating sample audio: {e}")
306
+ return None
307
+
308
+ gen_sample_btn.click(generate_and_play_sample, inputs=[], outputs=[sample_audio])
309
+
310
+ # サンプリング周波数計算コールバック(win_lenを更新)
311
+ check_sr_btn.click(get_sampling_rate, inputs=[video_input, sample_audio], outputs=[sr_display, win_len])
312
+
313
+ # main processing callback
314
+ def process_and_format(video_file, sample_file, win_len_val, forgetting_val, use_online_mode):
315
+ try:
316
+ use_online_flag = use_online_mode == "オンライン (Online AuxIVA)"
317
+
318
+ # 入力チェック: 動画がアップロードされていない && サンプルも生成されていない -> エラー表示
319
+ if not video_file and not sample_file:
320
+ status_msg = "❗ 動画が選択されていません。動画をアップロードするか、サンプルを生成してください。"
321
+ return None, None, None, None, status_msg
322
+
323
+ print(f"DEBUG: video_file={video_file}, sample_file={sample_file}")
324
+ print(f"DEBUG: video_file type={type(video_file)}, sample_file type={type(sample_file)}")
325
+
326
+ target = video_file or sample_file
327
+ print(f"DEBUG: target={target}, target type={type(target)}")
328
+
329
+ # セッションディレクトリを取得してprocess()に渡す
330
+ sess_dir = get_session_dir()
331
+ sr, fig, file1, file2, mixed_out_ch1, mixed_out_ch2 = process(target, win_len=win_len_val, alpha=forgetting_val, use_online=use_online_flag, session_dir=sess_dir)
332
+ status_msg = "✅ 分離が完了しました。下のオーディオを確認してください。"
333
+ return fig, mixed_out_ch1, mixed_out_ch2, file1, file2, status_msg
334
+ except Exception as e:
335
+ import traceback
336
+ error_msg = f"❌ エラーが発生しました: {str(e)}\n{traceback.format_exc()}"
337
+ print(error_msg)
338
+ return None, None, None, None, None, error_msg
339
+
340
+ run_btn.click(
341
+ process_and_format,
342
+ inputs=[video_input, sample_audio, win_len, forgetting_factor, use_online],
343
+ outputs=[fig_out, mixed_audio_ch1, mixed_audio_ch2, audio1, audio2, status_output]
344
+ )
345
+
346
+ gr.Markdown(
347
+ """
348
+ ---
349
+
350
+ **Resources Used for Demo Site**
351
+ - pyroomacoustics (https://github.com/LCAV/pyroomacoustics)
352
+ - Pythonで学ぶ音源分離(機械学習実践シリーズ)のソースコード (https://github.com/masahitotogami/python_source_separation)
353
+ - CMU ARCTIC Concatenated 15s (https://zenodo.org/records/3066489)
354
+ - J. Kominek and A. W. Black, “CMU ARCTIC databases for speech synthesis,” CMU-LTI-03-177, 2003.
355
+
356
+ **references**
357
+ - N. Ono, *Stable and fast update rules for independent vector analysis based on auxiliary function technique,* Proc. WASPAA, pp. 189-192, Oct. 2011.
358
+ - T. Taniguchi, N. Ono, A. Kawamura, and S. Sagayama, *An auxiliary-function approach to online independent vector analysis for real-time blind source separation,* Proc. HSCMA, pp. 107-111, May. 2014.
359
+
360
+ This demo is for educational and research purposes only.
361
+ """
362
+ )
363
+
364
+ demo.launch(debug=True)
365
+ else:
366
+ # ===== ローカルモード:対話形式でパラメータ入力 =====
367
+ print("🔍 Local mode detected (gradio not available)")
368
+ print("=" * 50)
369
+
370
+ # test_video 入力
371
+ print("\n【ビデオファイル】")
372
+ video_input = input("ビデオファイルを入力してください (Enterで test_input.mp4, sでサンプル, 他は入力値): ").strip()
373
+ if video_input.lower() in [""]:
374
+ test_video = "test_input.mp4"
375
+ elif video_input.lower() in ["s"]:
376
+ test_video = None
377
+ DEFAULT_WIN_LEN = 2048
378
+ else:
379
+ test_video = video_input
380
+
381
+ if test_video is not None and not os.path.exists(test_video):
382
+ raise FileNotFoundError(f"{test_video} が見つかりません。")
383
+
384
+ # win_len 入力
385
+ print("\n【STFT 窓長】")
386
+ win_len_input = input(f"win_lenを入力してください (デフォルト: {DEFAULT_WIN_LEN}): ").strip()
387
+ if win_len_input == "":
388
+ win_len = DEFAULT_WIN_LEN
389
+ else:
390
+ try:
391
+ win_len = int(win_len_input)
392
+ except ValueError:
393
+ print(f"⚠️ 無効な入力。デフォルト値 {DEFAULT_WIN_LEN} を使用します。")
394
+ win_len = DEFAULT_WIN_LEN
395
+
396
+ # use_online 入力
397
+ print("\n【処理モード】")
398
+ use_online_input = input("オンライン処理を使用しますか? (yes/no, デフォルト: yes): ").strip().lower()
399
+ use_online = not use_online_input in ["no", "n"]
400
+
401
+ # use_online が True の場合のみ alpha 入力
402
+ if use_online:
403
+ print("\n【Alpha パラメータ】")
404
+ alpha_input = input(f"alphaを入力してください (0.90-0.99, デフォルト: {DEFAULT_ALPHA}): ").strip()
405
+ if alpha_input == "":
406
+ alpha = DEFAULT_ALPHA
407
+ else:
408
+ try:
409
+ alpha = float(alpha_input)
410
+ if not (0.90 <= alpha <= 0.99):
411
+ print(f"⚠️ alpha は 0.90-0.99 の範囲で指定してください。デフォルト値 {DEFAULT_ALPHA} を使用します。")
412
+ alpha = DEFAULT_ALPHA
413
+ except ValueError:
414
+ print(f"⚠️ 無効な入力。デフォルト値 {DEFAULT_ALPHA} を使用します。")
415
+ alpha = DEFAULT_ALPHA
416
+
417
+ else:
418
+ alpha = DEFAULT_ALPHA
419
+
420
+ # パラメータ表示
421
+ print("\n" + "=" * 50)
422
+ print("【処理パラメータ】")
423
+ print(f" ビデオファイル: {test_video if test_video else 'サンプル音声を生成'}")
424
+ print(f" win_len: {win_len}")
425
+ print(f" use_online: {use_online}")
426
+ if use_online:
427
+ print(f" alpha: {alpha}")
428
+ print("=" * 50)
429
+ print("\n処理を開始します...")
430
+
431
+ # 処理実行
432
+ sr, fig, file1, file2, mixed_ch1, mixed_ch2 = process(test_video, win_len=win_len, alpha=alpha, use_online=use_online)
433
+
434
+ print(f"✅ 処理完了")
435
+ print(f"Sampling Rate: {sr}")
436
+ print(f"Saved separated files: {file1}, {file2}")
437
+ print(f"Saved mixed channel files: {mixed_ch1}, {mixed_ch2}")
438
+ fig.savefig("separated_plot.png")
439
+ print("Waveform plot saved to separated_plot.png")
auxiva_online.py ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2025 Tetsuya Ueda
2
+ # このコードはPyroomacoutsticsに記載されているAuxIVAを参考にしてそのオンライン拡張版を実装した.
3
+ # そのため,このコードのオリジナルの著作権は以下の通りPyroomacoutsticsの著作権表示に従う.
4
+ # このコード自体の著作権はMITライセンスに従う.
5
+
6
+ # Copyright (c) 2018-2019 Robin Scheibler
7
+ #
8
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
9
+ # of this software and associated documentation files (the "Software"), to deal
10
+ # in the Software without restriction, including without limitation the rights
11
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
12
+ # copies of the Software, and to permit persons to whom the Software is
13
+ # furnished to do so, subject to the following conditions:
14
+ #
15
+ # The above copyright notice and this permission notice shall be included in all
16
+ # copies or substantial portions of the Software.
17
+ #
18
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24
+ # SOFTWARE.
25
+ """
26
+ auxiva_online
27
+ ======
28
+
29
+ Blind Source Separation using independent vector analysis based on auxiliary function.
30
+ This function will separate the input signal into statistically independent sources
31
+ without using any prior information.
32
+
33
+ Example
34
+ -------
35
+ .. code-block:: python
36
+
37
+ from scipy.io import wavfile
38
+ import pyroomacoustics as pra
39
+
40
+ # read multichannel wav file
41
+ # audio.shape == (nsamples, nchannels)
42
+ fs, audio = wavfile.read("my_multichannel_audio.wav")
43
+
44
+ # STFT analysis parameters
45
+ fft_size = 4096 # `fft_size / fs` should be ~RT60
46
+ hop == fft_size // 2 # half-overlap
47
+ win_a = pra.hann(fft_size) # analysis window
48
+ # optimal synthesis window
49
+ win_s = pra.transform.compute_synthesis_window(win_a, hop)
50
+
51
+ # STFT
52
+ # X.shape == (nframes, nfrequencies, nchannels)
53
+ X = pra.transform.analysis(audio, fft_size, hop, win=win_a)
54
+
55
+ # Separation
56
+ Y = auxiva_online(X)
57
+
58
+ # iSTFT (introduces an offset of `hop` samples)
59
+ # y contains the time domain separated signals
60
+ # y.shape == (new_nsamples, nchannels)
61
+ y = pra.transform.synthesis(Y, fft_size, hop, win=win_s)
62
+
63
+ References
64
+ ----------
65
+
66
+ .. [1] T. Taniguchi, N. Ono, A. Kawamura, and S. Sagayama, *An auxiliary-function approach to online independent vector analysis for real-time blind source separation,* Proc. HSCMA, pp. 107-111, May. 2014.
67
+
68
+ """
69
+ import numpy as np
70
+
71
+ def auxiva_online(
72
+ X,
73
+ model="laplace",
74
+ alpha=0.9,
75
+ ):
76
+ """
77
+ This is an implementation of online AuxIVA that separates the input
78
+ signal into statistically independent sources. The separation is done
79
+ in the time-frequency domain and the FFT length should be approximately
80
+ equal to the reverberation time.
81
+
82
+ Two different statistical models (Laplace or time-varying Gauss) can
83
+ be used by using the keyword argument `model`. The performance of Gauss
84
+ model is higher in good conditions (few sources, low noise), but Laplace
85
+ (the default) is more robust in general.
86
+
87
+ この関数はpyroomacousticsにおけるAuxIVAのonline拡張を実装したものである。
88
+ ただし,デモ作成を目的としており,コードの修正は最小限に留めている.
89
+
90
+ Parameters
91
+ ----------
92
+ X: ndarray (nframes, nfrequencies, nchannels)
93
+ STFT representation of the signal
94
+ model: str
95
+ The model of source distribution 'gauss' or 'laplace' (default)
96
+ alpha: float
97
+ Forgetting factor for the online AuxIVA (0 < alpha < 1).
98
+
99
+ Returns
100
+ -------
101
+ Returns an (nframes, nfrequencies, nsources) array.
102
+ """
103
+
104
+ n_frames, n_freq, n_chan = X.shape
105
+
106
+ # default to determined case
107
+ n_src = n_chan
108
+
109
+ if model not in ["laplace", "gauss"]:
110
+ raise ValueError("Model should be either " "laplace" " or " "gauss" ".")
111
+
112
+ W_hat = np.zeros((n_freq, n_chan, n_chan), dtype=X.dtype)
113
+ W = W_hat[:, :n_src, :]
114
+
115
+ # initialize W
116
+ for f in range(n_freq):
117
+ W[f, :, :n_src] = np.eye(n_src)
118
+
119
+ eps = 1e-15
120
+ cov_init_factor=1e-9
121
+ eyes = np.tile(np.eye(n_chan, n_chan), (n_freq, 1, 1))
122
+ V = np.tile(np.eye(n_chan, n_chan, dtype=X.dtype), (n_freq, n_src, 1, 1))*cov_init_factor
123
+ r_inv = np.zeros((n_src, 1))
124
+ r = np.zeros((n_src, 1))
125
+
126
+ # Things are more efficient when the frequencies are over the first axis
127
+ Y = np.zeros((n_freq, n_src, n_frames), dtype=X.dtype)
128
+ X = X.transpose([1, 2, 0]).copy()
129
+
130
+ Y = np.zeros_like(X, dtype=X.dtype)
131
+ for frame in range(n_frames):
132
+ # W: (n_freq x n_src x n_chan)
133
+ # X: (n_freq x n_chan x n_frames)
134
+ XTmp = X[:, :, frame, None] # (n_freq x n_chan x 1)
135
+ YTmp = np.matmul(W, XTmp) # (n_freq x n_src x 1)
136
+ # shape: (n_frames, n_src)
137
+ if model == "laplace":
138
+ r[:, :] = 2.0 * np.linalg.norm(YTmp, axis=0) # (n_src x 1)
139
+ elif model == "gauss":
140
+ r[:, :] = (np.linalg.norm(YTmp, axis=0) ** 2) / n_freq # (n_src x 1)
141
+ r[r < eps] = eps
142
+ r_inv[:, :] = 1.0 / r
143
+
144
+ # Update now the demixing matrix
145
+ for s in range(n_src):
146
+ # Compute Auxiliary Variable
147
+ VTmp= (
148
+ np.matmul(XTmp*r_inv[None, s, None, :], np.conj(XTmp.swapaxes(1, 2)))
149
+ )
150
+ V[:, s, :, :] = alpha*V[:, s, :, :] + (1-alpha)*VTmp
151
+
152
+ WV = np.matmul(W_hat, V[:, s])
153
+ W[:, s, :] = np.conj(np.linalg.solve(WV, eyes[:, :, [s]]))[..., 0]
154
+
155
+ # normalize
156
+ denom = np.matmul(
157
+ np.matmul(W[:, None, s, :], V[:, s, :, :]), np.conj(W[:, s, :, None])
158
+ )
159
+ W[:, s, :] /= np.sqrt(denom[:, :, 0])
160
+
161
+ YTmp = np.matmul(W, XTmp) # (n_freq x n_src x 1)
162
+ # projection_back
163
+ A = np.linalg.inv(W) # (n_freq x n_chan x n_src)
164
+ # A: (n_freq x n_chan x n_src)
165
+ # YTmp: (n_freq x n_src x 1)
166
+ Y[:, :, frame] = A[:, 0] * YTmp[:, :, 0] # (n_freq x n_src x n_frames)
167
+ Y = Y.transpose([2, 0, 1])
168
+ return Y
169
+
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ gradio==6.0.1
2
+ numpy
3
+ scipy==1.15.3
4
+ soundfile
5
+ matplotlib
6
+ pyroomacoustics==0.8.4
sample_code_c5_2.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ '''
2
+ このコードはPythonで学ぶ音源分離(第5章)のサンプルコード5-2を改変したものです.
3
+ https://raw.githubusercontent.com/masahitotogami/python_source_separation/master/section5/sample_code_c5_2.py
4
+ '''
5
+ import os
6
+ import requests
7
+ import tarfile
8
+ import wave as wave
9
+ import pyroomacoustics as pa
10
+ import numpy as np
11
+ import random
12
+
13
+
14
+ def download_and_extract_cmu_arctic():
15
+ folder_name = "cmu_arctic_concat15"
16
+ tar_filename = "cmu_arctic_concat15.tar.gz"
17
+ url = "https://zenodo.org/records/3066489/files/cmu_arctic_concat15.tar.gz?download=1"
18
+
19
+ # 既にフォルダがある場合は処理しない
20
+ if os.path.exists(folder_name):
21
+ print(f"📂 フォルダ '{folder_name}' は既に存在しています。ダウンロードしません。")
22
+ return
23
+
24
+ print(f"⬇️ '{folder_name}' が見つかりません。ダウンロード開始...")
25
+
26
+ # ===== ダウンロード =====
27
+ response = requests.get(url, stream=True)
28
+ response.raise_for_status()
29
+
30
+ with open(tar_filename, "wb") as f:
31
+ for chunk in response.iter_content(chunk_size=8192):
32
+ f.write(chunk)
33
+
34
+ print(f"📦 ダウンロード完了: {tar_filename}")
35
+
36
+ # ===== 展開 =====
37
+ print(f"📂 展開中...")
38
+ with tarfile.open(tar_filename, "r:gz") as tar:
39
+ tar.extractall()
40
+
41
+ print("🎉 展開完了!")
42
+
43
+ # ===== ダウンロードファイル削除 =====
44
+ os.remove(tar_filename)
45
+ print(f"🧹 掃除完了: {tar_filename} を削除しました")
46
+
47
+
48
+ def main(audio_output="mixed_audio.wav"):
49
+ # CMU Arcticデータセットをダウンロード・展開
50
+ download_and_extract_cmu_arctic()
51
+
52
+ # 乱数の種を初期化
53
+ np.random.seed(0)
54
+
55
+ # ===== ① 使用する音源ファイル =====
56
+ base_dir = "cmu_arctic_concat15/"
57
+ files = os.listdir(base_dir)
58
+ wav_files = [
59
+ base_dir + f for f in files
60
+ if not f.startswith(".") and f.endswith(".wav")
61
+ ]
62
+ clean_wave_files = random.sample(wav_files, 2)
63
+
64
+ # 音源数
65
+ n_sources = len(clean_wave_files)
66
+
67
+ # 長さを調べる
68
+ n_samples = 0
69
+ n_samples_min = 1e10
70
+ for clean_wave_file in clean_wave_files:
71
+ wav = wave.open(clean_wave_file)
72
+ if n_samples < wav.getnframes():
73
+ n_samples = wav.getnframes()
74
+ n_samples_min = min(n_samples_min, wav.getnframes())
75
+ wav.close()
76
+
77
+ clean_data = np.zeros([n_sources, n_samples])
78
+
79
+ # ファイルを読み込む
80
+ s = 0
81
+ for clean_wave_file in clean_wave_files:
82
+ wav = wave.open(clean_wave_file)
83
+ data = wav.readframes(wav.getnframes())
84
+ data = np.frombuffer(data, dtype=np.int16)
85
+ data = data / np.iinfo(np.int16).max # -1〜1に正規化
86
+ clean_data[s, :wav.getnframes()] = data
87
+ wav.close()
88
+ s = s + 1
89
+
90
+ # ===== シミュレーションのパラメータ =====
91
+
92
+ sample_rate = 16000 # サンプリング周波数
93
+ SNR = 50. # 音声と雑音の比率 [dB]
94
+ room_dim = np.r_[10.0, 10.0, 10.0] # 部屋の大きさ
95
+
96
+ # マイクロホンアレイを置く部屋の場所
97
+ mic_array_loc = room_dim / 2 + np.random.randn(3) * 0.1
98
+
99
+ # マイクロホンアレイのマイク配置(2ch固定)
100
+ mic_alignments = np.array(
101
+ [
102
+ [-0.01, 0.0, 0.0],
103
+ [0.01, 0.0, 0.0],
104
+ ]
105
+ )
106
+
107
+ n_channels = mic_alignments.shape[0]
108
+ R = mic_alignments.T + mic_array_loc[:, None]
109
+
110
+ # 部屋を生成する
111
+ room = pa.ShoeBox(room_dim, fs=sample_rate, max_order=0)
112
+
113
+ # マイクロホンアレイの情報を設定
114
+ room.add_microphone_array(pa.MicrophoneArray(R, fs=room.fs))
115
+
116
+ # 音源の場所
117
+ doas = np.array(
118
+ [[np.pi/2., 0],
119
+ [np.pi/2., np.pi/2.]]
120
+ )
121
+ distance = 1.
122
+ source_locations = np.zeros((3, doas.shape[0]), dtype=doas.dtype)
123
+ source_locations[0, :] = np.cos(doas[:, 1]) * np.sin(doas[:, 0])
124
+ source_locations[1, :] = np.sin(doas[:, 1]) * np.sin(doas[:, 0])
125
+ source_locations[2, :] = np.cos(doas[:, 0])
126
+ source_locations *= distance
127
+ source_locations += mic_array_loc[:, None]
128
+
129
+ # 各音源を部屋に追加
130
+ for s in range(n_sources):
131
+ clean_data[s] /= np.std(clean_data[s])
132
+ room.add_source(source_locations[:, s], signal=clean_data[s])
133
+
134
+ # ===== シミュレーション実行 =====
135
+ room.simulate(snr=SNR)
136
+
137
+ # ===== ステレオ1つのファイルとして保存 =====
138
+ multi_conv_data = room.mic_array.signals # (channels, samples)
139
+
140
+ # int16スケール調整
141
+ data_scaled_L = (multi_conv_data[0] * np.iinfo(np.int16).max / 10).astype(np.int16)
142
+ data_scaled_R = (multi_conv_data[1] * np.iinfo(np.int16).max / 10).astype(np.int16)
143
+
144
+ # ステレオ結合
145
+ stereo_data = np.stack([data_scaled_L, data_scaled_R], axis=1)[:n_samples_min]
146
+
147
+ # ===== 保存 =====
148
+ out = wave.open(audio_output, "w")
149
+ out.setnchannels(2)
150
+ out.setsampwidth(2) # int16
151
+ out.setframerate(sample_rate)
152
+ out.writeframes(stereo_data.tobytes())
153
+ out.close()
154
+
155
+ print(f"🎧 {audio_output} を生成しました(2chステレオ)")
156
+
157
+ if __name__ == "__main__":
158
+ main()