C36-VCT commited on
Commit
3bf9624
·
verified ·
1 Parent(s): ef00cb4

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +239 -187
app.py CHANGED
@@ -3,242 +3,294 @@ import sys
3
  import subprocess
4
  import threading
5
  import time
 
 
6
  import io
7
  import wave
8
- import audioop
9
- import uuid
10
- from flask import Flask, request
11
  from flask_socketio import SocketIO, emit
12
  from dotenv import load_dotenv
13
  import google.generativeai as genai
14
- from groq import Groq
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
  load_dotenv()
17
 
18
- # === API設定 ===
19
- GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
20
- GROQ_API_KEY = os.getenv("GROQ_API_KEY")
 
 
 
 
 
21
 
22
- if not GEMINI_API_KEY: print("⚠️ GEMINI_API_KEYが設定されていません")
23
- if not GROQ_API_KEY: print("⚠️ GROQ_API_KEYが設定されていません")
24
 
25
- genai.configure(api_key=GEMINI_API_KEY)
26
- groq_client = Groq(api_key=GROQ_API_KEY) if GROQ_API_KEY else None
 
 
27
 
28
  sys.stdout.reconfigure(line_buffering=True)
29
 
30
  app = Flask(__name__)
31
  app.config['SECRET_KEY'] = os.getenv("SECRET_KEY", "secret!")
32
- socketio = SocketIO(app, cors_allowed_origins="*", max_http_buffer_size=10 * 1024 * 1024)
33
 
34
- # Geminiモデル (確定用)
35
- model = genai.GenerativeModel("models/gemini-2.0-flash")
36
 
37
- active_processors = {}
38
- SILENCE_THRESHOLD = 500
39
 
40
- class AudioProcessor:
41
- def __init__(self, sid):
42
  self.sid = sid
43
- self.running = True
44
- self.settings = {'target_lang': 'ja', 'max_speakers': 2}
45
- self.reset_flag = False # バッファリセット用フラグ
46
-
47
- # FFmpegプロセス
48
- self.ffmpeg_proc = subprocess.Popen(
49
- [
50
- 'ffmpeg', '-i', 'pipe:0', '-f', 's16le', '-ac', '1',
51
- '-ar', '16000', '-acodec', 'pcm_s16le', 'pipe:1', '-v', 'error'
52
- ],
53
- stdin=subprocess.PIPE,
54
- stdout=subprocess.PIPE,
55
- stderr=sys.stderr
56
- )
57
-
58
- self.thread = threading.Thread(target=self._process_loop, daemon=True)
59
- self.thread.start()
60
-
61
- def write_data(self, chunk):
62
- if self.running and self.ffmpeg_proc.stdin:
63
- try:
64
- self.ffmpeg_proc.stdin.write(chunk)
65
- self.ffmpeg_proc.stdin.flush()
66
- except Exception as e:
67
- print(f"❌ FFmpeg書き込みエラー: {e}")
68
-
69
- def update_settings(self, data):
70
- self.settings.update(data)
71
-
72
- def trigger_reset(self):
73
- """シーク時などにバッファを強制クリアする"""
74
- self.reset_flag = True
75
- print(f"🔄 バッファリセット予約: {self.sid}")
76
 
77
  def stop(self):
78
- self.running = False
79
- if self.ffmpeg_proc:
80
- try:
81
- self.ffmpeg_proc.stdin.close()
82
- self.ffmpeg_proc.kill()
83
  except: pass
84
- print(f"🛑 プロセッサ停止: {self.sid}")
85
-
86
- # --- 速報: Groq (Whisper) ---
87
- def _call_groq_fast(self, wav_bytes, chunk_id, tgt_name):
88
- if not groq_client: return
 
 
 
 
 
 
 
89
  try:
90
- # Whisperへの指示
91
- prompt_text = f"Translate everything into {tgt_name}."
92
-
93
- completion = groq_client.audio.transcriptions.create(
94
- file=("audio.wav", wav_bytes),
95
- model="whisper-large-v3",
96
- prompt=prompt_text,
97
- response_format="json",
98
- temperature=0.0
99
- )
100
-
101
- text = completion.text.strip()
102
- if text:
103
- # 速報を送信
104
- socketio.emit('subtitle_preview', {
105
- 'chunk_id': chunk_id,
106
- 'text': text
107
- }, room=self.sid)
108
- print(f"🚀 Groq速報: {text[:20]}...")
109
-
110
  except Exception as e:
111
- print(f"⚠️ Groqエラー: {e}")
 
112
 
113
- # --- 確定: Gemini ---
114
- def _call_gemini_accurate(self, wav_bytes, chunk_id, tgt_name, max_spk):
115
  try:
 
 
 
 
116
  prompt = f"""
117
- Task: Listen to the audio.
118
- 1. Identify distinct speakers (Max {max_spk}).
119
  2. Translate speech to {tgt_name}.
120
 
121
- Output format:
122
- Speaker 1: [Translation]
 
123
 
124
  Rules:
125
- - Output ONLY translation.
126
- - If one person, use "Speaker 1".
127
- - Adapt tone/context.
128
  """
129
 
130
- response = model.generate_content([
131
  prompt,
132
- {"mime_type": "audio/wav", "data": wav_bytes}
133
  ])
134
- text = response.text.strip()
135
-
136
- if text:
137
- parsed_lines = []
138
- for line in text.split('\n'):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
  if ':' in line:
140
  parts = line.split(':', 1)
141
- parsed_lines.append({
142
- 'speaker': parts[0].strip(),
143
- 'text': parts[1].strip()
144
- })
145
 
146
- if parsed_lines:
147
- # 確定を送信
148
- socketio.emit('subtitle_final', {
149
- 'chunk_id': chunk_id,
150
- 'results': parsed_lines
151
- }, room=self.sid)
152
- print(f"✅ Gemini確定: {parsed_lines[0]['text'][:20]}...")
153
 
154
- except Exception as e:
155
- print(f"⚠️ Geminiエラー: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
 
157
- def _process_loop(self):
158
- CHUNK_DURATION = 4 # バッファサイズ (秒)
159
- REQUIRED_BYTES = 16000 * 2 * CHUNK_DURATION
160
- pcm_buffer = bytearray()
 
 
 
161
 
162
- while self.running:
163
- try:
164
- # バッファリセット判定
165
- if self.reset_flag:
166
- pcm_buffer.clear()
167
- self.reset_flag = False
168
- print("✨ バッファクリア完了")
169
-
170
- raw_chunk = self.ffmpeg_proc.stdout.read(4096)
171
- if not raw_chunk:
172
- time.sleep(0.1)
173
- if self.ffmpeg_proc.poll() is not None: break
174
- continue
175
-
176
- pcm_buffer.extend(raw_chunk)
177
-
178
- if len(pcm_buffer) >= REQUIRED_BYTES:
179
- to_process = pcm_buffer[:REQUIRED_BYTES]
180
- pcm_buffer = pcm_buffer[REQUIRED_BYTES:] # スライドさせるか、オーバーラップさせるか
181
-
182
- # 無音検知 (VAD)
183
- try:
184
- rms = audioop.rms(to_process, 2)
185
- if rms < SILENCE_THRESHOLD:
186
- continue # 無音ならスキップ
187
- except: pass
188
-
189
- # WAV作成
190
- wav_io = io.BytesIO()
191
- with wave.open(wav_io, 'wb') as wf:
192
- wf.setnchannels(1); wf.setsampwidth(2); wf.setframerate(16000)
193
- wf.writeframes(to_process)
194
- wav_bytes = wav_io.getvalue()
195
-
196
- # 設定取得
197
- tgt_lang = self.settings.get('target_lang', 'ja')
198
- tgt_name = "Japanese" if tgt_lang == 'ja' else "English"
199
- max_spk = self.settings.get('max_speakers', 2)
200
- chunk_id = str(uuid.uuid4())
201
-
202
- # ★並列処理開始
203
- t1 = threading.Thread(target=self._call_groq_fast, args=(wav_bytes, chunk_id, tgt_name))
204
- t2 = threading.Thread(target=self._call_gemini_accurate, args=(wav_bytes, chunk_id, tgt_name, max_spk))
205
- t1.start()
206
- t2.start()
207
-
208
- except Exception as e:
209
- print(f"❌ ループエラー: {e}")
210
- time.sleep(1)
211
-
212
- # === Socket.IO イベント ===
213
  @socketio.on('connect')
214
- def handle_connect():
215
- active_processors[request.sid] = AudioProcessor(request.sid)
216
 
217
  @socketio.on('disconnect')
218
  def handle_disconnect():
219
- if request.sid in active_processors:
220
- active_processors[request.sid].stop()
221
- del active_processors[request.sid]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
 
223
  @socketio.on('update_settings')
224
- def handle_settings(data):
225
- if request.sid in active_processors:
226
- active_processors[request.sid].update_settings(data)
227
-
228
- @socketio.on('audio_stream')
229
- def handle_audio_stream(data):
230
- if request.sid in active_processors:
231
- active_processors[request.sid].write_data(data)
232
-
233
- # ★追加: クライアントからのリセット要求
234
- @socketio.on('reset_stream')
235
- def handle_stream_reset():
236
  sid = request.sid
237
- if sid in active_processors:
238
- active_processors[sid].trigger_reset()
 
 
 
 
 
 
 
 
 
 
 
239
 
240
- @socketio.on('request_speaker_profiles')
241
- def req_prof(): emit('initial_speaker_profiles', {}, room=request.sid)
 
 
 
 
 
242
 
243
  if __name__ == "__main__":
244
  socketio.run(app, host='0.0.0.0', port=7860, allow_unsafe_werkzeug=True)
 
3
  import subprocess
4
  import threading
5
  import time
6
+ import requests
7
+ import socket
8
  import io
9
  import wave
10
+ import json
11
+ import yt_dlp
12
+ from flask import Flask, render_template, request
13
  from flask_socketio import SocketIO, emit
14
  from dotenv import load_dotenv
15
  import google.generativeai as genai
16
+
17
+ # DNS Patch
18
+ _original_getaddrinfo = socket.getaddrinfo
19
+ def patched_getaddrinfo(host, port, family=0, type=0, proto=0, flags=0):
20
+ if host and ("youtube.com" in host or "youtu.be" in host or "googlevideo.com" in host):
21
+ try:
22
+ url = f"https://8.8.8.8/resolve?name={host}&type=A"
23
+ r = requests.get(url, timeout=2)
24
+ data = r.json()
25
+ if "Answer" in data:
26
+ ip = data["Answer"][0]["data"]
27
+ return _original_getaddrinfo(ip, port, family, type, proto, flags)
28
+ except Exception:
29
+ pass
30
+ return _original_getaddrinfo(host, port, family, type, proto, flags)
31
+ socket.getaddrinfo = patched_getaddrinfo
32
 
33
  load_dotenv()
34
 
35
+ try:
36
+ cookie_content = os.getenv("YOUTUBE_COOKIES")
37
+ if cookie_content:
38
+ cookie_content = cookie_content.replace("\\n", "\n")
39
+ with open("cookies.txt", "w") as f:
40
+ f.write(cookie_content)
41
+ except Exception:
42
+ pass
43
 
44
+ GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
45
+ model_name = "gemini-2.0-flash"
46
 
47
+ if not GEMINI_API_KEY:
48
+ print("⚠️ GEMINI_API_KEYが設定されていません")
49
+ else:
50
+ genai.configure(api_key=GEMINI_API_KEY)
51
 
52
  sys.stdout.reconfigure(line_buffering=True)
53
 
54
  app = Flask(__name__)
55
  app.config['SECRET_KEY'] = os.getenv("SECRET_KEY", "secret!")
56
+ socketio = SocketIO(app, cors_allowed_origins="*")
57
 
58
+ MAX_CONCURRENT_STREAMS = 5
59
+ SPEAKER_COLORS = ["#FFFFFF", "#03DAC6", "#FFB400", "#CF6679", "#BB86FC"]
60
 
61
+ session_speakers = {}
62
+ active_sessions = {}
63
 
64
+ class StreamWorker:
65
+ def __init__(self, sid, url, settings, socket_instance):
66
  self.sid = sid
67
+ self.url = url
68
+ self.settings = settings
69
+ self.socketio = socket_instance
70
+ self.is_running = False
71
+ self.ffmpeg_proc = None
72
+ self.dl_proc = None
73
+ self.fifo_path = f"/tmp/stream_{sid}.fifo"
74
+ self.model = genai.GenerativeModel(model_name)
75
+
76
+ def start(self):
77
+ if self.is_running: return
78
+ self.is_running = True
79
+ self.socketio.start_background_task(target=self._main_loop)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
 
81
  def stop(self):
82
+ self.is_running = False
83
+ if self.ffmpeg_proc and self.ffmpeg_proc.poll() is None:
84
+ self.ffmpeg_proc.kill()
85
+ if os.path.exists(self.fifo_path):
86
+ try: os.remove(self.fifo_path)
87
  except: pass
88
+ print(f"🛑 Worker停止: {self.sid}")
89
+
90
+ def _run_ytdlp(self):
91
+ if os.path.exists(self.fifo_path): os.remove(self.fifo_path)
92
+ os.mkfifo(self.fifo_path)
93
+ ydl_opts = {
94
+ 'format': 'bestaudio/best',
95
+ 'outtmpl': self.fifo_path,
96
+ 'quiet': True, 'no_warnings': True, 'nocheckcertificate': True,
97
+ 'cookiefile': 'cookies.txt' if os.path.exists("cookies.txt") else None,
98
+ 'socket_timeout': 10, 'retries': 10,
99
+ }
100
  try:
101
+ with yt_dlp.YoutubeDL(ydl_opts) as ydl:
102
+ ydl.download([self.url])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  except Exception as e:
104
+ print(f"❌ yt-dlpエラー ({self.sid}): {e}")
105
+ self.is_running = False
106
 
107
+ def _process_audio_with_gemini(self, audio_data):
 
108
  try:
109
+ tgt_lang = self.settings.get('target_lang', 'ja')
110
+ tgt_name = "Japanese" if tgt_lang == 'ja' else "English"
111
+ max_spk = self.settings.get('max_speakers', 2)
112
+
113
  prompt = f"""
114
+ Task: Listen to this audio.
115
+ 1. Identify distinct speakers. (Context implies MAX {max_spk} speakers).
116
  2. Translate speech to {tgt_name}.
117
 
118
+ Output format (Strictly plain text):
119
+ Speaker 1: [Translated Text]
120
+ Speaker 2: [Translated Text]
121
 
122
  Rules:
123
+ - If it sounds like one person but tone changes, keep it as Speaker 1 if limit is 1.
124
+ - Do NOT output original text.
125
+ - If no speech, output nothing.
126
  """
127
 
128
+ response = self.model.generate_content([
129
  prompt,
130
+ {"mime_type": "audio/wav", "data": audio_data}
131
  ])
132
+ return response.text.strip()
133
+ except Exception:
134
+ return ""
135
+
136
+ def _main_loop(self):
137
+ print(f"🔁 処理開始 [User: {self.sid}] URL: {self.url}")
138
+ self.download_thread = threading.Thread(target=self._run_ytdlp)
139
+ self.download_thread.daemon = True
140
+ self.download_thread.start()
141
+
142
+ time.sleep(1)
143
+ if not os.path.exists(self.fifo_path):
144
+ self.socketio.emit("error_msg", {"msg": "開始失敗"}, room=self.sid)
145
+ self.stop()
146
+ return
147
+
148
+ ffmpeg_cmd = ["ffmpeg", "-y", "-i", self.fifo_path, "-f", "s16le", "-acodec", "pcm_s16le", "-ac", "1", "-ar", "16000", "pipe:1"]
149
+ try:
150
+ self.ffmpeg_proc = subprocess.Popen(ffmpeg_cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
151
+ except Exception:
152
+ self.stop(); return
153
+
154
+ chunk_seconds = 8
155
+ chunk_size = 16000 * 2 * chunk_seconds
156
+
157
+ while self.is_running:
158
+ try:
159
+ audio_chunk_raw = self.ffmpeg_proc.stdout.read(chunk_size)
160
+ if not audio_chunk_raw:
161
+ if self.is_running and self.ffmpeg_proc.poll() is None:
162
+ time.sleep(0.5); continue
163
+ else: break
164
+
165
+ wav_io = io.BytesIO()
166
+ with wave.open(wav_io, 'wb') as wf:
167
+ wf.setnchannels(1); wf.setsampwidth(2); wf.setframerate(16000)
168
+ wf.writeframes(audio_chunk_raw)
169
+ audio_bytes = wav_io.getvalue()
170
+
171
+ result_text = self._process_audio_with_gemini(audio_bytes)
172
+ if not result_text: continue
173
+
174
+ for line in result_text.split('\n'):
175
  if ':' in line:
176
  parts = line.split(':', 1)
177
+ if len(parts) < 2: continue
 
 
 
178
 
179
+ original_speaker_id = parts[0].strip()
180
+ text_content = parts[1].strip()
181
+ if not text_content: continue
 
 
 
 
182
 
183
+ # 【強制補正ロジック】
184
+ max_spk = self.settings.get('max_speakers', 2)
185
+
186
+ # 1人の設定なら、何を言われても "Speaker 1" にする
187
+ if max_spk == 1:
188
+ original_speaker_id = "Speaker 1"
189
+
190
+ # 数字部分を抽出してチェック (例: "Speaker 5" -> 5)
191
+ spk_num_match = re.search(r'\d+', original_speaker_id)
192
+ if spk_num_match:
193
+ spk_num = int(spk_num_match.group())
194
+ if spk_num > max_spk:
195
+ # 制限を超えた話者は Speaker 1 にまとめる(または無視)
196
+ original_speaker_id = "Speaker 1"
197
+
198
+ if self.sid not in session_speakers: session_speakers[self.sid] = {}
199
+
200
+ # 新規登録
201
+ if original_speaker_id not in session_speakers[self.sid]:
202
+ # もし登録済み人数が制限を超えていたら、既存の最後に割り当てる等の処理も可能だが
203
+ # 上記のID書き換えで対応済み
204
+ new_idx = len(session_speakers[self.sid])
205
+ color = SPEAKER_COLORS[new_idx % len(SPEAKER_COLORS)]
206
+ session_speakers[self.sid][original_speaker_id] = {"name": original_speaker_id, "color": color}
207
+ self.socketio.emit("initial_speaker_profiles", session_speakers[self.sid], room=self.sid)
208
+
209
+ spk_data = session_speakers[self.sid][original_speaker_id]
210
+ display_name = spk_data["name"]
211
+ display_color = spk_data["color"]
212
+
213
+ self.socketio.emit("new_subtitle", {
214
+ "original": "...",
215
+ "translated": text_content,
216
+ "speaker": display_name,
217
+ "speaker_id": original_speaker_id,
218
+ "color": display_color
219
+ }, room=self.sid)
220
+
221
+ print(f"✅ 送信({self.sid}): {display_name}: {text_content}")
222
 
223
+ except Exception: continue
224
+
225
+ self.stop()
226
+
227
+ @app.route("/")
228
+ def index():
229
+ return render_template("index.html")
230
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
  @socketio.on('connect')
232
+ def handle_connect(): pass
 
233
 
234
  @socketio.on('disconnect')
235
  def handle_disconnect():
236
+ sid = request.sid
237
+ if sid in active_sessions:
238
+ active_sessions[sid].stop()
239
+ del active_sessions[sid]
240
+ if sid in session_speakers: del session_speakers[sid]
241
+
242
+ @socketio.on('start_stream')
243
+ def handle_start_stream(data):
244
+ sid = request.sid
245
+ if sid in active_sessions: active_sessions[sid].stop()
246
+ if len(active_sessions) >= MAX_CONCURRENT_STREAMS:
247
+ emit("error_msg", {"msg": "混雑中"}, room=sid); return
248
+
249
+ settings = {
250
+ 'target_lang': data.get('target_lang', 'ja'),
251
+ 'max_speakers': int(data.get('max_speakers', 2))
252
+ }
253
+ worker = StreamWorker(sid, data.get('url'), settings, socketio)
254
+ active_sessions[sid] = worker
255
+ worker.start()
256
+ emit("status_msg", {"msg": "開始"}, room=sid)
257
+
258
+ @socketio.on('stop_stream')
259
+ def handle_stop_stream():
260
+ if request.sid in active_sessions: active_sessions[request.sid].stop()
261
+ emit("status_msg", {"msg": "停止"}, room=request.sid)
262
 
263
  @socketio.on('update_settings')
264
+ def handle_settings_update(data):
265
+ if request.sid in active_sessions:
266
+ active_sessions[request.sid].settings.update(data)
267
+
268
+
269
+ # === リセットボタン ===
270
+ @socketio.on('reset_speakers')
271
+ def handle_reset_speakers():
 
 
 
 
272
  sid = request.sid
273
+ if sid in session_speakers:
274
+ session_speakers[sid] = {}
275
+ # 空の状態を通知
276
+ emit("initial_speaker_profiles", {}, room=sid)
277
+ print(f"🔄 話者リセット: {sid}")
278
+
279
+ @socketio.on('update_speaker_name')
280
+ def handle_speaker_name_update(data):
281
+ sid = request.sid
282
+ id = data.get("id"); new_name = data.get("new_name")
283
+ if sid in session_speakers and id in session_speakers[sid]:
284
+ session_speakers[sid][id]["name"] = new_name
285
+ emit("speaker_name_changed", {"id": id, "new_name": new_name}, room=sid)
286
 
287
+ @socketio.on('update_speaker_color')
288
+ def handle_speaker_color_update(data):
289
+ sid = request.sid
290
+ id = data.get('id'); color = data.get('color')
291
+ if sid in session_speakers and id in session_speakers[sid]:
292
+ session_speakers[sid][id]["color"] = color
293
+ emit("speaker_color_changed", {"id": id, "color": color}, room=sid)
294
 
295
  if __name__ == "__main__":
296
  socketio.run(app, host='0.0.0.0', port=7860, allow_unsafe_werkzeug=True)