indigo0511 commited on
Commit
d90b8a8
·
0 Parent(s):

initial: HarmoSplit app

Browse files
Files changed (13) hide show
  1. .dockerignore +15 -0
  2. .gitignore +41 -0
  3. Dockerfile +33 -0
  4. README.md +34 -0
  5. app.py +810 -0
  6. requirements.txt +35 -0
  7. server.py +410 -0
  8. setup_guide.md +192 -0
  9. static/app.js +213 -0
  10. static/index.html +132 -0
  11. static/pricing.html +128 -0
  12. static/style.css +326 -0
  13. static/success.html +126 -0
.dockerignore ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ venv/
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ .git/
6
+ .gitignore
7
+ *.wav
8
+ *.mp3
9
+ *.mp4
10
+ *.mov
11
+ output_panned.wav
12
+ test_audio.wav
13
+ test.mp4
14
+ .env
15
+ *.log
.gitignore ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ *.pyd
6
+ *.egg-info/
7
+ dist/
8
+ build/
9
+ .eggs/
10
+
11
+ # 仮想環境
12
+ venv/
13
+ .venv/
14
+ env/
15
+
16
+ # 出力ファイル
17
+ *.wav
18
+ *.mp3
19
+ *.mp4
20
+ *.mov
21
+ output_panned.wav
22
+ data/
23
+
24
+ # HF モデルキャッシュ
25
+ .cache/
26
+
27
+ # 環境変数
28
+ .env
29
+ .env.*
30
+
31
+ # エディタ
32
+ .vscode/
33
+ .idea/
34
+ *.swp
35
+
36
+ # OS
37
+ .DS_Store
38
+ Thumbs.db
39
+
40
+ # ログ
41
+ *.log
Dockerfile ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # HarmoSplit — HuggingFace Spaces 用 Dockerfile
2
+ # ポート 7860 を使用(HF Spaces 必須)
3
+
4
+ FROM python:3.11-slim
5
+
6
+ # システム依存関係
7
+ RUN apt-get update && apt-get install -y --no-install-recommends \
8
+ ffmpeg \
9
+ git \
10
+ && apt-get clean && rm -rf /var/lib/apt/lists/*
11
+
12
+ WORKDIR /app
13
+
14
+ # PyTorch CPU 版を先にインストール(キャッシュ効率化)
15
+ RUN pip install --no-cache-dir \
16
+ torch==2.2.2 torchaudio==2.2.2 \
17
+ --index-url https://download.pytorch.org/whl/cpu
18
+
19
+ # その他依存関係
20
+ COPY requirements.txt .
21
+ RUN pip install --no-cache-dir -r requirements.txt
22
+
23
+ # アプリケーションコード
24
+ COPY . .
25
+
26
+ # 永続ストレージ用ディレクトリ(HF Spaces の /data にマウントされる)
27
+ RUN mkdir -p /data
28
+
29
+ # HuggingFace Spaces は 7860 番ポートを公開する
30
+ EXPOSE 7860
31
+
32
+ # 起動
33
+ CMD ["python", "server.py"]
README.md ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: HarmoSplit
3
+ emoji: 🎧
4
+ colorFrom: purple
5
+ colorTo: blue
6
+ sdk: docker
7
+ pinned: false
8
+ license: mit
9
+ app_port: 7860
10
+ ---
11
+
12
+ # 🎧 HarmoSplit
13
+
14
+ 音楽ファイルから **主旋律(メインボーカル)** と **ハモリ(バッキングボーカル)** を AI が分離し、左右イヤホンで聴き分けられる WAV を生成するアプリです。
15
+
16
+ ## 機能
17
+
18
+ - 🎵 音声ファイル(MP3 / WAV / FLAC など)に対応
19
+ - 🎬 動画ファイル(MP4 / MOV / MKV など)から自動で音声抽出
20
+ - 🤖 [Demucs](https://github.com/facebookresearch/demucs) + UVR MDX-NET KARA による 2 段階 AI 分離
21
+ - 🎚️ 伴奏音量・モデル選択など細かい設定が可能
22
+
23
+ ## 使い方
24
+
25
+ 1. ファイルをドラッグ & ドロップ(または「ファイルを選択」)
26
+ 2. オプションを設定して「処理を開始」をクリック
27
+ 3. 完了後「WAV をダウンロード」🎧
28
+
29
+ ## 技術スタック
30
+
31
+ - **音源分離**: [Demucs htdemucs_6s](https://github.com/facebookresearch/demucs)
32
+ - **ボーカル分離**: UVR MDX-NET KARA 2
33
+ - **バックエンド**: Flask
34
+ - **フロントエンド**: HTML / CSS / JavaScript
app.py ADDED
@@ -0,0 +1,810 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app.py — 主旋律 / ハモリ 分離 & L/R パンニング プロトタイプ
3
+ =====================================================================
4
+ 使い方:
5
+ python app.py <入力ファイル> [--output output_panned.wav] [--inst-vol 0.15]
6
+
7
+ 入力: mp3 / wav / mp4 / mov など ffmpeg が対応する任意フォーマット
8
+ 出力: 左ch=主旋律(メインボーカル) / 右ch=ハモリ(コーラス) のステレオ WAV
9
+
10
+ アルゴリズム概要:
11
+ 1. subprocess で ffmpeg を直接呼び出し → 安定した音声抽出
12
+ 2. Demucs (htdemucs_6s) で6音源分離
13
+ → vocals / other / drums / bass / guitar / piano
14
+ 3. パンニング
15
+ - vocals → 左ch 100%
16
+ - other → 右ch 100% (コーラス/ハモリ)
17
+ - 伴奏 → センター & 音量縮小 (--inst-vol で調整, 0=ミュート)
18
+ 4. ミックスして output_panned.wav を出力
19
+ """
20
+
21
+ import argparse
22
+ import os
23
+ import sys
24
+ import tempfile
25
+ import shutil
26
+ import subprocess
27
+ from pathlib import Path
28
+
29
+ import numpy as np
30
+ import soundfile as sf
31
+
32
+ # ── torchaudio.save モンキーパッチ ─────────────────────────────
33
+ # Windows 環境で torchcodec の DLL が読み込めず torchaudio.save が
34
+ # 失敗する問題を回避するため、soundfile ベースの保存処理で置き換える。
35
+ # これにより Demucs の内部での WAV 書き出しが正常に動作する。
36
+ def _patched_torchaudio_save(uri, src, sample_rate, **kwargs):
37
+ """torchaudio.save を soundfile で代替する"""
38
+ import numpy as np
39
+ import soundfile as sf
40
+ data = src.detach().cpu().numpy()
41
+ # torchaudio は (C, T) 形式、soundfile は (T, C) を期待する
42
+ if data.ndim == 2:
43
+ data = data.T # (T, C) に変換
44
+ elif data.ndim == 1:
45
+ pass # モノラルはそのまま
46
+ sf.write(str(uri), data, sample_rate, subtype="PCM_16")
47
+
48
+ try:
49
+ import torchaudio
50
+ torchaudio.save = _patched_torchaudio_save
51
+ # 新しいバージョンの内部モジュールもパッチ
52
+ try:
53
+ import torchaudio.backend.soundfile_backend as _sfb
54
+ _sfb.save = _patched_torchaudio_save
55
+ except Exception:
56
+ pass
57
+ try:
58
+ import torchaudio._backend.utils as _bu
59
+ # save 関数が複数箇所に散在するため torchaudio 本体だけで十分
60
+ except Exception:
61
+ pass
62
+ except ImportError:
63
+ pass # torchaudio がない場合は無視
64
+
65
+
66
+ # ── UVR MDX-NET によるリード/バッキングボーカル分離 ────────────
67
+ # UVR_MDXNET_KARA_2 モデル: ボーカルステムから
68
+ # Lead (リードボーカル) と Backing (バッキング/ハモリ) を分離する
69
+
70
+ MDX_MODEL_REPO = "seanghay/uvr_models"
71
+ MDX_MODEL_FILE = "UVR_MDXNET_KARA_2.onnx"
72
+
73
+ # モデルの設定値 (UVR_MDXNET_KARA_2: 入力形状 [batch, 4, 2048, 256])
74
+ MDX_CONFIG = {
75
+ "dim_f": 2048, # 周波数ビン数(モデルの入力チャネル / 2)
76
+ "dim_t": 256, # 時間フレーム数
77
+ "n_fft": 6144, # STFT ウィンドウサイズ
78
+ "hop_length": 1024,
79
+ "sample_rate": 44100,
80
+ "overlap": 0.75,
81
+ }
82
+
83
+
84
+ def download_mdx_model(cache_dir: Path) -> Path:
85
+ """HuggingFace から UVR MDX-NET KARA モデルを取得する"""
86
+ model_path = cache_dir / MDX_MODEL_FILE
87
+ if model_path.exists():
88
+ print(f"[INFO] MDX モデルはキャッシュ済み: {model_path}")
89
+ return model_path
90
+
91
+ print(f"[INFO] UVR MDX-NET KARA モデルをダウンロード中... (初回のみ・約53MB)")
92
+ try:
93
+ from huggingface_hub import hf_hub_download
94
+ downloaded = hf_hub_download(
95
+ repo_id=MDX_MODEL_REPO,
96
+ filename=MDX_MODEL_FILE,
97
+ local_dir=str(cache_dir),
98
+ )
99
+ return Path(downloaded)
100
+ except Exception as e:
101
+ raise RuntimeError(
102
+ f"MDX モデルのダウンロードに失敗しました: {e}\n"
103
+ "インターネット接続を確認してください。"
104
+ )
105
+
106
+
107
+ def mdx_separate_lead_backing(
108
+ vocals_wav: np.ndarray,
109
+ sr: int,
110
+ model_path: Path,
111
+ ) -> tuple[np.ndarray, np.ndarray]:
112
+ """
113
+ UVR MDX-NET KARA モデルを使ってボーカルを
114
+ リードボーカル と バッキング/ハモリ に分離する。
115
+
116
+ モデル入力形状: [1, 4, dim_f, dim_t] = [1, 4, 2048, 256]
117
+ vocals_wav: (samples, 2) float32 のステレオ配列
118
+ 戻り値: (lead, backing) それぞれ (samples, 2) float32
119
+ """
120
+ import onnxruntime as ort
121
+
122
+ print("[INFO] UVR MDX-NET KARA でリード/バッキング分離を開始...")
123
+
124
+ dim_f = MDX_CONFIG["dim_f"] # 2048
125
+ dim_t = MDX_CONFIG["dim_t"] # 256
126
+ n_fft = MDX_CONFIG["n_fft"] # 6144
127
+ hop_length = MDX_CONFIG["hop_length"] # 1024
128
+ overlap = MDX_CONFIG["overlap"] # 0.75
129
+
130
+ # ONNX セッション
131
+ sess_opts = ort.SessionOptions()
132
+ sess_opts.log_severity_level = 3
133
+ session = ort.InferenceSession(
134
+ str(model_path),
135
+ sess_options=sess_opts,
136
+ providers=["CPUExecutionProvider"],
137
+ )
138
+ input_name = session.get_inputs()[0].name
139
+
140
+ # 入力: (samples, 2) → (2, T)
141
+ mix = vocals_wav.T.astype(np.float32) # (2, T)
142
+ n_channels, T = mix.shape
143
+
144
+ # チャンクサイズ = dim_t フレーム分のサンプル数
145
+ chunk_samples = hop_length * dim_t
146
+ step_samples = int(chunk_samples * (1 - overlap))
147
+ pad_size = chunk_samples # 前後にゼロパディング
148
+
149
+ mix_padded = np.pad(mix, ((0, 0), (pad_size, pad_size + chunk_samples)))
150
+
151
+ # 出力バッファ
152
+ out_sum = np.zeros((n_channels, mix_padded.shape[1]), dtype=np.float64)
153
+ out_count = np.zeros(mix_padded.shape[1], dtype=np.float64)
154
+
155
+ # ハン窓フェード
156
+ fade_len = step_samples
157
+ fade_in = np.linspace(0.0, 1.0, fade_len)
158
+ fade_out = fade_in[::-1]
159
+ win = np.ones(chunk_samples)
160
+ win[:fade_len] = fade_in
161
+ win[-fade_len:] = fade_out
162
+
163
+ starts = range(0, mix_padded.shape[1] - chunk_samples, step_samples)
164
+ total = len(list(starts))
165
+ print(f"[INFO] {total} チャンクを処理中...")
166
+
167
+ for idx, start in enumerate(range(0, mix_padded.shape[1] - chunk_samples, step_samples)):
168
+ chunk = mix_padded[:, start: start + chunk_samples] # (2, chunk_samples)
169
+
170
+ # STFT → (2, n_fft//2+1, dim_t)
171
+ stft_l = _np_stft(chunk[0], n_fft, hop_length, dim_t)
172
+ stft_r = _np_stft(chunk[1], n_fft, hop_length, dim_t)
173
+
174
+ # 上位 dim_f ビンだけ使う
175
+ sl = stft_l[:dim_f, :] # (dim_f, dim_t)
176
+ sr_ = stft_r[:dim_f, :]
177
+
178
+ # real/imag → (1, 4, dim_f, dim_t)
179
+ model_input = np.stack([
180
+ sl.real, sl.imag,
181
+ sr_.real, sr_.imag,
182
+ ], axis=0)[np.newaxis].astype(np.float32) # (1,4,dim_f,dim_t)
183
+
184
+ # 推論 → (1, 4, dim_f, dim_t)
185
+ pred = session.run(None, {input_name: model_input})[0][0] # (4, dim_f, dim_t)
186
+
187
+ # マスクをそのまま分離マスクとして適用
188
+ sep_l_stft = (pred[0] + 1j * pred[1]) * sl
189
+ sep_r_stft = (pred[2] + 1j * pred[3]) * sr_
190
+
191
+ # 周波数ビンを元の長さに戻す (残りはゼロ)
192
+ full_l = np.zeros((n_fft // 2 + 1, dim_t), dtype=np.complex64)
193
+ full_r = np.zeros((n_fft // 2 + 1, dim_t), dtype=np.complex64)
194
+ full_l[:dim_f] = sep_l_stft
195
+ full_r[:dim_f] = sep_r_stft
196
+
197
+ # iSTFT
198
+ sig_l = _np_istft(full_l, n_fft, hop_length, chunk_samples)
199
+ sig_r = _np_istft(full_r, n_fft, hop_length, chunk_samples)
200
+ sep = np.stack([sig_l, sig_r]) # (2, chunk_samples)
201
+
202
+ # オーバーラップ加算
203
+ out_sum[:, start: start + chunk_samples] += sep * win
204
+ out_count[start: start + chunk_samples] += win
205
+
206
+ if (idx + 1) % max(1, total // 10) == 0:
207
+ print(f" {idx+1}/{total} チャンク完了")
208
+
209
+ np.maximum(out_count, 1e-8, out_count)
210
+ result = out_sum / out_count
211
+
212
+ # パディング除去
213
+ lead = result[:, pad_size: pad_size + T] # (2, T)
214
+ backing = mix - lead # 残差
215
+
216
+ print("[INFO] リード/バッキング分離完了")
217
+ return lead.T.astype(np.float32), backing.T.astype(np.float32)
218
+
219
+
220
+ def _np_stft(signal: np.ndarray, n_fft: int, hop_length: int, n_frames: int) -> np.ndarray:
221
+ """
222
+ NumPy で STFT を計算する。
223
+ signal: (T,) float32
224
+ 戻り値: (n_fft//2+1, n_frames) complex64
225
+ """
226
+ window = np.hanning(n_fft).astype(np.float32)
227
+ # 必要なサンプル数になるようにパディング
228
+ required = n_fft + hop_length * (n_frames - 1)
229
+ if signal.shape[0] < required:
230
+ signal = np.pad(signal, (0, required - signal.shape[0]))
231
+ else:
232
+ signal = signal[:required]
233
+
234
+ out = np.zeros((n_fft // 2 + 1, n_frames), dtype=np.complex64)
235
+ for i in range(n_frames):
236
+ s = i * hop_length
237
+ frame = signal[s: s + n_fft] * window
238
+ out[:, i] = np.fft.rfft(frame, n=n_fft).astype(np.complex64)
239
+ return out
240
+
241
+
242
+ def _np_istft(stft: np.ndarray, n_fft: int, hop_length: int, length: int) -> np.ndarray:
243
+ """
244
+ NumPy で iSTFT を計算する。
245
+ stft: (n_fft//2+1, T) complex64
246
+ 戻り値: (length,) float32
247
+ """
248
+ window = np.hanning(n_fft).astype(np.float32)
249
+ n_frames = stft.shape[1]
250
+ out = np.zeros(length + n_fft, dtype=np.float64)
251
+ wsum = np.zeros(length + n_fft, dtype=np.float64)
252
+ for i in range(n_frames):
253
+ frame = np.fft.irfft(stft[:, i], n=n_fft).real * window
254
+ s = i * hop_length
255
+ e = s + n_fft
256
+ if s >= length:
257
+ break
258
+ out[s:e] += frame
259
+ wsum[s:e] += window ** 2
260
+ np.maximum(wsum, 1e-8, wsum)
261
+ return (out / wsum)[:length].astype(np.float32)
262
+
263
+
264
+
265
+
266
+ def download_mdx_model(cache_dir: Path) -> Path:
267
+ """HuggingFace から UVR MDX-NET KARA モデルを取得する"""
268
+ model_path = cache_dir / MDX_MODEL_FILE
269
+ if model_path.exists():
270
+ print(f"[INFO] MDX モデルはキャッシュ済み: {model_path}")
271
+ return model_path
272
+
273
+ print(f"[INFO] UVR MDX-NET KARA モデルをダウンロード中... (初回のみ)")
274
+ try:
275
+ from huggingface_hub import hf_hub_download
276
+ downloaded = hf_hub_download(
277
+ repo_id=MDX_MODEL_REPO,
278
+ filename=MDX_MODEL_FILE,
279
+ local_dir=str(cache_dir),
280
+ )
281
+ return Path(downloaded)
282
+ except Exception as e:
283
+ raise RuntimeError(
284
+ f"MDX モデルのダウンロードに失敗しました: {e}\n"
285
+ "インターネット接続を確認してください。"
286
+ )
287
+
288
+
289
+
290
+
291
+ # ── ユーティリティ ──────────────────────────────────────────────
292
+
293
+
294
+ VIDEO_EXTENSIONS = {".mp4", ".mov", ".avi", ".mkv", ".m4v", ".flv", ".webm", ".ts"}
295
+ AUDIO_EXTENSIONS = {".mp3", ".wav", ".flac", ".aac", ".ogg", ".m4a"}
296
+
297
+
298
+ def is_url(text: str) -> bool:
299
+ """http:// or https:// で始まる文字列を URL と判定する"""
300
+ return text.startswith("http://") or text.startswith("https://")
301
+
302
+
303
+ def download_audio_from_url(url: str, tmp_dir: Path) -> Path:
304
+ """
305
+ yt-dlp で YouTube などの URL から音声をダウンロードし WAV に変換する。
306
+ YouTube 以外にも yt-dlp が対応するサイトは利用可能。
307
+ 戻り値: 一時 WAV ファイルの Path
308
+ """
309
+ try:
310
+ import yt_dlp
311
+ except ImportError:
312
+ raise ImportError(
313
+ "yt-dlp が見つかりません。\n"
314
+ "pip install yt-dlp でインストールしてください。"
315
+ )
316
+
317
+ out_wav = tmp_dir / "source.wav"
318
+ print(f"[INFO] URL を検出。yt-dlp で音声をダウンロード中...")
319
+ print(f" URL: {url}")
320
+
321
+ ydl_opts = {
322
+ # WAV に直接変換して一時ディレクトリに保存
323
+ "format": "bestaudio/best",
324
+ "outtmpl": str(tmp_dir / "ydl_download.%(ext)s"),
325
+ "postprocessors": [
326
+ {
327
+ "key": "FFmpegExtractAudio",
328
+ "preferredcodec": "wav",
329
+ "preferredquality": "0",
330
+ }
331
+ ],
332
+ "quiet": False,
333
+ "no_warnings": False,
334
+ "noplaylist": True, # プレイリストでなく先頭の1曲のみ取得
335
+ }
336
+
337
+ with yt_dlp.YoutubeDL(ydl_opts) as ydl:
338
+ info = ydl.extract_info(url, download=True)
339
+ title = info.get("title", "unknown")
340
+ print(f"[INFO] ダウンロード完了: {title}")
341
+
342
+ # yt-dlp が生成した WAV を探す
343
+ wav_candidates = list(tmp_dir.glob("ydl_download*.wav"))
344
+ if not wav_candidates:
345
+ raise FileNotFoundError(
346
+ "yt-dlp の変換後 WAV が見つかりません。"
347
+ "ffmpeg が正しくインストールされているか確認してください。"
348
+ )
349
+ downloaded_wav = wav_candidates[0]
350
+
351
+ # ffmpeg でサンプルレートを 44100Hz に統一
352
+ ffmpeg = find_ffmpeg()
353
+ result = subprocess.run(
354
+ [
355
+ ffmpeg, "-y",
356
+ "-i", str(downloaded_wav),
357
+ "-ar", "44100",
358
+ "-ac", "2",
359
+ str(out_wav),
360
+ ],
361
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
362
+ )
363
+ if result.returncode != 0:
364
+ print(f"[ffmpeg stderr]\n{result.stderr[-2000:]}")
365
+ raise RuntimeError("音声変換に失敗しました")
366
+
367
+ print(f"[INFO] 音声変換完了 → {out_wav.name}")
368
+ return out_wav
369
+
370
+
371
+ def find_ffmpeg() -> str:
372
+ """ffmpeg の実行パスを返す。見つからなければ例外を投げる"""
373
+ ffmpeg_cmd = shutil.which("ffmpeg")
374
+ if ffmpeg_cmd:
375
+ return ffmpeg_cmd
376
+ candidates = [
377
+ r"C:\ffmpeg\bin\ffmpeg.exe",
378
+ r"C:\Program Files\ffmpeg\bin\ffmpeg.exe",
379
+ r"C:\ProgramData\chocolatey\bin\ffmpeg.exe",
380
+ ]
381
+ for c in candidates:
382
+ if Path(c).exists():
383
+ return c
384
+ raise FileNotFoundError(
385
+ "ffmpeg が見つかりません。\n"
386
+ "Windows: winget install Gyan.FFmpeg\n"
387
+ "Mac: brew install ffmpeg\n"
388
+ "Linux: sudo apt install ffmpeg"
389
+ )
390
+
391
+
392
+ def check_audio_stream(input_path: Path) -> None:
393
+ """
394
+ ffprobe で音声ストリームの有無を確認する。
395
+ 音声がない場合は分かりやすいエラーメッセージを出して終了する。
396
+ """
397
+ ffprobe_cmd = shutil.which("ffprobe")
398
+ if not ffprobe_cmd:
399
+ # ffprobe がない場合はスキップ(ffmpegで失敗させる)
400
+ return
401
+
402
+ result = subprocess.run(
403
+ [
404
+ ffprobe_cmd,
405
+ "-v", "quiet",
406
+ "-select_streams", "a", # 音声ストリームのみ
407
+ "-show_entries", "stream=codec_type",
408
+ "-of", "csv=p=0",
409
+ str(input_path),
410
+ ],
411
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
412
+ )
413
+ if not result.stdout.strip():
414
+ raise ValueError(
415
+ f"'{input_path.name}' に音声トラックが見つかりません。\n"
416
+ "このファイルには映像のみが含まれているため処理できません。\n"
417
+ "音声トラック付きのファイル(mp3/wav/mp4など)を指定してください。"
418
+ )
419
+
420
+
421
+ def extract_audio(input_path: Path, out_wav: Path) -> None:
422
+ """
423
+ subprocess で ffmpeg を直接呼び出して音声を WAV に変換。
424
+ 動画・音声どちらにも対応。事前に音声ストリームの有無を確認する。
425
+ """
426
+ ffmpeg = find_ffmpeg()
427
+ # 音声トラックの有無を事前確認
428
+ check_audio_stream(input_path)
429
+ print(f"[INFO] ffmpeg で音声変換中: {input_path.name} → {out_wav.name}")
430
+
431
+ cmd = [
432
+ ffmpeg,
433
+ "-y", # 上書き確認をスキップ
434
+ "-i", str(input_path),
435
+ "-vn", # 映像トラックを除外
436
+ "-acodec", "pcm_s16le", # 16bit PCM
437
+ "-ar", "44100", # サンプルレート
438
+ "-ac", "2", # ステレオ
439
+ str(out_wav),
440
+ ]
441
+
442
+ result = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace")
443
+ if result.returncode != 0:
444
+ print(f"[ffmpeg stderr]\n{result.stderr[-2000:]}") # 末尾2000文字だけ表示
445
+ raise RuntimeError(f"ffmpeg が失敗しました (returncode={result.returncode})")
446
+
447
+ print(f"[INFO] 音声変換完了 → {out_wav}")
448
+
449
+
450
+ def prepare_audio(input_path_or_url: str | Path, tmp_dir: Path) -> Path:
451
+ """URL もしくはファイルパスを受け取り、WAV に変換して返す"""
452
+ # URLの場合は yt-dlp でダウンロード
453
+ if isinstance(input_path_or_url, str) and is_url(input_path_or_url):
454
+ return download_audio_from_url(input_path_or_url, tmp_dir)
455
+
456
+ input_path = Path(input_path_or_url)
457
+ ext = input_path.suffix.lower()
458
+ out_wav = tmp_dir / "source.wav"
459
+
460
+ if ext == ".wav":
461
+ # WAV でもサンプルレートが異なる場合があるため ffmpeg で統一する
462
+ extract_audio(input_path, out_wav)
463
+ return out_wav
464
+
465
+ if ext in VIDEO_EXTENSIONS or ext in AUDIO_EXTENSIONS:
466
+ extract_audio(input_path, out_wav)
467
+ return out_wav
468
+
469
+ # 不明な拡張子でも ffmpeg に渡してみる
470
+ print(f"[WARN] 未知の拡張子 '{ext}'。ffmpeg で変換を試みます。")
471
+ extract_audio(input_path, out_wav)
472
+ return out_wav
473
+
474
+
475
+ # ── Demucs 分離 ──────────────────────────────────────────────────
476
+
477
+
478
+ def run_demucs(wav_path: Path, out_dir: Path, model: str) -> tuple[dict, int]:
479
+ """
480
+ Demucs でステム分離を実行。
481
+ 戻り値: (stems_dict, sample_rate)
482
+ stems_dict = {stem_name: np.ndarray(shape=[samples, 2], dtype=float32)}
483
+ """
484
+ print(f"\n[INFO] Demucs ({model}) で音源分離を開始...")
485
+ print(" 初回実行時はモデルのダウンロードが発生します(数百MB)。しばらくお待ちください。\n")
486
+
487
+ cmd = [
488
+ sys.executable, "-m", "demucs",
489
+ "-n", model,
490
+ "-o", str(out_dir),
491
+ str(wav_path),
492
+ ]
493
+
494
+ # リアルタイムで進捗を表示しながら実行
495
+ proc = subprocess.Popen(
496
+ cmd,
497
+ stdout=subprocess.PIPE,
498
+ stderr=subprocess.STDOUT,
499
+ text=True,
500
+ encoding="utf-8",
501
+ errors="replace",
502
+ )
503
+ output_lines = []
504
+ for line in proc.stdout:
505
+ line_stripped = line.rstrip()
506
+ output_lines.append(line_stripped)
507
+ print(f" {line_stripped}")
508
+ proc.wait()
509
+
510
+ if proc.returncode != 0:
511
+ raise RuntimeError(
512
+ f"Demucs が失敗しました (returncode={proc.returncode})\n"
513
+ + "\n".join(output_lines[-20:])
514
+ )
515
+
516
+ # ── 出力ディレクトリを特定 ──
517
+ # Demucs の出力構造: <out_dir>/<model>/<入力ファイル名(拡張子なし)>/
518
+ stem_dir = out_dir / model / wav_path.stem
519
+ if not stem_dir.exists():
520
+ # ディレクトリ名が一致しない場合はざっくり検索
521
+ found = list(out_dir.rglob("*.wav"))
522
+ if not found:
523
+ raise FileNotFoundError(
524
+ f"Demucs の出力ファイルが見つかりません。出力先: {out_dir}\n"
525
+ f"分離されたモデル名のフォルダを確認してください。"
526
+ )
527
+ stem_dir = found[0].parent
528
+ print(f"[INFO] ステムディレクトリを自動検出: {stem_dir}")
529
+
530
+ # ── WAV ファイルを読み込む ──
531
+ stems: dict[str, np.ndarray] = {}
532
+ sr = 44100
533
+ for wav_file in sorted(stem_dir.glob("*.wav")):
534
+ data, sr = sf.read(str(wav_file), always_2d=True)
535
+ stems[wav_file.stem] = data.astype(np.float32)
536
+ print(f" [stem] {wav_file.stem:12s}: {data.shape} @ {sr}Hz")
537
+
538
+ if not stems:
539
+ raise FileNotFoundError(f"ステム WAV が見つかりません: {stem_dir}")
540
+
541
+ print(f"\n[INFO] 分離完了。検出ステム: {list(stems.keys())}")
542
+ return stems, sr
543
+
544
+
545
+ # ── ハモリ推定フォールバック ──────────────────────────────────────
546
+
547
+
548
+ def fallback_split_vocals(vocals: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
549
+ """
550
+ htdemucs_6s で 'other' が得られない場合のフォールバック。
551
+ ステレオの Mid/Side 分解でハモリ成分を推定する。
552
+ - Mid (L+R)/2 → 主旋律(モノラル中心成分)
553
+ - Side (L-R)/2 → ハモリ候補(ステレオ広がり成分)
554
+ """
555
+ print("[INFO] フォールバック: Mid/Side 分解でハモリを推定します")
556
+ L = vocals[:, 0]
557
+ R = vocals[:, 1] if vocals.shape[1] > 1 else vocals[:, 0]
558
+
559
+ mid = ((L + R) / 2.0).astype(np.float32)
560
+ side = ((L - R) / 2.0).astype(np.float32)
561
+
562
+ main_vocal = np.stack([mid, mid], axis=1)
563
+ harmony = np.stack([side, side], axis=1)
564
+ return main_vocal, harmony
565
+
566
+
567
+ # ── パンニング & ミックス ─────────────────────────────────────────
568
+
569
+
570
+ def to_stereo(audio: np.ndarray) -> np.ndarray:
571
+ """入力を (samples, 2) のステレオ配列に整形"""
572
+ if audio.ndim == 1:
573
+ audio = np.stack([audio, audio], axis=1)
574
+ elif audio.shape[1] == 1:
575
+ audio = np.concatenate([audio, audio], axis=1)
576
+ return audio[:, :2] # 3ch以上は最初の2chだけ使う
577
+
578
+
579
+ def pan_to_left(audio: np.ndarray) -> np.ndarray:
580
+ """ステレオ音源を左チャンネルのみに振る (Rch=0)"""
581
+ stereo = to_stereo(audio)
582
+ mono = np.mean(stereo, axis=1, keepdims=True)
583
+ return np.concatenate([mono, np.zeros_like(mono)], axis=1)
584
+
585
+
586
+ def pan_to_right(audio: np.ndarray) -> np.ndarray:
587
+ """ステレオ音源を右チャンネルのみに振る (Lch=0)"""
588
+ stereo = to_stereo(audio)
589
+ mono = np.mean(stereo, axis=1, keepdims=True)
590
+ return np.concatenate([np.zeros_like(mono), mono], axis=1)
591
+
592
+
593
+ def mid_side_split(vocals: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
594
+ """
595
+ vocals ステムを Mid/Side 分解して主旋律とハモリを推定する。
596
+
597
+ Mid = (L + R) / 2 → センターに定位したメインボーカル → 左ch
598
+ Side = (L - R) / 2 → 左右にパンされたハモリ/コーラス → 右ch
599
+
600
+ プロの録音では:
601
+ - メインボーカルはセンター(Mid 成分が大きい)
602
+ - ハモリやコーラスは左右にパン(Side 成分に現れる)
603
+ """
604
+ L = vocals[:, 0].astype(np.float32)
605
+ R = vocals[:, 1].astype(np.float32) if vocals.shape[1] > 1 else L.copy()
606
+
607
+ mid = (L + R) / 2.0
608
+ side = (L - R) / 2.0
609
+
610
+ # Side 成分が極端に小さい場合(ほぼモノラル録音)は警告
611
+ mid_rms = float(np.sqrt(np.mean(mid ** 2)) + 1e-9)
612
+ side_rms = float(np.sqrt(np.mean(side ** 2)) + 1e-9)
613
+ ratio = side_rms / mid_rms
614
+ print(f"[INFO] Mid/Side 比率: Mid={mid_rms:.4f}, Side={side_rms:.4f}, Side/Mid={ratio:.3f}")
615
+ if ratio < 0.05:
616
+ print("[WARN] Side 成分が非常に小さいです。元音源がモノラル録音の可能性があります。")
617
+ print(" 左右の差が小さいため、ハモリ分離の効果が限定的になる場合があります。")
618
+
619
+ main_vocal = np.stack([mid, mid], axis=1) # モノ → ステレオ
620
+ harmony = np.stack([side, side], axis=1)
621
+ return main_vocal, harmony
622
+
623
+
624
+ def mix_stems(
625
+ stems: dict[str, np.ndarray],
626
+ model: str,
627
+ inst_vol: float,
628
+ mdx_model_path: Path | None = None,
629
+ sr: int = 44100,
630
+ ) -> np.ndarray:
631
+ """
632
+ 分離済みステムを L/R パンニングしてミックスする。
633
+
634
+ mdx_model_path が指定されている場合:
635
+ UVR MDX-NET KARA で vocals を Lead/Backing に AI 分離
636
+ Lead(主旋律)→ 左ch, Backing(ハモリ)→ 右ch
637
+ 未指定の場合:
638
+ Mid/Side 分解(フォールバック)
639
+ """
640
+ n_samples = max(s.shape[0] for s in stems.values())
641
+
642
+ def pad(arr: np.ndarray) -> np.ndarray:
643
+ arr = to_stereo(arr)
644
+ if arr.shape[0] < n_samples:
645
+ arr = np.pad(arr, ((0, n_samples - arr.shape[0]), (0, 0)))
646
+ return arr[:n_samples]
647
+
648
+ mix = np.zeros((n_samples, 2), dtype=np.float32)
649
+
650
+ if "vocals" in stems:
651
+ vocals_padded = pad(stems["vocals"])
652
+
653
+ if mdx_model_path is not None:
654
+ try:
655
+ lead, backing = mdx_separate_lead_backing(vocals_padded, sr, mdx_model_path)
656
+ # 長さを揃える
657
+ L = min(lead.shape[0], n_samples)
658
+ lead_out = np.zeros((n_samples, 2), dtype=np.float32)
659
+ backing_out = np.zeros((n_samples, 2), dtype=np.float32)
660
+ lead_out[:L] = lead[:L]
661
+ backing_out[:L] = backing[:L]
662
+ print("[INFO] パンニング: Lead(主旋律) → L, Backing(ハモリ) → R")
663
+ mix += pan_to_left(lead_out)
664
+ mix += pan_to_right(backing_out)
665
+ except Exception as e:
666
+ print(f"[WARN] UVR 推論失敗 ({e})。Mid/Side にフォールバックします。")
667
+ main_vocal, harmony = mid_side_split(vocals_padded)
668
+ mix += pan_to_left(main_vocal)
669
+ mix += pan_to_right(harmony)
670
+ else:
671
+ print("[INFO] vocals を Mid/Side 分解: Mid → L(主旋律), Side → R(ハモリ)")
672
+ main_vocal, harmony = mid_side_split(vocals_padded)
673
+ mix += pan_to_left(main_vocal)
674
+ mix += pan_to_right(harmony)
675
+
676
+ # vocals 以外はすべて伴奏としてセンターに縮小
677
+ inst_keys = [k for k in stems if k != "vocals"]
678
+ if inst_vol > 0 and inst_keys:
679
+ print(f"[INFO] 伴奏ステム {inst_keys} → センター x {inst_vol}")
680
+ for k in inst_keys:
681
+ mix += pad(stems[k]) * inst_vol
682
+ elif inst_keys:
683
+ print(f"[INFO] 伴奏ステム {inst_keys} → ミュート (inst-vol=0)")
684
+ else:
685
+ print("[WARN] 'vocals' ステムが見つかりません。全ステムをセンター出力します。")
686
+ for k, arr in stems.items():
687
+ mix += pad(arr)
688
+
689
+ # ピーク正規化(クリッピング防止)
690
+ peak = float(np.max(np.abs(mix)))
691
+ if peak > 1e-6:
692
+ mix = mix / peak * 0.95
693
+ return mix
694
+
695
+
696
+
697
+ # ── メインフロー ──────────────────────────────────────────────────
698
+
699
+
700
+ def main():
701
+ parser = argparse.ArgumentParser(
702
+ description="主旋律 / ハモリ 分離 & L/R パンニング ツール",
703
+ formatter_class=argparse.RawDescriptionHelpFormatter,
704
+ epilog="""
705
+ 例:
706
+ python app.py song.mp3
707
+ python app.py movie.mp4 --output result.wav
708
+ python app.py https://www.youtube.com/watch?v=XXXXXXXXXXX
709
+ python app.py song.mp3 --inst-vol 0.0 # 伴奏ミュート
710
+ python app.py song.mp3 --model htdemucs # 軽量モデル
711
+ """,
712
+ )
713
+ parser.add_argument("input", help="入力ファイル (mp3/wav/mp4/mov など) または URL (YouTube など)")
714
+ parser.add_argument(
715
+ "--output", default="output_panned.wav",
716
+ help="出力 WAV ファイル名 (デフォルト: output_panned.wav)",
717
+ )
718
+ parser.add_argument(
719
+ "--inst-vol", type=float, default=0.15,
720
+ help="伴奏の音量係数 0.0(ミュート)〜1.0 (デフォルト: 0.15)",
721
+ )
722
+ parser.add_argument(
723
+ "--model",
724
+ default="htdemucs_6s",
725
+ choices=["htdemucs_6s", "htdemucs", "mdx_extra", "mdx"],
726
+ help="Demucs モデル (デフォルト: htdemucs_6s)",
727
+ )
728
+ parser.add_argument(
729
+ "--no-mdx", action="store_true",
730
+ help="UVR MDX-NET によるリード/バッキング分離をスキップし Mid/Side 分解を使用",
731
+ )
732
+ args = parser.parse_args()
733
+
734
+ # URL かファイルパスか判定
735
+ input_arg = args.input
736
+ if is_url(input_arg):
737
+ # URL の場合はファイル存在チェック不要
738
+ input_for_process = input_arg
739
+ else:
740
+ input_path = Path(input_arg).resolve()
741
+ if not input_path.exists():
742
+ print(f"[ERROR] ファイルが見つかりません: {input_path}")
743
+ sys.exit(1)
744
+ input_for_process = input_path
745
+
746
+ tmp_dir = Path(tempfile.mkdtemp(prefix="musicbot_"))
747
+ demucs_out = tmp_dir / "demucs_out"
748
+ demucs_out.mkdir(parents=True, exist_ok=True)
749
+
750
+ try:
751
+ # Step 1: 音声準備(URL またはファイル)
752
+ wav_path = prepare_audio(input_for_process, tmp_dir)
753
+
754
+ # Step 2: Demucs 分離
755
+ model = args.model
756
+ stems, sr = run_demucs(wav_path, demucs_out, model)
757
+
758
+ # htdemucs_6s で 'other' がない場合は htdemucs にフォールバック
759
+ if model == "htdemucs_6s" and "other" not in stems:
760
+ print("\n[WARN] htdemucs_6s で 'other' ステムが見つかりません。htdemucs に切り替えます。")
761
+ shutil.rmtree(demucs_out)
762
+ demucs_out.mkdir(parents=True, exist_ok=True)
763
+ model = "htdemucs"
764
+ stems, sr = run_demucs(wav_path, demucs_out, model)
765
+
766
+ # Step 3: UVR MDX-NET モデルを取得(必要なら)
767
+ mdx_model_path = None
768
+ if not args.no_mdx:
769
+ mdx_cache = Path.home() / ".cache" / "musicbot" / "models"
770
+ mdx_cache.mkdir(parents=True, exist_ok=True)
771
+ try:
772
+ mdx_model_path = download_mdx_model(mdx_cache)
773
+ except Exception as e:
774
+ print(f"[WARN] MDX モデルの取得に失敗しました: {e}")
775
+ print(" Mid/Side 分解コードにフォールバックします。")
776
+
777
+ # Step 4: パンニング & ミックス
778
+ print("\n[INFO] L/R パンニング & ミックス処理中...")
779
+ mixed = mix_stems(stems, model, args.inst_vol, mdx_model_path=mdx_model_path, sr=sr)
780
+
781
+ # Step 4: 出力
782
+ output_path = Path(args.output).resolve()
783
+ sf.write(str(output_path), mixed, sr, subtype="PCM_16")
784
+
785
+ print(f"""
786
+ ╔══════════════════════════════════════╗
787
+ ║ ✅ 処理完了! ║
788
+ ╠══════════════════════════════════════╣
789
+ ║ 出力: {output_path.name:<31}║
790
+ ║ 左チャンネル (L): 主旋律 ║
791
+ ║ 右チャンネル (R): ハモリ/コーラス ║
792
+ ║ 伴奏音量係数 : {args.inst_vol:<22.2f}║
793
+ ╚══════════════════════════════════════╝
794
+ → {output_path}
795
+ """)
796
+
797
+ except KeyboardInterrupt:
798
+ print("\n[INFO] ユーザーによって中断されました。")
799
+ sys.exit(0)
800
+ except Exception as e:
801
+ print(f"\n[ERROR] 処理中にエラーが発生しました: {e}")
802
+ import traceback
803
+ traceback.print_exc()
804
+ sys.exit(1)
805
+ finally:
806
+ shutil.rmtree(tmp_dir, ignore_errors=True)
807
+
808
+
809
+ if __name__ == "__main__":
810
+ main()
requirements.txt ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ============================
2
+ # 音源分離 & パンニング アプリ
3
+ # ============================
4
+
5
+ # 音声・動画処理
6
+ ffmpeg-python>=0.2.0 # 動画→音声抽出
7
+ soundfile>=0.12.1 # WAV 読み書き
8
+ numpy>=1.24.0 # 配列演算
9
+ scipy>=1.10.0 # リサンプリング
10
+ librosa>=0.10.0 # 音声解析ユーティリティ
11
+
12
+ # AI 音源分離
13
+ demucs>=4.0.0 # Meta Demucs (htdemucs / htdemucs_6s)
14
+
15
+ # PyTorch (CPU版 / GPU版はREADMEを参照)
16
+ # CPU 環境:
17
+ torch>=2.0.0
18
+ torchaudio>=2.0.0
19
+
20
+ # 進捗表示
21
+ tqdm>=4.65.0
22
+
23
+ # YouTube / Web音源ダウンロード (CLI利用時のみ)
24
+ yt-dlp>=2024.1.0
25
+
26
+ # Web アプリ
27
+ flask>=3.0.0
28
+ flask-cors>=4.0.0
29
+
30
+ # 月額決済
31
+ stripe>=7.0.0
32
+
33
+ # MDX モデルダウンロード
34
+ onnxruntime>=1.16.0
35
+ huggingface_hub>=0.20.0
server.py ADDED
@@ -0,0 +1,410 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ server.py — HarmoSplit バックエンド
3
+ Hugging Face Spaces 対応(ポート 7860)+ Stripe 月額決済
4
+ """
5
+
6
+ import os
7
+ import sys
8
+ import uuid
9
+ import json
10
+ import shutil
11
+ import secrets
12
+ import tempfile
13
+ import threading
14
+ import subprocess
15
+ from pathlib import Path
16
+ from datetime import datetime, timezone
17
+
18
+ from flask import (
19
+ Flask, request, jsonify, send_file,
20
+ Response, send_from_directory, redirect, url_for
21
+ )
22
+ from flask_cors import CORS
23
+
24
+ sys.path.insert(0, str(Path(__file__).parent))
25
+ import app as core
26
+
27
+ # ── Flask ──────────────────────────────────────────────────────
28
+ flask_app = Flask(__name__, static_folder="static", static_url_path="")
29
+ CORS(flask_app)
30
+
31
+ # ── 設定 ──────────────────────────────────────────────────────
32
+ PORT = int(os.environ.get("PORT", 7860))
33
+
34
+ # Stripe キー(HF Spaces Secrets または .env で設定)
35
+ STRIPE_SECRET_KEY = os.environ.get("STRIPE_SECRET_KEY", "")
36
+ STRIPE_WEBHOOK_SECRET = os.environ.get("STRIPE_WEBHOOK_SECRET", "")
37
+ STRIPE_PRICE_ID = os.environ.get("STRIPE_PRICE_ID", "")
38
+ # アプリの公開 URL(Webhook / Checkout success URL 用)
39
+ APP_URL = os.environ.get("APP_URL", f"http://localhost:{PORT}")
40
+
41
+ # 決済不要モード(Stripe キー未設定なら無料開放)
42
+ FREE_MODE = not bool(STRIPE_SECRET_KEY)
43
+
44
+ # ── 永続ストレージ ──────────────────────────────────────────
45
+ # HF Spaces では /data が永続。ローカルでは ./data を使用。
46
+ DATA_DIR = Path("/data") if Path("/data").exists() else Path("./data")
47
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
48
+
49
+ TOKENS_FILE = DATA_DIR / "tokens.json"
50
+ UPLOAD_DIR = DATA_DIR / "uploads"
51
+ UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
52
+
53
+ # ── トークン管理 ──────────────────────────────────────────────
54
+ tokens_lock = threading.Lock()
55
+
56
+ def load_tokens() -> dict:
57
+ with tokens_lock:
58
+ if TOKENS_FILE.exists():
59
+ try:
60
+ return json.loads(TOKENS_FILE.read_text("utf-8"))
61
+ except Exception:
62
+ pass
63
+ return {}
64
+
65
+ def save_tokens(data: dict):
66
+ with tokens_lock:
67
+ TOKENS_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False), "utf-8")
68
+
69
+ def create_token(customer_id: str, subscription_id: str, email: str) -> str:
70
+ token = secrets.token_urlsafe(32)
71
+ data = load_tokens()
72
+ data[token] = {
73
+ "customer_id": customer_id,
74
+ "subscription_id": subscription_id,
75
+ "email": email,
76
+ "created_at": datetime.now(timezone.utc).isoformat(),
77
+ "active": True,
78
+ }
79
+ save_tokens(data)
80
+ return token
81
+
82
+ def is_token_valid(token: str) -> bool:
83
+ if FREE_MODE:
84
+ return True # 無料モードは常に有効
85
+ data = load_tokens()
86
+ entry = data.get(token)
87
+ return bool(entry and entry.get("active"))
88
+
89
+ def deactivate_token_by_subscription(subscription_id: str):
90
+ data = load_tokens()
91
+ for info in data.values():
92
+ if info.get("subscription_id") == subscription_id:
93
+ info["active"] = False
94
+ save_tokens(data)
95
+
96
+ # ── ジョブ管理 ──────────────────────────────────────────────
97
+ JOBS: dict[str, dict] = {}
98
+ JOBS_LOCK = threading.Lock()
99
+
100
+ def log_progress(job_id: str, message: str, percent: int | None = None):
101
+ entry = {"msg": message}
102
+ if percent is not None:
103
+ entry["pct"] = percent
104
+ with JOBS_LOCK:
105
+ if job_id in JOBS:
106
+ JOBS[job_id]["progress"].append(entry)
107
+
108
+ def _load_stems_helper(demucs_out: Path, model: str, wav_path: Path):
109
+ import soundfile as sf
110
+ import numpy as np
111
+ stem_dir = demucs_out / model / wav_path.stem
112
+ if not stem_dir.exists():
113
+ candidates = list(demucs_out.rglob("*.wav"))
114
+ if not candidates:
115
+ raise FileNotFoundError("ステムファイルが見つかりません")
116
+ stem_dir = candidates[0].parent
117
+ stems = {}
118
+ sr = 44100
119
+ for wav_file in sorted(stem_dir.glob("*.wav")):
120
+ data, sr = sf.read(str(wav_file), always_2d=True)
121
+ stems[wav_file.stem] = data.astype(np.float_())
122
+ return stems, sr
123
+
124
+ core._load_stems = _load_stems_helper
125
+
126
+ def process_job(job_id: str, input_path: Path, inst_vol: float, model: str, use_mdx: bool):
127
+ tmp_dir = Path(tempfile.mkdtemp(prefix=f"hmsplit_{job_id[:8]}_"))
128
+ demucs_out = tmp_dir / "demucs_out"
129
+ demucs_out.mkdir(parents=True, exist_ok=True)
130
+ try:
131
+ with JOBS_LOCK:
132
+ JOBS[job_id]["status"] = "processing"
133
+
134
+ log_progress(job_id, "🎵 音声を読み込��中...", 5)
135
+ wav_path = core.prepare_audio(input_path, tmp_dir)
136
+
137
+ log_progress(job_id, "🤖 Demucs AI で音源を分離中...", 15)
138
+ cmd = [sys.executable, "-m", "demucs", "-n", model,
139
+ "-o", str(demucs_out), str(wav_path)]
140
+ proc = subprocess.Popen(
141
+ cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
142
+ text=True, encoding="utf-8", errors="replace"
143
+ )
144
+ for line in proc.stdout:
145
+ line = line.rstrip()
146
+ if line.strip():
147
+ log_progress(job_id, line)
148
+ proc.wait()
149
+ if proc.returncode != 0:
150
+ raise RuntimeError("Demucs 処理に失敗しました")
151
+
152
+ log_progress(job_id, "📂 ステムを読み込み中...", 55)
153
+ stems, sr = core._load_stems(demucs_out, model, wav_path)
154
+
155
+ if model == "htdemucs_6s" and "vocals" not in stems:
156
+ log_progress(job_id, "⚠️ htdemucs にフォールバック", 57)
157
+ shutil.rmtree(demucs_out); demucs_out.mkdir(parents=True, exist_ok=True)
158
+ model = "htdemucs"
159
+ subprocess.run([sys.executable, "-m", "demucs", "-n", model,
160
+ "-o", str(demucs_out), str(wav_path)], check=True, capture_output=True)
161
+ stems, sr = core._load_stems(demucs_out, model, wav_path)
162
+
163
+ mdx_model_path = None
164
+ if use_mdx:
165
+ log_progress(job_id, "🧠 UVR MDX-NET モデルを準備中...", 60)
166
+ try:
167
+ mdx_cache = DATA_DIR / "models"
168
+ mdx_cache.mkdir(parents=True, exist_ok=True)
169
+ mdx_model_path = core.download_mdx_model(mdx_cache)
170
+ log_progress(job_id, "✅ UVR MDX-NET 準備完了", 65)
171
+ except Exception as e:
172
+ log_progress(job_id, f"⚠️ MDX 取得失敗、Mid/Side にフォールバック: {e}", 65)
173
+
174
+ log_progress(job_id, "🎚️ L/R パンニング & ミックス処理中...", 68)
175
+ if mdx_model_path:
176
+ log_progress(job_id, "🔬 リード / バッキング AI 分離中... (数分かかります)", 70)
177
+ mixed = core.mix_stems(stems, model, inst_vol, mdx_model_path=mdx_model_path, sr=sr)
178
+
179
+ log_progress(job_id, "💾 WAV を書き出し中...", 95)
180
+ import soundfile as sf
181
+ output_path = UPLOAD_DIR / f"{job_id}_panned.wav"
182
+ sf.write(str(output_path), mixed, sr, subtype="PCM_16")
183
+
184
+ with JOBS_LOCK:
185
+ JOBS[job_id]["status"] = "done"
186
+ JOBS[job_id]["output_path"] = str(output_path)
187
+ log_progress(job_id, "✅ 処理完了!ダウンロードボタンをクリックしてください。", 100)
188
+
189
+ except Exception as e:
190
+ import traceback
191
+ with JOBS_LOCK:
192
+ JOBS[job_id]["status"] = "error"
193
+ JOBS[job_id]["error"] = str(e)
194
+ log_progress(job_id, f"❌ {e}")
195
+ finally:
196
+ shutil.rmtree(tmp_dir, ignore_errors=True)
197
+ try:
198
+ input_path.unlink(missing_ok=True)
199
+ except Exception:
200
+ pass
201
+
202
+ # ── ルーティング ────────────────────────────────────────────
203
+
204
+ @flask_app.route("/")
205
+ def index():
206
+ return send_from_directory("static", "index.html")
207
+
208
+ @flask_app.route("/pricing")
209
+ def pricing():
210
+ return send_from_directory("static", "pricing.html")
211
+
212
+ @flask_app.route("/success")
213
+ def success():
214
+ return send_from_directory("static", "success.html")
215
+
216
+ @flask_app.route("/auth-mode")
217
+ def auth_mode():
218
+ """フロントエンドが無料/有料モードを識別するためのエンドポイント"""
219
+ return jsonify({"free_mode": FREE_MODE})
220
+
221
+ @flask_app.route("/pricing-info")
222
+ def pricing_info():
223
+ """料金ページ用: Stripe から Price 情報を取得して返す"""
224
+ if FREE_MODE:
225
+ return jsonify({"price": 0, "currency": "jpy", "free_mode": True})
226
+ try:
227
+ import stripe
228
+ stripe.api_key = STRIPE_SECRET_KEY
229
+ price = stripe.Price.retrieve(STRIPE_PRICE_ID)
230
+ return jsonify({
231
+ "price": price.unit_amount,
232
+ "currency": price.currency,
233
+ "interval": price.recurring.interval if price.recurring else "month",
234
+ })
235
+ except Exception as e:
236
+ return jsonify({"error": str(e)}), 500
237
+
238
+ # ── Stripe: Checkout セッション作成 ──────────────────────────
239
+ @flask_app.route("/create-checkout", methods=["POST"])
240
+ def create_checkout():
241
+ if FREE_MODE:
242
+ return jsonify({"error": "Stripe 未設定(開発モード)"}), 400
243
+ try:
244
+ import stripe
245
+ stripe.api_key = STRIPE_SECRET_KEY
246
+ session = stripe.checkout.Session.create(
247
+ payment_method_types=["card"],
248
+ line_items=[{"price": STRIPE_PRICE_ID, "quantity": 1}],
249
+ mode="subscription",
250
+ success_url=f"{APP_URL}/success?session_id={{CHECKOUT_SESSION_ID}}",
251
+ cancel_url=f"{APP_URL}/pricing",
252
+ )
253
+ return jsonify({"url": session.url})
254
+ except Exception as e:
255
+ return jsonify({"error": str(e)}), 500
256
+
257
+ # ── Stripe: Webhook ──────────────────────────────────────────
258
+ @flask_app.route("/webhook", methods=["POST"])
259
+ def stripe_webhook():
260
+ if FREE_MODE:
261
+ return "", 200
262
+ try:
263
+ import stripe
264
+ stripe.api_key = STRIPE_SECRET_KEY
265
+ payload = request.get_data()
266
+ sig_header = request.headers.get("Stripe-Signature", "")
267
+ event = stripe.Webhook.construct_event(payload, sig_header, STRIPE_WEBHOOK_SECRET)
268
+ except Exception as e:
269
+ return jsonify({"error": str(e)}), 400
270
+
271
+ etype = event["type"]
272
+ obj = event["data"]["object"]
273
+
274
+ if etype == "checkout.session.completed":
275
+ sub_id = obj.get("subscription")
276
+ cust_id = obj.get("customer")
277
+ email = obj.get("customer_email") or obj.get("customer_details", {}).get("email", "")
278
+ token = create_token(cust_id, sub_id, email)
279
+ print(f"[WEBHOOK] 新サブスクリプション: {email} → token={token[:8]}...")
280
+
281
+ elif etype in ("customer.subscription.deleted", "customer.subscription.paused"):
282
+ sub_id = obj.get("id")
283
+ deactivate_token_by_subscription(sub_id)
284
+ print(f"[WEBHOOK] サブスクリプション停止: {sub_id}")
285
+
286
+ return "", 200
287
+
288
+ # ── Stripe: 成功後トークン取得 ────────────────────────────────
289
+ @flask_app.route("/get-token")
290
+ def get_token():
291
+ """決済完了後に Stripe Session ID からトークンを返す"""
292
+ if FREE_MODE:
293
+ return jsonify({"token": "FREE_MODE"})
294
+ session_id = request.args.get("session_id", "")
295
+ if not session_id:
296
+ return jsonify({"error": "session_id が必要です"}), 400
297
+ try:
298
+ import stripe
299
+ stripe.api_key = STRIPE_SECRET_KEY
300
+ session = stripe.checkout.Session.retrieve(session_id)
301
+ sub_id = session.get("subscription")
302
+ cust_id = session.get("customer")
303
+ email = session.get("customer_details", {}).get("email", "")
304
+ # 既存トークンを探す(Webhook が先に処理している場合)
305
+ data = load_tokens()
306
+ for tok, info in data.items():
307
+ if info.get("subscription_id") == sub_id:
308
+ return jsonify({"token": tok, "email": email})
309
+ # Webhook がまだなら作成
310
+ token = create_token(cust_id, sub_id, email)
311
+ return jsonify({"token": token, "email": email})
312
+ except Exception as e:
313
+ return jsonify({"error": str(e)}), 500
314
+
315
+ # ── トークン検証 ──────────────────────────────────────────────
316
+ @flask_app.route("/verify-token", methods=["POST"])
317
+ def verify_token():
318
+ token = request.json.get("token", "")
319
+ return jsonify({"valid": is_token_valid(token)})
320
+
321
+ # ── ファイルアップロード & 処理 ────────────────────────────────
322
+ @flask_app.route("/upload", methods=["POST"])
323
+ def upload():
324
+ # トークン認証
325
+ token = request.form.get("token", "")
326
+ if not is_token_valid(token):
327
+ return jsonify({"error": "無効なトークンです。料金ページから登録してください。"}), 401
328
+
329
+ if "file" not in request.files:
330
+ return jsonify({"error": "ファイルがありません"}), 400
331
+ file = request.files["file"]
332
+ if not file.filename:
333
+ return jsonify({"error": "ファイル名が空です"}), 400
334
+
335
+ inst_vol = float(request.form.get("inst_vol", 0.15))
336
+ model = request.form.get("model", "htdemucs_6s")
337
+ use_mdx = request.form.get("use_mdx", "true").lower() == "true"
338
+
339
+ job_id = str(uuid.uuid4())
340
+ suffix = Path(file.filename).suffix
341
+ input_path = UPLOAD_DIR / f"{job_id}_input{suffix}"
342
+ file.save(str(input_path))
343
+
344
+ with JOBS_LOCK:
345
+ JOBS[job_id] = {
346
+ "status": "queued", "progress": [],
347
+ "output_path": None, "error": None,
348
+ "filename": file.filename,
349
+ }
350
+
351
+ t = threading.Thread(
352
+ target=process_job,
353
+ args=(job_id, input_path, inst_vol, model, use_mdx),
354
+ daemon=True,
355
+ )
356
+ t.start()
357
+ return jsonify({"job_id": job_id})
358
+
359
+ # ── 進捗 SSE ──────────────────────────────────────────────────
360
+ @flask_app.route("/progress/<job_id>")
361
+ def progress(job_id: str):
362
+ def generate():
363
+ import time
364
+ sent = 0
365
+ while True:
366
+ with JOBS_LOCK:
367
+ if job_id not in JOBS:
368
+ yield 'data: {"error":"not found"}\n\n'; return
369
+ job = JOBS[job_id]
370
+ new_ents = job["progress"][sent:]
371
+ sent += len(new_ents)
372
+ status = job["status"]
373
+ for e in new_ents:
374
+ yield f"data: {json.dumps(e, ensure_ascii=False)}\n\n"
375
+ if status in ("done", "error"):
376
+ yield f"data: {json.dumps({'status': status})}\n\n"; return
377
+ time.sleep(0.5)
378
+ return Response(generate(), mimetype="text/event-stream",
379
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
380
+
381
+ @flask_app.route("/status/<job_id>")
382
+ def status(job_id: str):
383
+ with JOBS_LOCK:
384
+ job = JOBS.get(job_id)
385
+ if not job:
386
+ return jsonify({"error": "not found"}), 404
387
+ return jsonify({"status": job["status"], "error": job.get("error")})
388
+
389
+ # ── ダウンロード ──────────────────────────────────────────────
390
+ @flask_app.route("/download/<job_id>")
391
+ def download(job_id: str):
392
+ with JOBS_LOCK:
393
+ job = JOBS.get(job_id)
394
+ if not job or job["status"] != "done":
395
+ return jsonify({"error": "ファイル未準備"}), 404
396
+ output_path = Path(job["output_path"])
397
+ if not output_path.exists():
398
+ return jsonify({"error": "ファイルが見つかりません"}), 404
399
+ download_name = Path(job.get("filename", "audio")).stem + "_panned.wav"
400
+ return send_file(str(output_path), as_attachment=True,
401
+ download_name=download_name, mimetype="audio/wav")
402
+
403
+ # ── 起動 ──────────────────────────────────────────────────────
404
+ if __name__ == "__main__":
405
+ mode = "FREE(Stripe 未設定)" if FREE_MODE else "有料(Stripe 有効)"
406
+ print("=" * 52)
407
+ print(f"🎵 HarmoSplit 起動中... モード: {mode}")
408
+ print(f" http://localhost:{PORT}")
409
+ print("=" * 52)
410
+ flask_app.run(host="0.0.0.0", port=PORT, debug=False, threaded=True)
setup_guide.md ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 🎵 音源分離 & L/Rパンニング アプリ セットアップガイド
2
+
3
+ ## 概要
4
+
5
+ このアプリは音楽/動画ファイルから AI(Demucs)を使って **主旋律(メインボーカル)** と **ハモリ(コーラス)** を分離し、左右のイヤホンで聴き分けられるステレオ WAV を出力します。
6
+
7
+ ```
8
+ 左チャンネル (L) ───── 主旋律 (メインボーカル)
9
+ 右チャンネル (R) ───── ハモリ / コーラス
10
+ センター (L+R) ──────── 伴奏(音量縮小)
11
+ ```
12
+
13
+ ---
14
+
15
+ ## ① ffmpeg のインストール(OS別)
16
+
17
+ ffmpeg はシステムにインストールされている必要があります。
18
+
19
+ ### Windows
20
+
21
+ ```powershell
22
+ # winget を使う方法(推奨)
23
+ winget install Gyan.FFmpeg
24
+
25
+ # または Chocolatey を使う方法
26
+ choco install ffmpeg
27
+
28
+ # インストール確認
29
+ ffmpeg -version
30
+ ```
31
+
32
+ > **手動インストールの場合:**
33
+ > 1. https://ffmpeg.org/download.html → Windows → `ffmpeg-release-full.7z` をダウンロード
34
+ > 2. 解凍して `C:\ffmpeg\bin` を システムの環境変数 `PATH` に追加
35
+
36
+ ### macOS
37
+
38
+ ```bash
39
+ # Homebrew を使う方法(推奨)
40
+ brew install ffmpeg
41
+
42
+ # インストール確認
43
+ ffmpeg -version
44
+ ```
45
+
46
+ ### Linux (Ubuntu / Debian)
47
+
48
+ ```bash
49
+ sudo apt update && sudo apt install -y ffmpeg
50
+
51
+ # インストール確認
52
+ ffmpeg -version
53
+ ```
54
+
55
+ ---
56
+
57
+ ## ② Python 仮想環境のセットアップ
58
+
59
+ ```bash
60
+ # プロジェクトディレクトリへ移動
61
+ cd c:\musicbot # Windows
62
+ # cd /path/to/musicbot # macOS / Linux
63
+
64
+ # 仮想環境を作成 (Python 3.10 以上推奨)
65
+ python -m venv venv
66
+
67
+ # 仮想環境を有効化
68
+ # Windows
69
+ venv\Scripts\activate
70
+
71
+ # macOS / Linux
72
+ source venv/bin/activate
73
+ ```
74
+
75
+ ---
76
+
77
+ ## ③ 依存ライブラリのインストール
78
+
79
+ ### CPU 環境(GPU なし)の場合
80
+
81
+ ```bash
82
+ pip install --upgrade pip
83
+
84
+ # PyTorch CPU 版を先にインストール(重要: GPU版と混在しないよう)
85
+ pip install torch torchaudio --index-url https://download.pytorch.org/whl/cpu
86
+
87
+ # その他ライブラリをインストール
88
+ pip install -r requirements.txt
89
+ ```
90
+
91
+ ### GPU 環境(NVIDIA CUDA)の場合(オプション・高速化)
92
+
93
+ ```bash
94
+ pip install --upgrade pip
95
+
96
+ # CUDA 12.1 対応の場合(自分の CUDA バージョンに合わせて変更)
97
+ pip install torch torchaudio --index-url https://download.pytorch.org/whl/cu121
98
+
99
+ # その他ライブラリをインストール
100
+ pip install -r requirements.txt
101
+ ```
102
+
103
+ > GPU があると Demucs の処理が **数倍〜10倍** 速くなります。
104
+
105
+ ---
106
+
107
+ ## ④ アプリの実行
108
+
109
+ ### 基本的な使い方
110
+
111
+ ```bash
112
+ # 音声ファイル (mp3/wav) を処理
113
+ python app.py input.mp3
114
+
115
+ # 動画ファイル (mp4/mov) を処理(音声が自動抽出されます)
116
+ python app.py input.mp4
117
+
118
+ # 出力ファイル名を指定
119
+ python app.py input.mp3 --output my_output.wav
120
+
121
+ # 伴奏をミュートしたい場合
122
+ python app.py input.mp3 --inst-vol 0.0
123
+
124
+ # 伴奏を少し大きくしたい場合
125
+ python app.py input.mp3 --inst-vol 0.3
126
+
127
+ # モデルを指定する場合(デフォルトは htdemucs_6s)
128
+ python app.py input.mp3 --model htdemucs
129
+ ```
130
+
131
+ ### コマンドライン引数一覧
132
+
133
+ | 引数 | 説明 | デフォルト |
134
+ |---|---|---|
135
+ | `input` | 入力ファイルパス(必須) | — |
136
+ | `--output` | 出力 WAV ファイル名 | `output_panned.wav` |
137
+ | `--inst-vol` | 伴奏音量(0.0〜1.0) | `0.15` |
138
+ | `--model` | Demucs モデル名 | `htdemucs_6s` |
139
+
140
+ ### 使用可能なモデル
141
+
142
+ | モデル名 | ステム数 | 特徴 |
143
+ |---|---|---|
144
+ | `htdemucs_6s` | 6 (vocals/drums/bass/guitar/piano/other) | **推奨**。vocals と other(コーラス)を独立分離 |
145
+ | `htdemucs` | 4 (vocals/drums/bass/other) | 軽量版。ハモリはLR差分で推定 |
146
+ | `mdx_extra` | 4 | MDX アーキテクチャ。ボーカル品質重視 |
147
+
148
+ ---
149
+
150
+ ## ⑤ 初回実行について
151
+
152
+ 初回実行時は **Demucs のモデルファイルが自動ダウンロード** されます(数百MB)。
153
+ キャッシュは `~/.cache/torch/hub/` に保存されるため、次回以降は高速です。
154
+
155
+ ---
156
+
157
+ ## ⑥ トラブルシューティング
158
+
159
+ ### `ffmpeg not found` エラーが出る
160
+
161
+ → ①の手順で ffmpeg をインストールし、`ffmpeg -version` が動くことを確認してください。
162
+
163
+ ### `torch` のインポートエラー
164
+
165
+ → 仮想環境が有効化されているか確認 (`venv\Scripts\activate`)。
166
+ → `pip install torch torchaudio --index-url https://download.pytorch.org/whl/cpu` を再実行。
167
+
168
+ ### メモリ不足 (OOM) エラー
169
+
170
+ ```bash
171
+ # CPU モードを明示的に指定
172
+ python app.py input.mp3 --model htdemucs
173
+ ```
174
+ CPU の場合 htdemucs は約 **4〜8GB RAM** を使用します。
175
+
176
+ ### 処理が遅い
177
+
178
+ Demucs は計算量が多く、CPU 環境では数分かかります。
179
+ GPU (CUDA) 環境では大幅に高速化されます。
180
+
181
+ ---
182
+
183
+ ## ⑦ 出力ファイ��の確認
184
+
185
+ 処理完了後、`output_panned.wav` が生成されます。
186
+ ステレオ対応のヘッドフォン/イヤホンで再生すると:
187
+
188
+ - **左耳** → 主旋律(メインボーカル)が聴こえる
189
+ - **右耳** → ハモリ / コーラスが聴こえる
190
+
191
+ > 元の音源にハモリ録音がない場合や、モノラル録音の場合は
192
+ > LR の差が小さくなることがあります。
static/app.js ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // app.js — HarmoSplit フロントエンドロジック(Stripe 認証対応)
2
+
3
+ const dropZone = document.getElementById('dropZone');
4
+ const fileInput = document.getElementById('fileInput');
5
+ const startBtn = document.getElementById('startBtn');
6
+ const instVol = document.getElementById('instVol');
7
+ const instVolLabel= document.getElementById('instVolLabel');
8
+ const modelSelect = document.getElementById('modelSelect');
9
+ const useMdx = document.getElementById('useMdx');
10
+
11
+ const uploadSection = document.getElementById('uploadSection');
12
+ const progressSection= document.getElementById('progressSection');
13
+ const resultSection = document.getElementById('resultSection');
14
+ const errorSection = document.getElementById('errorSection');
15
+
16
+ const progressBar = document.getElementById('progressBar');
17
+ const progressPct = document.getElementById('progressPct');
18
+ const progressFile = document.getElementById('progressFile');
19
+ const logBox = document.getElementById('logBox');
20
+ const downloadBtn = document.getElementById('downloadBtn');
21
+ const errorMsg = document.getElementById('errorMsg');
22
+
23
+ let selectedFile = null;
24
+ let verifiedToken = '';
25
+
26
+ // ── 起動時: Stripe 有効かどうか確認 ────────────────────────
27
+ async function initAuth() {
28
+ try {
29
+ const res = await fetch('/auth-mode');
30
+ const data = await res.json();
31
+ if (!data.free_mode) {
32
+ // 有料モード: トークンエリアを表示
33
+ document.getElementById('tokenArea').style.display = 'block';
34
+ // ローカルストレージに保存済みトークンがあれば復元
35
+ const saved = localStorage.getItem('hmsplit_token');
36
+ if (saved) {
37
+ document.getElementById('tokenInput').value = saved;
38
+ await verifyToken(saved, false);
39
+ }
40
+ } else {
41
+ // 無料モード: トークン不要
42
+ verifiedToken = 'FREE';
43
+ }
44
+ } catch (e) {
45
+ verifiedToken = 'FREE'; // エラー時は無料扱い
46
+ }
47
+ }
48
+
49
+ // ── トークン検証 ─────────────────────────────────────────────
50
+ async function verifyToken(token, showAlert = true) {
51
+ try {
52
+ const res = await fetch('/verify-token', {
53
+ method: 'POST',
54
+ headers: { 'Content-Type': 'application/json' },
55
+ body: JSON.stringify({ token }),
56
+ });
57
+ const data = await res.json();
58
+ if (data.valid) {
59
+ verifiedToken = token;
60
+ localStorage.setItem('hmsplit_token', token);
61
+ document.getElementById('tokenOk').classList.remove('hidden');
62
+ document.getElementById('tokenInput').style.borderColor = 'var(--success)';
63
+ } else {
64
+ verifiedToken = '';
65
+ localStorage.removeItem('hmsplit_token');
66
+ document.getElementById('tokenOk').classList.add('hidden');
67
+ document.getElementById('tokenInput').style.borderColor = 'var(--error)';
68
+ if (showAlert) alert('トークンが無効です。料金ページから登録してください。');
69
+ }
70
+ return data.valid;
71
+ } catch {
72
+ return false;
73
+ }
74
+ }
75
+
76
+ document.getElementById('verifyBtn')?.addEventListener('click', async () => {
77
+ const token = document.getElementById('tokenInput').value.trim();
78
+ if (!token) { alert('トークンを入力してください'); return; }
79
+ await verifyToken(token, true);
80
+ });
81
+
82
+ // ── ファイル選択 ──────────────────────────────────────────────
83
+ function selectFile(file) {
84
+ selectedFile = file;
85
+ dropZone.classList.add('has-file');
86
+ dropZone.querySelector('.drop-icon').textContent = fileIsVideo(file.name) ? '🎬' : '🎵';
87
+ const sub = dropZone.querySelector('.drop-sub');
88
+ sub.textContent = file.name;
89
+ sub.classList.add('drop-filename');
90
+ updateStartBtn();
91
+ }
92
+
93
+ function fileIsVideo(name) {
94
+ return /\.(mp4|mov|avi|mkv|m4v|flv|webm|ts)$/i.test(name);
95
+ }
96
+
97
+ function updateStartBtn() {
98
+ const hasFile = !!selectedFile;
99
+ const hasToken = !!verifiedToken;
100
+ startBtn.disabled = !(hasFile && hasToken);
101
+ }
102
+
103
+ dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('dragover'); });
104
+ dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
105
+ dropZone.addEventListener('drop', e => {
106
+ e.preventDefault(); dropZone.classList.remove('dragover');
107
+ if (e.dataTransfer.files.length) selectFile(e.dataTransfer.files[0]);
108
+ });
109
+ dropZone.addEventListener('click', () => fileInput.click());
110
+ fileInput.addEventListener('change', () => { if (fileInput.files.length) selectFile(fileInput.files[0]); });
111
+
112
+ instVol.addEventListener('input', () => {
113
+ instVolLabel.textContent = Math.round(instVol.value * 100) + '%';
114
+ });
115
+
116
+ // ── 処理開始 ──────────────────────────────────────────────────
117
+ startBtn.addEventListener('click', async () => {
118
+ if (!selectedFile || !verifiedToken) return;
119
+
120
+ uploadSection.classList.add('hidden');
121
+ progressSection.classList.remove('hidden');
122
+ logBox.innerHTML = '';
123
+ progressBar.style.width = '0%';
124
+ progressPct.textContent = '0%';
125
+ progressFile.textContent = selectedFile.name;
126
+
127
+ const fd = new FormData();
128
+ fd.append('file', selectedFile);
129
+ fd.append('inst_vol', instVol.value);
130
+ fd.append('model', modelSelect.value);
131
+ fd.append('use_mdx', useMdx.checked ? 'true' : 'false');
132
+ fd.append('token', verifiedToken === 'FREE' ? '' : verifiedToken);
133
+
134
+ let jobId;
135
+ try {
136
+ const res = await fetch('/upload', { method: 'POST', body: fd });
137
+ const data = await res.json();
138
+ if (!res.ok || data.error) throw new Error(data.error || 'アップロード失敗');
139
+ jobId = data.job_id;
140
+ } catch (e) {
141
+ showError(e.message); return;
142
+ }
143
+
144
+ const sse = new EventSource(`/progress/${jobId}`);
145
+ sse.addEventListener('message', e => {
146
+ let payload;
147
+ try { payload = JSON.parse(e.data); } catch { return; }
148
+ if (payload.status) {
149
+ sse.close();
150
+ if (payload.status === 'done') showResult(jobId);
151
+ else showError('処理中にエラーが発生しました。ログを確認してください。');
152
+ return;
153
+ }
154
+ if (payload.msg) {
155
+ const p = document.createElement('p');
156
+ p.textContent = payload.msg;
157
+ if (payload.msg.includes('✅') || payload.msg.includes('完了')) p.classList.add('ok');
158
+ if (payload.msg.includes('❌')) p.classList.add('err');
159
+ logBox.appendChild(p);
160
+ logBox.scrollTop = logBox.scrollHeight;
161
+ }
162
+ if (payload.pct !== undefined) {
163
+ progressBar.style.width = payload.pct + '%';
164
+ progressPct.textContent = payload.pct + '%';
165
+ }
166
+ });
167
+
168
+ sse.onerror = () => {
169
+ sse.close();
170
+ setTimeout(async () => {
171
+ try {
172
+ const res = await fetch(`/status/${jobId}`);
173
+ const data = await res.json();
174
+ if (data.status === 'done') showResult(jobId);
175
+ else showError(data.error || '接続エラー');
176
+ } catch { showError('サーバーとの接続が切れました'); }
177
+ }, 500);
178
+ };
179
+ });
180
+
181
+ function showResult(jobId) {
182
+ progressSection.classList.add('hidden');
183
+ resultSection.classList.remove('hidden');
184
+ downloadBtn.href = `/download/${jobId}`;
185
+ }
186
+
187
+ function showError(msg) {
188
+ progressSection.classList.add('hidden');
189
+ errorSection.classList.remove('hidden');
190
+ errorMsg.textContent = msg;
191
+ }
192
+
193
+ function reset() {
194
+ selectedFile = null;
195
+ fileInput.value = '';
196
+ dropZone.classList.remove('has-file', 'dragover');
197
+ dropZone.querySelector('.drop-icon').textContent = '🎵';
198
+ const sub = dropZone.querySelector('.drop-sub');
199
+ sub.textContent = 'または'; sub.classList.remove('drop-filename');
200
+ progressBar.style.width = '0%'; progressPct.textContent = '0%';
201
+ logBox.innerHTML = '';
202
+ uploadSection.classList.remove('hidden');
203
+ progressSection.classList.add('hidden');
204
+ resultSection.classList.add('hidden');
205
+ errorSection.classList.add('hidden');
206
+ updateStartBtn();
207
+ }
208
+
209
+ document.getElementById('resetBtn').addEventListener('click', reset);
210
+ document.getElementById('resetBtnErr').addEventListener('click', reset);
211
+
212
+ // 起動
213
+ initAuth().then(() => updateStartBtn());
static/index.html ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ja">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>HarmoSplit — 主旋律 / ハモリ 分離アプリ</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
9
+ <link rel="stylesheet" href="/style.css">
10
+ </head>
11
+ <body>
12
+ <div class="bg-orbs">
13
+ <div class="orb orb1"></div>
14
+ <div class="orb orb2"></div>
15
+ <div class="orb orb3"></div>
16
+ </div>
17
+
18
+ <main class="container">
19
+ <!-- ヘッダー -->
20
+ <header class="header">
21
+ <div class="logo">
22
+ <span class="logo-icon">🎧</span>
23
+ <span class="logo-text">HarmoSplit</span>
24
+ </div>
25
+ <p class="tagline">音楽ファイルから<strong>主旋律</strong>と<strong>ハモリ</strong>を AI が分離。<br>左右イヤホンで聴き分けられる WAV を生成します。</p>
26
+ </header>
27
+
28
+ <!-- アップロードカード -->
29
+ <section class="card upload-card" id="uploadSection">
30
+ <!-- トークン認証エリア(Stripe 設定時のみ表示) -->
31
+ <div class="token-area" id="tokenArea" style="display:none;">
32
+ <div class="token-row">
33
+ <input type="text" id="tokenInput" placeholder="アクセストークンを入力..."
34
+ autocomplete="off" spellcheck="false">
35
+ <button class="btn-verify" id="verifyBtn">確認</button>
36
+ </div>
37
+ <p class="token-hint">512 トークンをお持ちでない方は <a href="/pricing" class="pricing-link">料金ページ</a> から登録できます。</p>
38
+ <p class="token-ok hidden" id="tokenOk">✅ 認証済み</p>
39
+ </div>
40
+
41
+ <div class="drop-zone" id="dropZone">
42
+ <div class="drop-icon">🎵</div>
43
+ <p class="drop-text">音声・動画ファイルをドロップ</p>
44
+ <p class="drop-sub">または</p>
45
+ <label class="btn btn-outline" for="fileInput">ファイルを選択</label>
46
+ <input type="file" id="fileInput" accept=".mp3,.wav,.flac,.aac,.ogg,.m4a,.mp4,.mov,.avi,.mkv,.m4v,.webm" hidden>
47
+ <p class="drop-formats">MP3 / WAV / MP4 / MOV / MKV などに対応</p>
48
+ </div>
49
+
50
+ <!-- 設定パネル -->
51
+ <div class="settings">
52
+ <div class="setting-row">
53
+ <label for="instVol">伴奏音量</label>
54
+ <input type="range" id="instVol" min="0" max="1" step="0.05" value="0.15">
55
+ <span class="setting-value" id="instVolLabel">15%</span>
56
+ </div>
57
+ <div class="setting-row">
58
+ <label for="modelSelect">Demucs モデル</label>
59
+ <select id="modelSelect">
60
+ <option value="htdemucs_6s">htdemucs_6s(推奨・高品質)</option>
61
+ <option value="htdemucs">htdemucs(軽量・高速)</option>
62
+ </select>
63
+ </div>
64
+ <div class="setting-row">
65
+ <label for="useMdx">
66
+ UVR AI 分離(高精度)
67
+ <span class="badge">推奨</span>
68
+ </label>
69
+ <label class="toggle">
70
+ <input type="checkbox" id="useMdx" checked>
71
+ <span class="slider"></span>
72
+ </label>
73
+ </div>
74
+ </div>
75
+
76
+ <button class="btn btn-primary" id="startBtn" disabled>処理を開始</button>
77
+ </section>
78
+
79
+ <!-- 処理中カード -->
80
+ <section class="card progress-card hidden" id="progressSection">
81
+ <div class="progress-header">
82
+ <div class="spinner"></div>
83
+ <div>
84
+ <p class="progress-title">AI 処理中...</p>
85
+ <p class="progress-file" id="progressFile"></p>
86
+ </div>
87
+ </div>
88
+
89
+ <div class="progress-bar-wrap">
90
+ <div class="progress-bar" id="progressBar"></div>
91
+ </div>
92
+ <p class="progress-pct" id="progressPct">0%</p>
93
+
94
+ <div class="log-box" id="logBox"></div>
95
+ </section>
96
+
97
+ <!-- 完了カード -->
98
+ <section class="card result-card hidden" id="resultSection">
99
+ <div class="result-icon">✅</div>
100
+ <h2 class="result-title">処理完了!</h2>
101
+ <div class="channel-info">
102
+ <div class="channel-item left">
103
+ <span class="ch-label">L</span>
104
+ <span class="ch-desc">主旋律(メインボーカル)</span>
105
+ </div>
106
+ <div class="channel-item right">
107
+ <span class="ch-label">R</span>
108
+ <span class="ch-desc">ハモリ / バッキングボーカル</span>
109
+ </div>
110
+ </div>
111
+ <a class="btn btn-primary download-btn" id="downloadBtn" href="#" download>
112
+ <span>⬇️</span> WAV をダウンロード
113
+ </a>
114
+ <button class="btn btn-outline reset-btn" id="resetBtn">別のファイルを処理する</button>
115
+ </section>
116
+
117
+ <!-- エラーカード -->
118
+ <section class="card error-card hidden" id="errorSection">
119
+ <div class="result-icon">❌</div>
120
+ <h2 class="result-title">エラーが発生しました</h2>
121
+ <p class="error-msg" id="errorMsg"></p>
122
+ <button class="btn btn-outline reset-btn" id="resetBtnErr">もう一度試す</button>
123
+ </section>
124
+ </main>
125
+
126
+ <footer class="footer">
127
+ <p>🎧 HarmoSplit — Powered by <a href="https://github.com/facebookresearch/demucs" target="_blank">Demucs</a> &amp; UVR MDX-NET</p>
128
+ </footer>
129
+
130
+ <script src="/app.js"></script>
131
+ </body>
132
+ </html>
static/pricing.html ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ja">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>HarmoSplit — 料金プラン</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
9
+ <link rel="stylesheet" href="/style.css">
10
+ <style>
11
+ .pricing-hero { text-align: center; margin-bottom: 2.5rem; }
12
+ .pricing-hero h1 { font-size: 2rem; font-weight: 800; margin-bottom: .75rem;
13
+ background: linear-gradient(135deg, var(--accent), var(--accent2));
14
+ -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
15
+ .pricing-hero p { color: var(--text-muted); font-size: .95rem; }
16
+
17
+ .plan-card {
18
+ position: relative;
19
+ background: var(--surface);
20
+ border: 2px solid var(--accent);
21
+ border-radius: var(--radius);
22
+ padding: 2.25rem 2rem;
23
+ text-align: center;
24
+ backdrop-filter: blur(20px);
25
+ box-shadow: 0 0 40px rgba(124,106,247,.2);
26
+ }
27
+ .plan-badge {
28
+ position: absolute; top: -13px; left: 50%; transform: translateX(-50%);
29
+ background: linear-gradient(135deg, var(--accent), var(--accent2));
30
+ color: #fff; font-size: .75rem; font-weight: 700;
31
+ padding: .25rem 1rem; border-radius: 99px; letter-spacing: .06em;
32
+ white-space: nowrap;
33
+ }
34
+ .plan-name { font-size: 1.1rem; font-weight: 600; color: var(--text-muted); margin-bottom: .5rem; }
35
+ .plan-price {
36
+ font-size: 3.2rem; font-weight: 800; letter-spacing: -0.03em; margin: .5rem 0;
37
+ background: linear-gradient(135deg, var(--accent), var(--accent2));
38
+ -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
39
+ }
40
+ .plan-price span { font-size: 1rem; font-weight: 400; color: var(--text-muted); }
41
+ .plan-features { list-style: none; margin: 1.5rem 0 2rem; text-align: left; }
42
+ .plan-features li {
43
+ padding: .5rem 0; border-bottom: 1px solid var(--border);
44
+ font-size: .9rem; display: flex; align-items: center; gap: .6rem;
45
+ }
46
+ .plan-features li:last-child { border: none; }
47
+ .plan-features li::before { content: "✅"; flex-shrink: 0; }
48
+
49
+ .nav-link { color: var(--accent2); text-decoration: none; font-size: .85rem; }
50
+ .nav-link:hover { text-decoration: underline; }
51
+ .nav-bar { text-align: center; margin-bottom: 1.5rem; }
52
+ </style>
53
+ </head>
54
+ <body>
55
+ <div class="bg-orbs">
56
+ <div class="orb orb1"></div>
57
+ <div class="orb orb2"></div>
58
+ <div class="orb orb3"></div>
59
+ </div>
60
+
61
+ <main class="container">
62
+ <div class="nav-bar">
63
+ <a class="nav-link" href="/">← アプリに戻る</a>
64
+ </div>
65
+
66
+ <div class="pricing-hero">
67
+ <div class="logo" style="justify-content:center;margin-bottom:.75rem;">
68
+ <span class="logo-icon">🎧</span>
69
+ <span class="logo-text">HarmoSplit</span>
70
+ </div>
71
+ <h1>プランを選択してください</h1>
72
+ <p>月額サブスクリプションでいつでも解約可能</p>
73
+ </div>
74
+
75
+ <div class="plan-card">
76
+ <div class="plan-badge">🎵 スタンダードプラン</div>
77
+ <p class="plan-name">月額</p>
78
+ <p class="plan-price" id="priceDisplay">¥ - <span>/ 月</span></p>
79
+ <ul class="plan-features">
80
+ <li>音声・動画ファイルを無制限に処理</li>
81
+ <li>Demucs AI による 6 ステム分離</li>
82
+ <li>UVR MDX-NET KARA でリード / ハモリ分離</li>
83
+ <li>MP3 / WAV / MP4 / MOV など対応</li>
84
+ <li>処理完了後に WAV をダウンロード</li>
85
+ <li>いつでも解約可能</li>
86
+ </ul>
87
+ <button class="btn btn-primary" id="checkoutBtn">今すぐ始める →</button>
88
+ </div>
89
+
90
+ <p style="text-align:center;margin-top:1.25rem;font-size:.78rem;color:var(--text-muted);">
91
+ Stripe によるセキュアな決済 🔒<br>クレジットカード・デビットカード対応
92
+ </p>
93
+ </main>
94
+
95
+ <footer class="footer">
96
+ <p>🎧 HarmoSplit — Powered by Demucs &amp; UVR MDX-NET</p>
97
+ </footer>
98
+
99
+ <script>
100
+ // 料金を取得して表示
101
+ fetch('/pricing-info').then(r => r.json()).then(d => {
102
+ if (d.price) document.getElementById('priceDisplay').innerHTML =
103
+ `¥${Number(d.price).toLocaleString()} <span>/ 月</span>`;
104
+ }).catch(() => {});
105
+
106
+ document.getElementById('checkoutBtn').addEventListener('click', async () => {
107
+ const btn = document.getElementById('checkoutBtn');
108
+ btn.disabled = true;
109
+ btn.textContent = '処理中...';
110
+ try {
111
+ const res = await fetch('/create-checkout', { method: 'POST' });
112
+ const data = await res.json();
113
+ if (data.url) {
114
+ window.location.href = data.url;
115
+ } else {
116
+ alert('エラー: ' + (data.error || '不明なエラー'));
117
+ btn.disabled = false;
118
+ btn.textContent = '今すぐ始める →';
119
+ }
120
+ } catch (e) {
121
+ alert('サーバーエラーが発生しました');
122
+ btn.disabled = false;
123
+ btn.textContent = '今すぐ始める →';
124
+ }
125
+ });
126
+ </script>
127
+ </body>
128
+ </html>
static/style.css ADDED
@@ -0,0 +1,326 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ── Reset & Base ──────────────────────────────────────── */
2
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
3
+
4
+ :root {
5
+ --bg: #0a0a0f;
6
+ --surface: rgba(255,255,255,0.05);
7
+ --surface-hover: rgba(255,255,255,0.08);
8
+ --border: rgba(255,255,255,0.10);
9
+ --text: #e8e8f0;
10
+ --text-muted: #7b7b9a;
11
+ --accent: #7c6af7;
12
+ --accent2: #4fc8e9;
13
+ --accent3: #f76a8c;
14
+ --success: #4ade80;
15
+ --error: #f87171;
16
+ --radius: 20px;
17
+ --shadow: 0 8px 40px rgba(0,0,0,0.5);
18
+ }
19
+
20
+ html { font-size: 16px; scroll-behavior: smooth; }
21
+
22
+ body {
23
+ font-family: 'Inter', sans-serif;
24
+ background: var(--bg);
25
+ color: var(--text);
26
+ min-height: 100vh;
27
+ overflow-x: hidden;
28
+ }
29
+
30
+ /* ── 背景オーブ ──────────────────────────────────────── */
31
+ .bg-orbs { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
32
+
33
+ .orb {
34
+ position: absolute;
35
+ border-radius: 50%;
36
+ filter: blur(80px);
37
+ opacity: 0.25;
38
+ animation: orb-float 12s ease-in-out infinite alternate;
39
+ }
40
+ .orb1 { width: 600px; height: 600px; background: var(--accent); top: -200px; left: -200px; animation-delay: 0s; }
41
+ .orb2 { width: 500px; height: 500px; background: var(--accent2); bottom: -150px; right: -100px; animation-delay: -4s; }
42
+ .orb3 { width: 400px; height: 400px; background: var(--accent3); top: 40%; left: 50%; transform: translateX(-50%); animation-delay: -8s; }
43
+
44
+ @keyframes orb-float {
45
+ from { transform: translate(0, 0) scale(1); }
46
+ to { transform: translate(30px, 30px) scale(1.05); }
47
+ }
48
+
49
+ /* ── Layout ──────────────────────────────────────────── */
50
+ .container {
51
+ position: relative; z-index: 1;
52
+ max-width: 680px;
53
+ margin: 0 auto;
54
+ padding: 2rem 1.5rem 4rem;
55
+ }
56
+
57
+ /* ── Header ──────────────────────────────────────────── */
58
+ .header { text-align: center; margin-bottom: 2.5rem; }
59
+
60
+ .logo {
61
+ display: inline-flex; align-items: center; gap: .5rem;
62
+ font-size: 2rem; font-weight: 700; letter-spacing: -0.03em;
63
+ background: linear-gradient(135deg, var(--accent), var(--accent2));
64
+ -webkit-background-clip: text; -webkit-text-fill-color: transparent;
65
+ background-clip: text;
66
+ margin-bottom: .75rem;
67
+ }
68
+ .logo-icon { font-size: 2rem; filter: drop-shadow(0 0 12px var(--accent)); }
69
+
70
+ .tagline {
71
+ color: var(--text-muted); font-size: .95rem; line-height: 1.7;
72
+ }
73
+ .tagline strong { color: var(--text); }
74
+
75
+ /* ── Card ──────────────────────────────────────────── */
76
+ .card {
77
+ background: var(--surface);
78
+ border: 1px solid var(--border);
79
+ border-radius: var(--radius);
80
+ padding: 2rem;
81
+ backdrop-filter: blur(20px);
82
+ -webkit-backdrop-filter: blur(20px);
83
+ box-shadow: var(--shadow);
84
+ margin-bottom: 1.5rem;
85
+ }
86
+ .hidden { display: none !important; }
87
+
88
+ /* ── ドロップゾーン ──────────────────────────────────── */
89
+ .drop-zone {
90
+ border: 2px dashed var(--border);
91
+ border-radius: 14px;
92
+ padding: 3rem 2rem;
93
+ text-align: center;
94
+ cursor: pointer;
95
+ transition: border-color .2s, background .2s;
96
+ }
97
+ .drop-zone.dragover {
98
+ border-color: var(--accent);
99
+ background: rgba(124,106,247,.08);
100
+ }
101
+ .drop-zone.has-file {
102
+ border-color: var(--accent);
103
+ border-style: solid;
104
+ }
105
+
106
+ .drop-icon { font-size: 3rem; margin-bottom: .75rem; }
107
+ .drop-text { font-size: 1.05rem; font-weight: 500; margin-bottom: .25rem; }
108
+ .drop-sub { color: var(--text-muted); font-size: .85rem; margin-bottom: .75rem; }
109
+ .drop-formats { margin-top: .75rem; font-size: .78rem; color: var(--text-muted); }
110
+ .drop-filename { margin-top: .5rem; font-size: .9rem; color: var(--accent2); font-weight: 500; }
111
+
112
+ /* ── 設定パネル ──────────────────────────────────────── */
113
+ .settings { margin: 1.5rem 0 1.25rem; display: flex; flex-direction: column; gap: .9rem; }
114
+
115
+ .setting-row {
116
+ display: flex; align-items: center; gap: .75rem;
117
+ padding: .75rem 1rem;
118
+ background: rgba(255,255,255,.03);
119
+ border: 1px solid var(--border);
120
+ border-radius: 10px;
121
+ }
122
+ .setting-row label {
123
+ flex: 1; font-size: .88rem; color: var(--text-muted);
124
+ display: flex; align-items: center; gap: .4rem;
125
+ }
126
+ .setting-value {
127
+ font-size: .85rem; color: var(--accent2); font-weight: 600;
128
+ min-width: 32px; text-align: right;
129
+ }
130
+
131
+ input[type="range"] {
132
+ -webkit-appearance: none; appearance: none;
133
+ flex: 0 0 130px; height: 4px;
134
+ background: var(--border); border-radius: 99px; cursor: pointer;
135
+ }
136
+ input[type="range"]::-webkit-slider-thumb {
137
+ -webkit-appearance: none;
138
+ width: 16px; height: 16px; border-radius: 50%;
139
+ background: var(--accent); border: 2px solid #fff; cursor: pointer;
140
+ box-shadow: 0 0 8px var(--accent);
141
+ }
142
+
143
+ select {
144
+ background: rgba(255,255,255,.06);
145
+ border: 1px solid var(--border);
146
+ color: var(--text);
147
+ padding: .35rem .6rem; border-radius: 7px;
148
+ font-size: .83rem; cursor: pointer; flex: 0 0 auto;
149
+ }
150
+
151
+ /* ── トグル ──────────────────────────────────────────── */
152
+ .toggle { position: relative; display: inline-block; width: 46px; height: 26px; flex-shrink: 0; }
153
+ .toggle input { opacity: 0; width: 0; height: 0; }
154
+ .slider {
155
+ position: absolute; inset: 0;
156
+ background: var(--border); border-radius: 99px; cursor: pointer;
157
+ transition: background .2s;
158
+ }
159
+ .slider::before {
160
+ content: ''; position: absolute;
161
+ width: 18px; height: 18px; left: 4px; top: 4px;
162
+ background: #fff; border-radius: 50%;
163
+ transition: transform .2s;
164
+ box-shadow: 0 2px 6px rgba(0,0,0,.3);
165
+ }
166
+ .toggle input:checked + .slider { background: var(--accent); }
167
+ .toggle input:checked + .slider::before { transform: translateX(20px); }
168
+
169
+ /* ── バッジ ──────────────────────────────────────────── */
170
+ .badge {
171
+ display: inline-block;
172
+ padding: .1rem .45rem; font-size: .68rem; font-weight: 600;
173
+ background: linear-gradient(135deg, var(--accent), var(--accent2));
174
+ border-radius: 99px; color: #fff; letter-spacing: .04em;
175
+ }
176
+
177
+ /* ── ボタン ──────────────────────────────────────────── */
178
+ .btn {
179
+ display: inline-flex; align-items: center; justify-content: center; gap: .5rem;
180
+ padding: .75rem 1.75rem; border-radius: 10px;
181
+ font-family: inherit; font-size: .95rem; font-weight: 600;
182
+ cursor: pointer; border: none; text-decoration: none;
183
+ transition: transform .15s, box-shadow .15s, opacity .15s;
184
+ }
185
+ .btn:active { transform: scale(.97); }
186
+
187
+ .btn-primary {
188
+ background: linear-gradient(135deg, var(--accent), var(--accent2));
189
+ color: #fff; width: 100%;
190
+ box-shadow: 0 4px 24px rgba(124,106,247,.4);
191
+ }
192
+ .btn-primary:hover:not(:disabled) { box-shadow: 0 6px 32px rgba(124,106,247,.6); transform: translateY(-1px); }
193
+ .btn-primary:disabled { opacity: .4; cursor: not-allowed; }
194
+
195
+ .btn-outline {
196
+ background: transparent;
197
+ border: 1px solid var(--border);
198
+ color: var(--text);
199
+ }
200
+ .btn-outline:hover { background: var(--surface-hover); }
201
+
202
+ /* ── 進捗 ──────────────────────────────────────────── */
203
+ .progress-header {
204
+ display: flex; align-items: flex-start; gap: 1rem; margin-bottom: 1.25rem;
205
+ }
206
+ .progress-title { font-size: 1.05rem; font-weight: 600; }
207
+ .progress-file { font-size: .82rem; color: var(--text-muted); margin-top: .2rem; }
208
+
209
+ .spinner {
210
+ flex-shrink: 0;
211
+ width: 36px; height: 36px;
212
+ border: 3px solid var(--border);
213
+ border-top-color: var(--accent);
214
+ border-radius: 50%;
215
+ animation: spin .8s linear infinite;
216
+ }
217
+ @keyframes spin { to { transform: rotate(360deg); } }
218
+
219
+ .progress-bar-wrap {
220
+ background: rgba(255,255,255,.06);
221
+ border-radius: 99px; height: 8px;
222
+ overflow: hidden; margin-bottom: .5rem;
223
+ }
224
+ .progress-bar {
225
+ height: 100%; width: 0%;
226
+ background: linear-gradient(90deg, var(--accent), var(--accent2));
227
+ border-radius: 99px;
228
+ transition: width .5s ease;
229
+ }
230
+ .progress-pct { font-size: .82rem; color: var(--text-muted); text-align: right; margin-bottom: 1rem; }
231
+
232
+ .log-box {
233
+ background: rgba(0,0,0,.4);
234
+ border: 1px solid var(--border);
235
+ border-radius: 10px;
236
+ padding: .9rem 1rem;
237
+ max-height: 200px; overflow-y: auto;
238
+ font-size: .8rem; font-family: 'Menlo', 'Consolas', monospace;
239
+ color: var(--text-muted); line-height: 1.6;
240
+ scroll-behavior: smooth;
241
+ }
242
+ .log-box p { margin-bottom: .15rem; white-space: pre-wrap; }
243
+ .log-box p.ok { color: var(--success); }
244
+ .log-box p.err { color: var(--error); }
245
+
246
+ /* ── 完了・エラー ──────────────────────────────────── */
247
+ .result-card, .error-card { text-align: center; }
248
+
249
+ .result-icon { font-size: 3.5rem; margin-bottom: .75rem; }
250
+ .result-title { font-size: 1.5rem; font-weight: 700; margin-bottom: 1.5rem; }
251
+
252
+ .channel-info {
253
+ display: flex; gap: 1rem; justify-content: center; margin-bottom: 2rem;
254
+ }
255
+ .channel-item {
256
+ flex: 1; max-width: 220px;
257
+ padding: 1rem; border-radius: 12px;
258
+ border: 1px solid var(--border);
259
+ background: rgba(255,255,255,.03);
260
+ }
261
+ .channel-item.left { border-color: var(--accent); }
262
+ .channel-item.right { border-color: var(--accent2); }
263
+ .ch-label {
264
+ display: inline-block;
265
+ font-size: 1.3rem; font-weight: 800;
266
+ margin-bottom: .3rem;
267
+ }
268
+ .channel-item.left .ch-label { color: var(--accent); }
269
+ .channel-item.right .ch-label { color: var(--accent2); }
270
+ .ch-desc { display: block; font-size: .82rem; color: var(--text-muted); }
271
+
272
+ .download-btn { margin-bottom: .75rem; }
273
+ .reset-btn { width: 100%; }
274
+
275
+ .error-msg { color: var(--error); font-size: .9rem; margin-bottom: 1.5rem; white-space: pre-wrap; }
276
+
277
+ /* ── フッター ──────────────────────────────────────── */
278
+ .footer {
279
+ position: relative; z-index: 1;
280
+ text-align: center; padding: 1.5rem;
281
+ color: var(--text-muted); font-size: .8rem;
282
+ }
283
+ .footer a { color: var(--accent2); text-decoration: none; }
284
+ .footer a:hover { text-decoration: underline; }
285
+
286
+ /* ── レスポンシブ ──────────────────────────────────── */
287
+ @media (max-width: 520px) {
288
+ .container { padding: 1.25rem 1rem 3rem; }
289
+ .card { padding: 1.5rem; }
290
+ .channel-info { flex-direction: column; align-items: center; }
291
+ input[type="range"] { flex: 0 0 100px; }
292
+ }
293
+
294
+ /* ── トークン認証 UI ─────────────────────────────────── */
295
+ .token-area {
296
+ margin-bottom: 1.25rem;
297
+ padding: 1rem;
298
+ border: 1px solid var(--border);
299
+ border-radius: 12px;
300
+ background: rgba(255,255,255,.02);
301
+ }
302
+ .token-row {
303
+ display: flex; gap: .5rem; margin-bottom: .5rem;
304
+ }
305
+ .token-row input[type="text"] {
306
+ flex: 1;
307
+ background: rgba(255,255,255,.06);
308
+ border: 1px solid var(--border);
309
+ color: var(--text);
310
+ padding: .5rem .8rem; border-radius: 8px;
311
+ font-size: .88rem; font-family: monospace;
312
+ outline: none; transition: border-color .2s;
313
+ }
314
+ .token-row input[type="text"]:focus { border-color: var(--accent); }
315
+ .btn-verify {
316
+ padding: .5rem 1rem; border-radius: 8px;
317
+ background: var(--accent); color: #fff;
318
+ font-family: inherit; font-size: .85rem; font-weight: 600;
319
+ border: none; cursor: pointer; flex-shrink: 0;
320
+ transition: opacity .15s;
321
+ }
322
+ .btn-verify:hover { opacity: .85; }
323
+ .token-hint { font-size: .78rem; color: var(--text-muted); }
324
+ .token-hint a.pricing-link { color: var(--accent2); text-decoration: none; }
325
+ .token-hint a.pricing-link:hover { text-decoration: underline; }
326
+ .token-ok { font-size: .82rem; color: var(--success); margin-top: .4rem; font-weight: 600; }
static/success.html ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ja">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>HarmoSplit — 決済完了</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
9
+ <link rel="stylesheet" href="/style.css">
10
+ <style>
11
+ .success-wrap { text-align: center; }
12
+ .token-box {
13
+ background: rgba(0,0,0,.5);
14
+ border: 1px solid var(--accent);
15
+ border-radius: 10px;
16
+ padding: 1rem 1.5rem;
17
+ font-family: monospace; font-size: 1rem;
18
+ letter-spacing: .04em;
19
+ color: var(--accent2);
20
+ word-break: break-all;
21
+ margin: 1.25rem 0;
22
+ user-select: all;
23
+ cursor: pointer;
24
+ transition: background .2s;
25
+ }
26
+ .token-box:hover { background: rgba(124,106,247,.1); }
27
+ .copy-hint { font-size: .78rem; color: var(--text-muted); margin-top: -.5rem; margin-bottom: 1rem; }
28
+ .copied-badge {
29
+ display: none;
30
+ color: var(--success); font-size: .85rem; margin-bottom: 1rem; font-weight: 600;
31
+ }
32
+ .steps { text-align: left; margin: 1.5rem 0; }
33
+ .step {
34
+ display: flex; gap: 1rem; align-items: flex-start;
35
+ padding: .75rem 0; border-bottom: 1px solid var(--border);
36
+ font-size: .9rem;
37
+ }
38
+ .step-num {
39
+ flex-shrink: 0; width: 28px; height: 28px;
40
+ background: linear-gradient(135deg, var(--accent), var(--accent2));
41
+ border-radius: 50%; display: flex; align-items: center; justify-content: center;
42
+ font-size: .8rem; font-weight: 700; color: #fff;
43
+ }
44
+ </style>
45
+ </head>
46
+ <body>
47
+ <div class="bg-orbs">
48
+ <div class="orb orb1"></div>
49
+ <div class="orb orb2"></div>
50
+ <div class="orb orb3"></div>
51
+ </div>
52
+
53
+ <main class="container">
54
+ <div class="card success-wrap">
55
+ <div style="font-size:3.5rem;margin-bottom:1rem;">🎉</div>
56
+ <h1 class="result-title">決済完了!ありがとうございます</h1>
57
+
58
+ <p style="color:var(--text-muted);font-size:.9rem;margin-bottom:1rem;">
59
+ 以下のアクセストークンを保存してください。アプリ利用時に必要です。
60
+ </p>
61
+
62
+ <div class="token-box" id="tokenBox" onclick="copyToken()">
63
+ <span id="tokenText">読み込み中...</span>
64
+ </div>
65
+ <p class="copy-hint">👆 クリックでコピー</p>
66
+ <div class="copied-badge" id="copiedBadge">✅ コピーしました!</div>
67
+
68
+ <div class="steps">
69
+ <div class="step">
70
+ <div class="step-num">1</div>
71
+ <div>上のトークンをコピーして <strong>安全な場所に保存</strong>してください</div>
72
+ </div>
73
+ <div class="step">
74
+ <div class="step-num">2</div>
75
+ <div>アプリに戻り、トークンを入力してご利用ください</div>
76
+ </div>
77
+ <div class="step" style="border:none;">
78
+ <div class="step-num">3</div>
79
+ <div>サブスクリプションは <a href="https://billing.stripe.com" style="color:var(--accent2);">Stripe ポータル</a> からいつでも解約できます</div>
80
+ </div>
81
+ </div>
82
+
83
+ <a class="btn btn-primary" href="/" style="margin-top:.5rem;">
84
+ 🎧 アプリを使ってみる
85
+ </a>
86
+ </div>
87
+ </main>
88
+
89
+ <footer class="footer">
90
+ <p>🎧 HarmoSplit — Powered by Demucs &amp; UVR MDX-NET</p>
91
+ </footer>
92
+
93
+ <script>
94
+ async function loadToken() {
95
+ const params = new URLSearchParams(location.search);
96
+ const sessionId = params.get('session_id');
97
+ if (!sessionId) {
98
+ document.getElementById('tokenText').textContent = 'エラー: session_id が見つかりません';
99
+ return;
100
+ }
101
+ try {
102
+ const res = await fetch(`/get-token?session_id=${sessionId}`);
103
+ const data = await res.json();
104
+ if (data.token) {
105
+ document.getElementById('tokenText').textContent = data.token;
106
+ } else {
107
+ document.getElementById('tokenText').textContent = 'エラー: ' + (data.error || '不明');
108
+ }
109
+ } catch(e) {
110
+ document.getElementById('tokenText').textContent = 'サーバーエラー';
111
+ }
112
+ }
113
+
114
+ function copyToken() {
115
+ const text = document.getElementById('tokenText').textContent;
116
+ navigator.clipboard.writeText(text).then(() => {
117
+ const badge = document.getElementById('copiedBadge');
118
+ badge.style.display = 'block';
119
+ setTimeout(() => badge.style.display = 'none', 2000);
120
+ });
121
+ }
122
+
123
+ loadToken();
124
+ </script>
125
+ </body>
126
+ </html>