Spaces:
Sleeping
Sleeping
| import os | |
| import sys | |
| # Setup dynamic CUDA paths before importing Whisper | |
| nvidia_subdirs = [ | |
| ("nvidia", "cublas", "lib"), | |
| ("nvidia", "cudnn", "lib"), | |
| ("nvidia", "cuda_nvrtc", "lib") | |
| ] | |
| added_paths = [] | |
| for p in sys.path: | |
| if "site-packages" in p: | |
| for subdir in nvidia_subdirs: | |
| full_path = os.path.join(p, *subdir) | |
| if os.path.exists(full_path) and full_path not in added_paths: | |
| added_paths.append(full_path) | |
| if added_paths: | |
| existing = os.environ.get("LD_LIBRARY_PATH", "") | |
| if existing: | |
| os.environ["LD_LIBRARY_PATH"] = ":".join(added_paths) + ":" + existing | |
| else: | |
| os.environ["LD_LIBRARY_PATH"] = ":".join(added_paths) | |
| from datetime import datetime, timedelta, timezone | |
| import time | |
| last_resolve_time = 0 | |
| import socket | |
| import select | |
| import threading | |
| import queue | |
| import subprocess | |
| import random | |
| import requests | |
| import numpy as np | |
| from dotenv import load_dotenv | |
| from faster_whisper import WhisperModel | |
| from vod_backfiller import download_vod_chat, transcribe_vod_audio | |
| # Load configurations | |
| load_dotenv() | |
| API_URL = os.getenv("API_URL", "http://localhost:3000") | |
| API_KEY = os.getenv("API_KEY", "") | |
| TWITCH_CHANNEL = os.getenv("TWITCH_CHANNEL", "winx_prinx").lower() | |
| CAPTURE_METHOD = os.getenv("CAPTURE_METHOD", "stream").lower() | |
| WHISPER_MODEL_SIZE = os.getenv("WHISPER_MODEL", "base") | |
| WHISPER_DEVICE = os.getenv("WHISPER_DEVICE", "cpu") | |
| WHISPER_COMPUTE_TYPE = os.getenv("WHISPER_COMPUTE_TYPE", "int8") | |
| WHISPER_CPU_THREADS = int(os.getenv("WHISPER_CPU_THREADS", "2")) | |
| # Verify essential secrets | |
| if not API_KEY: | |
| print("[Error] API_KEY is missing in .env! Local worker cannot push data.") | |
| sys.exit(1) | |
| # Thread-safe message queue for Twitch chat messages | |
| chat_queue = queue.Queue() | |
| # Flag to signal thread termination | |
| stop_flag = threading.Event() | |
| # ========================================================================= | |
| # TWITCH CHAT LOGGER (IRC CLIENT) | |
| # ========================================================================= | |
| def parse_irc_tags(tags_str): | |
| """Parse IRC v3 tags into a dictionary""" | |
| tags = {} | |
| if not tags_str: | |
| return tags | |
| parts = tags_str.split(";") | |
| for part in parts: | |
| if "=" in part: | |
| k, v = part.split("=", 1) | |
| tags[k] = v | |
| return tags | |
| def twitch_chat_listener(): | |
| """Background thread to connect to Twitch IRC and read chat messages""" | |
| server = "irc.chat.twitch.tv" | |
| port = 6667 | |
| # Generate a random anonymous nickname | |
| anon_nick = f"justinfan{random.randint(10000, 99999)}" | |
| print(f"[Twitch IRC] Connecting anonymously as {anon_nick}...") | |
| while not stop_flag.is_set(): | |
| try: | |
| irc_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) | |
| irc_sock.settimeout(10.0) | |
| irc_sock.connect((server, port)) | |
| # Authenticate anonymously | |
| irc_sock.send(f"PASS oauth:anonymous\r\n".encode("utf-8")) | |
| irc_sock.send(f"NICK {anon_nick}\r\n".encode("utf-8")) | |
| # Request tags and commands capability to see badges/sub status | |
| irc_sock.send("CAP REQ :twitch.tv/tags twitch.tv/commands twitch.tv/membership\r\n".encode("utf-8")) | |
| # Join target channel | |
| irc_sock.send(f"JOIN #{TWITCH_CHANNEL}\r\n".encode("utf-8")) | |
| print(f"[Twitch IRC] Joined channel #{TWITCH_CHANNEL}") | |
| buffer = "" | |
| irc_sock.setblocking(False) | |
| last_msg_time = time.time() | |
| while not stop_flag.is_set(): | |
| # Check for network timeout (no messages for 5 minutes) | |
| if time.time() - last_msg_time > 300: | |
| print("[Twitch IRC] Network timeout (no messages for 5 minutes). Reconnecting...") | |
| break | |
| # Use select for non-blocking read with timeout to allow exit checks | |
| ready = select.select([irc_sock], [], [], 1.0) | |
| if not ready[0]: | |
| continue | |
| try: | |
| data = irc_sock.recv(4096).decode("utf-8", errors="ignore") | |
| except socket.timeout: | |
| continue | |
| if not data: | |
| print("[Twitch IRC] Connection closed by remote host.") | |
| break | |
| last_msg_time = time.time() | |
| buffer += data | |
| while "\r\n" in buffer: | |
| line, buffer = buffer.split("\r\n", 1) | |
| # 1. Handle Ping-Pong | |
| if line.startswith("PING"): | |
| irc_sock.send("PONG :tmi.twitch.tv\r\n".encode("utf-8")) | |
| continue | |
| # 2. Parse PRIVMSG (chat message) | |
| # Twitch format: @tags :user!user@user.tmi.twitch.tv PRIVMSG #channel :message | |
| if "PRIVMSG" in line: | |
| tags = {} | |
| tags_str = "" | |
| # Extract tags if they exist | |
| if line.startswith("@"): | |
| tags_str, remainder = line[1:].split(" ", 1) | |
| tags = parse_irc_tags(tags_str) | |
| line = remainder | |
| parts = line.split(" PRIVMSG ", 1) | |
| if len(parts) < 2: | |
| continue | |
| prefix, msg_parts = parts | |
| user = prefix.split("!", 1)[0].replace(":", "") | |
| channel_part, message_text = msg_parts.split(" :", 1) | |
| # Extract user metadata | |
| msg_id = tags.get("id", f"local-{time.time_ns()}") | |
| display_name = tags.get("display-name", user) | |
| badges = tags.get("badges", "") | |
| is_streamer = (user.lower() == TWITCH_CHANNEL) | |
| is_mod = "moderator" in badges or "broadcaster" in badges | |
| is_sub = "subscriber" in badges or "founder" in badges | |
| is_vip = "vip" in badges | |
| parsed_msg = { | |
| "id": msg_id, | |
| "username": user, | |
| "displayName": display_name, | |
| "message": message_text, | |
| "timestamp": new_iso_timestamp(), | |
| "isStreamer": is_streamer, | |
| "isMod": is_mod, | |
| "isSub": is_sub, | |
| "isVip": is_vip | |
| } | |
| # Add to queue | |
| chat_queue.put(parsed_msg) | |
| except Exception as e: | |
| print(f"[Twitch IRC] Connection error: {e}. Retrying in 10 seconds...") | |
| time.sleep(10) | |
| finally: | |
| try: | |
| irc_sock.close() | |
| except: | |
| pass | |
| def new_iso_timestamp(): | |
| return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) | |
| def chat_sender(): | |
| """Periodically sends accumulated chat messages to backend API""" | |
| print("[Chat Sender] Started background queue sender.") | |
| headers = {"x-api-key": API_KEY, "Content-Type": "application/json"} | |
| while not stop_flag.is_set(): | |
| messages = [] | |
| # Drain the queue | |
| while not chat_queue.empty(): | |
| try: | |
| messages.append(chat_queue.get_nowait()) | |
| except queue.Empty: | |
| break | |
| if messages: | |
| try: | |
| # 1. Send messages | |
| url = f"{API_URL}/api/log/messages" | |
| response = requests.post(url, json={"messages": messages}, headers=headers, timeout=5) | |
| if response.status_code != 200: | |
| print(f"[Chat Sender] Failed to sync messages. API returned status {response.status_code}") | |
| except Exception as e: | |
| print(f"[Chat Sender] Network error sending messages: {e}") | |
| try: | |
| # 2. Send roles for users who have at least one badge | |
| roles = [ | |
| { | |
| "username": m["username"], | |
| "displayName": m.get("displayName", m["username"]), | |
| "isMod": m.get("isMod", False), | |
| "isSub": m.get("isSub", False), | |
| "isVip": m.get("isVip", False), | |
| "timestamp": m.get("timestamp") | |
| } | |
| for m in messages | |
| if m.get("isMod") or m.get("isSub") or m.get("isVip") | |
| ] | |
| if roles: | |
| requests.post(f"{API_URL}/api/log/roles", json={"roles": roles}, headers=headers, timeout=5) | |
| except Exception as e: | |
| print(f"[Chat Sender] Network error sending roles: {e}") | |
| # Wait 5 seconds before next sync | |
| time.sleep(5) | |
| # ========================================================================= | |
| # AUDIO TRANSCRIBER (WHISPER) | |
| # ========================================================================= | |
| def init_whisper_model(): | |
| """Load Faster-Whisper model into memory""" | |
| print(f"[Whisper] Loading model '{WHISPER_MODEL_SIZE}' on {WHISPER_DEVICE} ({WHISPER_COMPUTE_TYPE}, threads={WHISPER_CPU_THREADS})...") | |
| # This might take a few minutes on first run as the model downloads | |
| model = WhisperModel( | |
| WHISPER_MODEL_SIZE, | |
| device=WHISPER_DEVICE, | |
| compute_type=WHISPER_COMPUTE_TYPE, | |
| cpu_threads=WHISPER_CPU_THREADS | |
| ) | |
| print("[Whisper] Model loaded successfully.") | |
| return model | |
| def transcribe_audio_segment(model, pcm_bytes): | |
| """Run Whisper transcription on PCM 16kHz 16-bit mono audio bytes""" | |
| if len(pcm_bytes) == 0: | |
| return [] | |
| # Convert raw 16-bit PCM bytes to float32 numpy array normalized to [-1.0, 1.0] | |
| audio_np = np.frombuffer(pcm_bytes, dtype=np.int16).astype(np.float32) / 32768.0 | |
| # Force language="ru" for better Russian accuracy and faster execution | |
| segments, info = model.transcribe( | |
| audio_np, | |
| beam_size=5, | |
| language="ru", | |
| temperature=0.0, | |
| condition_on_previous_text=False, | |
| log_prob_threshold=-0.8, | |
| no_speech_threshold=0.6, | |
| vad_filter=True, # Voice Activity Detection filters out silence | |
| vad_parameters=dict(threshold=0.5, min_silence_duration_ms=500) | |
| ) | |
| words_list = [] | |
| for segment in segments: | |
| text = segment.text.strip() | |
| if text: | |
| print(f"[Whisper Speech] Transcribed: \"{text}\"") | |
| # Split segment into clean words | |
| words = text.split() | |
| words_list.extend(words) | |
| return words_list | |
| def send_voice_words(words): | |
| """Post transcribed words to backend server""" | |
| if not words: | |
| return | |
| headers = {"x-api-key": API_KEY, "Content-Type": "application/json"} | |
| url = f"{API_URL}/api/log/voice" | |
| try: | |
| response = requests.post(url, json={"words": words, "timestamp": new_iso_timestamp()}, headers=headers, timeout=5) | |
| if response.status_code == 200: | |
| print(f"[Sync] Sent {len(words)} spoken words to cloud.") | |
| else: | |
| print(f"[Sync] Failed to send words. Status code: {response.status_code}") | |
| except Exception as e: | |
| print(f"[Sync] Error sending voice words to cloud: {e}") | |
| def notify_stream_start(): | |
| """Notify backend that the stream has started""" | |
| headers = {"x-api-key": API_KEY} | |
| url = f"{API_URL}/api/log/stream-start" | |
| try: | |
| response = requests.post(url, headers=headers, timeout=5) | |
| if response.status_code == 200: | |
| print("[Sync] Sent stream-start signal.") | |
| else: | |
| print(f"[Sync] Failed to send stream-start. Status: {response.status_code}") | |
| except Exception as e: | |
| print(f"[Sync] Error sending stream-start: {e}") | |
| def get_recent_twitch_vods(channel_name): | |
| """Fetch recent Twitch VODs of a user using public Twitch GQL API""" | |
| url = "https://gql.twitch.tv/gql" | |
| headers = { | |
| "Client-Id": "kimne78kx3ncx6brgo4mv6wki5h1ko", | |
| "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" | |
| } | |
| payload = { | |
| "query": """ | |
| query($login: String!) { | |
| user(login: $login) { | |
| videos(first: 10, type: ARCHIVE) { | |
| edges { | |
| node { | |
| id | |
| title | |
| createdAt | |
| lengthSeconds | |
| } | |
| } | |
| } | |
| } | |
| } | |
| """, | |
| "variables": { | |
| "login": channel_name | |
| } | |
| } | |
| try: | |
| res = requests.post(url, json=payload, headers=headers, timeout=10) | |
| if res.status_code == 200: | |
| data = res.json() | |
| edges = data.get("data", {}).get("user", {}).get("videos", {}).get("edges", []) | |
| return [edge.get("node") for edge in edges if edge and edge.get("node")] | |
| except Exception as e: | |
| print(f"[VOD Resolve] Error fetching Twitch VODs: {e}") | |
| return [] | |
| def find_matching_vod(stream_start_time_iso, recent_vods): | |
| try: | |
| from datetime import datetime | |
| clean_stream = stream_start_time_iso.replace('Z', '+00:00') | |
| stream_dt = datetime.fromisoformat(clean_stream) | |
| except Exception as e: | |
| print(f"[Match] Error parsing stream start time: {e}") | |
| return None | |
| best_match = None | |
| min_diff = float('inf') | |
| for node in recent_vods: | |
| vod_id = node.get("id") | |
| created_at_raw = node.get("createdAt") | |
| if not vod_id or not created_at_raw: | |
| continue | |
| try: | |
| clean_vod = created_at_raw.replace('Z', '+00:00') | |
| vod_dt = datetime.fromisoformat(clean_vod) | |
| except Exception: | |
| continue | |
| diff = abs((stream_dt - vod_dt).total_seconds()) | |
| # If the start times are within 2.5 hours (9000 seconds) | |
| if diff < 9000 and diff < min_diff: | |
| min_diff = diff | |
| best_match = vod_id | |
| return best_match | |
| def resolve_missing_vods(): | |
| """Find and resolve twitch_vod_id for pending streams that are missing it""" | |
| headers = {"x-api-key": API_KEY} | |
| try: | |
| # 1. Fetch missing VOD streams from backend | |
| res = requests.get(f"{API_URL}/api/streams/missing-vod", headers=headers, timeout=10) | |
| if res.status_code != 200: | |
| return | |
| streams = res.json().get("streams", []) | |
| if not streams: | |
| return | |
| print(f"[VOD Resolve] Found {len(streams)} pending stream(s) lacking VOD ID.") | |
| # 2. Get recent VODs from Twitch | |
| recent_vods = get_recent_twitch_vods(TWITCH_CHANNEL) | |
| if not recent_vods: | |
| print("[VOD Resolve] Could not retrieve recent Twitch VODs. Skipping resolve.") | |
| return | |
| # 3. Match each stream to a Twitch VOD | |
| for stream in streams: | |
| stream_id = stream.get("id") | |
| start_time_str = stream.get("start_time") | |
| end_time_str = stream.get("end_time") | |
| if not stream_id or not start_time_str: | |
| continue | |
| # Calculate stream age from end_time | |
| try: | |
| clean_end = end_time_str.replace('Z', '+00:00') if end_time_str else start_time_str.replace('Z', '+00:00') | |
| end_dt = datetime.fromisoformat(clean_end) | |
| now_utc = datetime.now(timezone.utc) | |
| age_hours = (now_utc - end_dt).total_seconds() / 3600.0 | |
| except Exception as age_err: | |
| age_hours = 0 | |
| matched_vod_id = find_matching_vod(start_time_str, recent_vods) | |
| if matched_vod_id: | |
| print(f"[VOD Resolve] Stream ID {stream_id} ({start_time_str}) matched with Twitch VOD {matched_vod_id}.") | |
| # Send update to server | |
| up_res = requests.post(f"{API_URL}/api/streams/{stream_id}/resolve-vod", | |
| json={"twitchVodId": matched_vod_id}, | |
| headers={"x-api-key": API_KEY, "Content-Type": "application/json"}, | |
| timeout=10) | |
| if up_res.status_code == 200: | |
| print(f"[VOD Resolve] Successfully updated VOD ID for stream {stream_id}!") | |
| else: | |
| print(f"[VOD Resolve] Failed to update VOD ID: {up_res.status_code}") | |
| else: | |
| if age_hours > 3.0: | |
| print(f"[VOD Resolve] Stream ID {stream_id} is older than 3 hours and has no matching VOD. Marking as completed to clear queue.") | |
| try: | |
| requests.post(f"{API_URL}/api/streams/mark-backfilled", json={"streamId": stream_id}, headers=headers, timeout=10) | |
| except Exception as mark_e: | |
| print(f"[VOD Resolve] Error marking stream as completed: {mark_e}") | |
| else: | |
| print(f"[VOD Resolve] No matching VOD found for stream ID {stream_id} ({start_time_str}) within timeframe (will retry).") | |
| except Exception as e: | |
| print(f"[VOD Resolve] Exception during resolve loop: {e}") | |
| def notify_stream_end(vod_id=None): | |
| """Notify backend that the stream has ended""" | |
| headers = {"x-api-key": API_KEY, "Content-Type": "application/json"} | |
| url = f"{API_URL}/api/log/stream-end" | |
| payload = {} | |
| if vod_id: | |
| payload["twitchVodId"] = vod_id | |
| try: | |
| requests.post(url, json=payload, headers=headers, timeout=5) | |
| print(f"[Sync] Sent stream-end signal. VOD ID resolved: {vod_id}") | |
| except Exception as e: | |
| print(f"[Sync] Failed to send stream-end: {e}") | |
| # Coverage window tracking | |
| coverage_id = None | |
| coverage_stop_flag = threading.Event() | |
| def start_coverage(stream_id, source='live', covered_from=None, covered_to=None): | |
| """Register a new coverage window with the backend""" | |
| global coverage_id | |
| headers = {"x-api-key": API_KEY, "Content-Type": "application/json"} | |
| payload = {"streamId": stream_id, "source": source} | |
| if covered_from: | |
| payload["coveredFrom"] = covered_from | |
| if covered_to: | |
| payload["coveredTo"] = covered_to | |
| try: | |
| res = requests.post(f"{API_URL}/api/log/coverage/start", | |
| json=payload, | |
| headers=headers, timeout=5) | |
| if res.status_code == 200: | |
| cid = res.json().get("coverageId") | |
| print(f"[Coverage] Started window #{cid} (source={source}) for stream {stream_id}") | |
| if source == 'live': | |
| coverage_id = cid | |
| return cid | |
| except Exception as e: | |
| print(f"[Coverage] Error starting coverage: {e}") | |
| return None | |
| def stop_coverage(cid=None, covered_to=None): | |
| """Finalize a coverage window""" | |
| global coverage_id | |
| target_id = cid if cid is not None else coverage_id | |
| if not target_id: | |
| return | |
| headers = {"x-api-key": API_KEY, "Content-Type": "application/json"} | |
| payload = {"coverageId": target_id} | |
| if covered_to: | |
| payload["coveredTo"] = covered_to | |
| try: | |
| requests.post(f"{API_URL}/api/log/coverage/end", | |
| json=payload, | |
| headers=headers, timeout=5) | |
| print(f"[Coverage] Ended window #{target_id}") | |
| except Exception as e: | |
| print(f"[Coverage] Error ending coverage: {e}") | |
| if cid is None or cid == coverage_id: | |
| coverage_id = None | |
| def coverage_heartbeat_loop(): | |
| """Background thread: ping coverage heartbeat every 2 minutes""" | |
| headers = {"x-api-key": API_KEY} | |
| while not coverage_stop_flag.is_set(): | |
| time.sleep(120) | |
| if coverage_id: | |
| try: | |
| requests.patch(f"{API_URL}/api/log/coverage/{coverage_id}/heartbeat", | |
| headers=headers, timeout=5) | |
| except Exception: | |
| pass | |
| # ========================================================================= | |
| # CAPTURE METHODS | |
| # ========================================================================= | |
| def check_stream_live(): | |
| """Check if the Twitch channel is currently streaming using streamlink""" | |
| try: | |
| # Runs streamlink check without downloading stream | |
| result = subprocess.run( | |
| ["streamlink", f"twitch.tv/{TWITCH_CHANNEL}", "--stream-url"], | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.PIPE, | |
| text=True | |
| ) | |
| # If streamlink returns a stream URL, it's live! | |
| return result.returncode == 0 and result.stdout.strip().startswith("http") | |
| except FileNotFoundError: | |
| print("[Error] 'streamlink' utility is not installed or not in system PATH! Please install it.") | |
| time.sleep(10) | |
| return False | |
| def run_stream_capture(model): | |
| """Capture Twitch stream audio using streamlink + ffmpeg and transcribe""" | |
| print(f"[Capture] Monitoring Twitch channel '{TWITCH_CHANNEL}'...") | |
| while not stop_flag.is_set(): | |
| if not check_stream_live(): | |
| # Try to resolve any missing VOD IDs once every 5 minutes (300s) to avoid log spam | |
| global last_resolve_time | |
| now_sec = time.time() | |
| if now_sec - last_resolve_time > 300: | |
| resolve_missing_vods() | |
| last_resolve_time = now_sec | |
| print(f"[Capture] Channel '{TWITCH_CHANNEL}' is offline. Checking for pending VOD backfill tasks...") | |
| try: | |
| headers_get = {"x-api-key": API_KEY} | |
| res = requests.get(f"{API_URL}/api/streams/pending-backfill", headers=headers_get, timeout=10) | |
| if res.status_code == 200: | |
| data = res.json() | |
| pending_streams = data.get("streams", []) | |
| if pending_streams: | |
| print(f"[Backfill] Found {len(pending_streams)} pending VOD backfill(s). Starting automated processing...") | |
| target = pending_streams[0] | |
| stream_id = target.get("id") | |
| vod_id = target.get("twitch_vod_id") | |
| title = target.get("title", "Unknown Archive") | |
| start_time = target.get("start_time") | |
| min_msg_time = target.get("min_message_time") | |
| max_msg_time = target.get("max_message_time") | |
| min_voice_time = target.get("min_voice_time") | |
| max_voice_time = target.get("max_voice_time") | |
| # Parse ISO strings to epoch timestamps | |
| def parse_time(t_str): | |
| if not t_str: | |
| return None | |
| try: | |
| clean_t = t_str.split(".")[0].replace("Z", "").replace("+00:00", "") | |
| return time.mktime(time.strptime(clean_t, "%Y-%m-%dT%H:%M:%S")) | |
| except Exception as parse_err: | |
| print(f"[Backfill] Error parsing time '{t_str}': {parse_err}") | |
| return None | |
| start_epoch = parse_time(start_time) | |
| # Helper to compute ISO timestamp for given offset from start_time | |
| def get_offset_iso_time(start_time_str, offset_seconds): | |
| if not start_time_str or offset_seconds is None: | |
| return None | |
| try: | |
| clean_str = start_time_str.replace('Z', '+00:00') | |
| dt = datetime.fromisoformat(clean_str) | |
| dt_offset = dt + timedelta(seconds=int(offset_seconds)) | |
| return dt_offset.strftime("%Y-%m-%dT%H:%M:%SZ") | |
| except Exception as e: | |
| print(f"[Coverage] Error calculating offset time: {e}") | |
| return None | |
| gaps = target.get("gaps", []) | |
| if not gaps: | |
| print(f"[Backfill] No gaps found for stream {stream_id}, marking complete.") | |
| try: | |
| requests.post(f"{API_URL}/api/streams/mark-backfilled", | |
| json={"streamId": stream_id}, headers=headers_get, timeout=10) | |
| except Exception as e: | |
| print(f"[Backfill] Error marking complete: {e}") | |
| continue | |
| print(f"[Backfill] Found {len(gaps)} gap(s) to fill:") | |
| for g in gaps: | |
| to_str = str(g['to_offset']) + 's' if g['to_offset'] is not None else 'end' | |
| print(f" Gap: {g['from_offset']}s → {to_str}") | |
| # Download and upload each gap in order | |
| for gap_idx, gap in enumerate(gaps, 1): | |
| from_off = gap.get('from_offset', 0) | |
| to_off = gap.get('to_offset', None) | |
| to_str = str(to_off) + 's' if to_off is not None else 'end' | |
| print(f"\n[Backfill] Gap {gap_idx}/{len(gaps)}: chat {from_off}s → {to_str}") | |
| # 1. Register a coverage window for this gap (start) | |
| covered_from_iso = get_offset_iso_time(start_time, from_off) | |
| gap_coverage_id = start_coverage(stream_id, source='backfill', covered_from=covered_from_iso) | |
| # 2. Download and upload chat comments | |
| chat_comments = download_vod_chat(vod_id, start_offset=from_off, end_offset=to_off) | |
| actual_to_offset = to_off | |
| if chat_comments: | |
| print(f"[Backfill] Uploading {len(chat_comments)} messages...") | |
| batch_size = 100 | |
| headers_post = {"x-api-key": API_KEY, "Content-Type": "application/json"} | |
| for i in range(0, len(chat_comments), batch_size): | |
| batch = chat_comments[i:i+batch_size] | |
| try: | |
| requests.post(f"{API_URL}/api/log/messages", | |
| json={"messages": batch}, | |
| headers=headers_post, timeout=10) | |
| except Exception as e: | |
| print(f"[Backfill] Chat batch upload error: {e}") | |
| # 2.5 Extract and upload roles | |
| roles = [ | |
| { | |
| "username": m["username"], | |
| "displayName": m.get("displayName", m["username"]), | |
| "isMod": m.get("isMod", False), | |
| "isSub": m.get("isSub", False), | |
| "isVip": m.get("isVip", False), | |
| "timestamp": m.get("timestamp") | |
| } | |
| for m in chat_comments | |
| if m.get("isMod") or m.get("isSub") or m.get("isVip") | |
| ] | |
| if roles: | |
| print(f"[Backfill] Uploading {len(roles)} roles...") | |
| for i in range(0, len(roles), 500): | |
| batch = roles[i:i+500] | |
| try: | |
| requests.post(f"{API_URL}/api/log/roles", | |
| json={"roles": batch}, | |
| headers=headers_post, timeout=10) | |
| except Exception as e: | |
| print(f"[Backfill] Role batch upload error: {e}") | |
| # If to_off was None, we can use the offset of the last downloaded message | |
| if to_off is None: | |
| try: | |
| last_msg_time_str = chat_comments[-1]["timestamp"] | |
| last_msg_epoch = parse_time(last_msg_time_str) | |
| if last_msg_epoch and start_epoch: | |
| actual_to_offset = int(last_msg_epoch - start_epoch) | |
| except Exception as e: | |
| print(f"[Backfill] Error estimating end offset: {e}") | |
| else: | |
| print(f"[Backfill] No chat in this gap.") | |
| # 3. Transcribe audio for this gap | |
| print(f"[Backfill] Starting Whisper audio transcription for this gap...") | |
| try: | |
| start_time_iso = start_time.replace("+00:00", "").replace("Z", "") + "Z" | |
| transcribe_vod_audio(vod_id, start_time_iso, model, | |
| start_offset=from_off, end_offset=to_off) | |
| except Exception as e: | |
| print(f"[Backfill] Transcription error: {e}") | |
| # 4. Register a coverage window for this gap (end) | |
| covered_to_iso = get_offset_iso_time(start_time, actual_to_offset) | |
| if not covered_to_iso: | |
| covered_to_iso = get_offset_iso_time(start_time, from_off + 60) | |
| stop_coverage(gap_coverage_id, covered_to=covered_to_iso) | |
| # 3. Mark completed | |
| print(f"[Backfill] Marking stream {stream_id} as backfilled...") | |
| try: | |
| res_mark = requests.post(f"{API_URL}/api/streams/mark-backfilled", json={"streamId": stream_id}, headers=headers_get, timeout=10) | |
| if res_mark.status_code == 200: | |
| print(f"[Backfill] Successfully marked stream {stream_id} as backfilled!") | |
| else: | |
| print(f"[Backfill] Failed to mark stream: {res_mark.status_code} - {res_mark.text}") | |
| except Exception as e: | |
| print(f"[Backfill] Network error marking stream: {e}") | |
| # Loop again immediately to process next VOD or check if live | |
| continue | |
| else: | |
| print("[Capture] No pending VOD backfills found.") | |
| else: | |
| print(f"[Backfill] Failed to fetch pending backfills: {res.status_code}") | |
| except Exception as err: | |
| print(f"[Backfill] Error checking pending backfills: {err}") | |
| print(f"[Capture] Retrying stream check in 60 seconds...") | |
| time.sleep(60) | |
| continue | |
| print(f"[Capture] Stream is LIVE! Starting audio pipeline...") | |
| # Notify backend that stream has started and get stream id for coverage | |
| notify_stream_start() | |
| # Start coverage window for the active stream | |
| try: | |
| import requests as _req | |
| _headers = {"x-api-key": API_KEY} | |
| _stream_res = _req.get(f"{API_URL}/api/streams/active", headers=_headers, timeout=5) | |
| if _stream_res.status_code == 200: | |
| _active = _stream_res.json() | |
| _sid = _active.get("stream", {}).get("id") or _active.get("id") | |
| if _sid: | |
| start_coverage(_sid, source='live') | |
| except Exception as _e: | |
| print(f"[Coverage] Could not get active stream for coverage: {_e}") | |
| # Start streamlink piping audio to ffmpeg, converting it to raw 16kHz mono 16-bit PCM | |
| streamlink_cmd = ["streamlink", f"twitch.tv/{TWITCH_CHANNEL}", "audio,worst", "-O"] | |
| ffmpeg_cmd = ["ffmpeg", "-i", "pipe:0", "-ac", "1", "-ar", "16000", "-f", "s16le", "-"] | |
| p_streamlink = None | |
| p_ffmpeg = None | |
| try: | |
| p_streamlink = subprocess.Popen(streamlink_cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) | |
| p_ffmpeg = subprocess.Popen(ffmpeg_cmd, stdin=p_streamlink.stdout, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) | |
| # Read in 10-second chunks | |
| # 16000Hz * 16-bit(2 bytes) * 10 seconds = 320,000 bytes | |
| chunk_size = 16000 * 2 * 10 | |
| while not stop_flag.is_set(): | |
| pcm_data = p_ffmpeg.stdout.read(chunk_size) | |
| if not pcm_data: | |
| print("[Capture] Stream audio stopped or disconnected.") | |
| break | |
| # Transcribe in a background thread to prevent blocking audio read | |
| threading.Thread( | |
| target=lambda data=pcm_data: send_voice_words(transcribe_audio_segment(model, data)) | |
| ).start() | |
| except Exception as err: | |
| print(f"[Capture] Audio pipeline crashed: {err}") | |
| finally: | |
| # Clean up processes | |
| for p in [p_ffmpeg, p_streamlink]: | |
| if p: | |
| try: | |
| p.terminate() | |
| p.wait(timeout=2) | |
| except: | |
| try: p.kill() | |
| except: pass | |
| # Try to get the latest VOD ID from Twitch GQL to auto-resolve it | |
| latest_vod_id = None | |
| try: | |
| vods = get_recent_twitch_vods(TWITCH_CHANNEL) | |
| if vods: | |
| latest_vod_id = vods[0].get("id") | |
| except Exception as e: | |
| print(f"[Capture] Error pre-fetching latest VOD ID: {e}") | |
| notify_stream_end(latest_vod_id) | |
| print("[Capture] Pipeline stopped. Waiting 30 seconds before checking stream status...") | |
| time.sleep(30) | |
| def run_microphone_capture(model): | |
| """Capture local microphone/system audio and transcribe""" | |
| try: | |
| import sounddevice as sd | |
| except ImportError: | |
| print("[Error] 'sounddevice' library is missing! Install it via pip install sounddevice.") | |
| sys.exit(1) | |
| print("[Capture] Starting microphone capture. Listening...") | |
| sample_rate = 16000 | |
| duration = 10 # Transcribe every 10 seconds | |
| while not stop_flag.is_set(): | |
| try: | |
| # Record float32 directly from default input device | |
| recording = sd.rec( | |
| int(duration * sample_rate), | |
| samplerate=sample_rate, | |
| channels=1, | |
| dtype='float32' | |
| ) | |
| sd.wait() # Wait until recording is finished | |
| # sounddevice records float32 directly, no need for raw conversion | |
| audio_np = recording.flatten() | |
| def process_mic(audio=audio_np): | |
| segments, info = model.transcribe( | |
| audio, | |
| beam_size=5, | |
| language="ru", | |
| temperature=0.0, | |
| condition_on_previous_text=False, | |
| log_prob_threshold=-0.8, | |
| no_speech_threshold=0.6, | |
| vad_filter=True, | |
| vad_parameters=dict(threshold=0.5, min_silence_duration_ms=500) | |
| ) | |
| words_list = [] | |
| for segment in segments: | |
| text = segment.text.strip() | |
| if text: | |
| print(f"[Whisper Mic] Transcribed: \"{text}\"") | |
| words_list.extend(text.split()) | |
| send_voice_words(words_list) | |
| threading.Thread(target=process_mic).start() | |
| except Exception as e: | |
| print(f"[Capture] Microphone error: {e}") | |
| time.sleep(5) | |
| # ========================================================================= | |
| # MAIN EXECUTION | |
| # ========================================================================= | |
| if __name__ == "__main__": | |
| print("=========================================================") | |
| print(" Twitch Analytics Local Logger & Voice Sync ") | |
| print("=========================================================") | |
| print(f"Target Channel: {TWITCH_CHANNEL}") | |
| print(f"Capture Method: {CAPTURE_METHOD}") | |
| print(f"Whisper Model: {WHISPER_MODEL_SIZE}") | |
| print(f"API Backend URL: {API_URL}") | |
| print("=========================================================") | |
| # 1. Initialize Whisper | |
| whisper_model = init_whisper_model() | |
| # 2. Start Twitch Chat background threads | |
| t_chat = threading.Thread(target=twitch_chat_listener, daemon=True) | |
| t_sender = threading.Thread(target=chat_sender, daemon=True) | |
| t_heartbeat = threading.Thread(target=coverage_heartbeat_loop, daemon=True) | |
| t_chat.start() | |
| t_sender.start() | |
| t_heartbeat.start() | |
| # 3. Start Audio Capture (Blocks main thread) | |
| try: | |
| if CAPTURE_METHOD == "stream": | |
| run_stream_capture(whisper_model) | |
| elif CAPTURE_METHOD == "microphone": | |
| run_microphone_capture(whisper_model) | |
| else: | |
| print(f"[Error] Unknown CAPTURE_METHOD '{CAPTURE_METHOD}'. Choose 'stream' or 'microphone'.") | |
| except KeyboardInterrupt: | |
| print("\n[Shutting Down] Gracefully stopping threads...") | |
| finally: | |
| # End coverage window and notify stream ended | |
| stop_coverage() | |
| coverage_stop_flag.set() | |
| # Try to get the latest VOD ID from Twitch GQL to auto-resolve it | |
| latest_vod_id = None | |
| if CAPTURE_METHOD == "stream": | |
| try: | |
| vods = get_recent_twitch_vods(TWITCH_CHANNEL) | |
| if vods: | |
| latest_vod_id = vods[0].get("id") | |
| except Exception as e: | |
| print(f"[Capture] Error pre-fetching latest VOD ID: {e}") | |
| notify_stream_end(latest_vod_id) | |
| stop_flag.set() | |
| time.sleep(1) | |
| print("[Shutting Down] Done.") | |