Sasha
feat: prepare transition to decoupled hosting (Render + Netlify + Supabase)
d9a03db
Raw
History Blame
25 kB
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)
import time
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")
# 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)
while not stop_flag.is_set():
# 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
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
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
}
# 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:
url = f"{API_URL}/api/log/messages"
response = requests.post(url, json={"messages": messages}, headers=headers, timeout=5)
if response.status_code == 200:
pass # Success
else:
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}")
# 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})...")
# 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
)
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",
vad_filter=True, # Voice Activity Detection filters out silence
vad_parameters=dict(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_end():
"""Notify backend that the stream has ended"""
headers = {"x-api-key": API_KEY}
url = f"{API_URL}/api/log/stream-end"
try:
requests.post(url, headers=headers, timeout=5)
print("[Sync] Sent stream-end signal.")
except Exception as e:
print(f"[Sync] Failed to send stream-end: {e}")
# =========================================================================
# 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():
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)
# -- CHAT BACKFILL TASKS --
chat_tasks = []
if not min_msg_time:
# 0 messages, download everything
chat_tasks.append((0, None))
else:
min_msg_epoch = parse_time(min_msg_time)
max_msg_epoch = parse_time(max_msg_time)
min_msg_offset = int(min_msg_epoch - start_epoch) if (min_msg_epoch and start_epoch) else 0
max_msg_offset = int(max_msg_epoch - start_epoch) if (max_msg_epoch and start_epoch) else 0
# If first message is > 60s from start, download the beginning gap
if min_msg_offset > 60:
chat_tasks.append((0, min_msg_offset))
# Download from last message to end
chat_tasks.append((max_msg_offset, None))
# -- VOICE BACKFILL TASKS --
voice_tasks = []
if not min_voice_time:
# 0 words, transcribe everything
voice_tasks.append((0, None))
else:
min_voice_epoch = parse_time(min_voice_time)
max_voice_epoch = parse_time(max_voice_time)
min_voice_offset = int(min_voice_epoch - start_epoch) if (min_voice_epoch and start_epoch) else 0
max_voice_offset = int(max_voice_epoch - start_epoch) if (max_voice_epoch and start_epoch) else 0
# If first voice word is > 60s from start, transcribe the beginning gap
if min_voice_offset > 60:
voice_tasks.append((0, min_voice_offset))
# Transcribe from last word (with 10s safety overlap) to end
voice_tasks.append((max(0, max_voice_offset - 10), None))
print(f"\n=========================================================")
print(f"[Backfill] Processing: {title}")
print(f"[Backfill] VOD ID: {vod_id}")
print(f"[Backfill] Stream ID: {stream_id}")
print(f"[Backfill] Chat Tasks: {chat_tasks}")
print(f"[Backfill] Voice Tasks: {voice_tasks}")
print(f"=========================================================")
# 1. Download and upload chat segments
for start_off, end_off in chat_tasks:
print(f"[Backfill] Downloading chat from {start_off}s to {'end' if end_off is None else str(end_off) + 's'}...")
chat_comments = download_vod_chat(vod_id, start_offset=start_off, end_offset=end_off)
if chat_comments:
print(f"[Backfill] Uploading {len(chat_comments)} chat comments...")
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. Download and transcribe audio segments
print(f"[Backfill] Starting Whisper audio transcription...")
for start_off, end_off in voice_tasks:
print(f"[Backfill] Transcribing audio from {start_off}s to {'end' if end_off is None else str(end_off) + 's'}...")
try:
start_time_iso = start_time.replace("+00:00", "").replace("Z", "") + "Z"
transcribe_vod_audio(vod_id, start_time_iso, model, start_offset=start_off, end_offset=end_off)
except Exception as e:
print(f"[Backfill] Transcription error: {e}")
# 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...")
# 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
notify_stream_end()
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()
# Transcribe and send words
def process_mic(audio=audio_np):
segments, info = model.transcribe(audio, beam_size=5, language="ru")
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_chat.start()
t_sender.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...")
stop_flag.set()
time.sleep(1)
print("[Shutting Down] Done.")