Heewon Oh commited on
Commit
742e266
·
0 Parent(s):

chore: reset repository history - ArtifactNet HF Spaces Demo v8.0

Browse files
.gitattributes ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ *.egg
6
+ dist/
7
+ build/
8
+
9
+ # IP Protection note: core/proprietary.py contains obfuscated algorithms
10
+ # (난독화된 알고리즘으로 특허 핵심 보호)
11
+
12
+ # Models (downloaded at runtime from HF Hub)
13
+ *.onnx
14
+ *.pt
15
+ *.onnx.data
16
+
17
+ # Environment
18
+ .env
19
+ .venv/
20
+ venv/
21
+
22
+ # IDE
23
+ .vscode/
24
+ .idea/
25
+ *.swp
26
+ *.swo
27
+
28
+ # OS
29
+ .DS_Store
30
+ Thumbs.db
31
+
32
+ # Gradio
33
+ flagged/
34
+
35
+ # Development files (not needed in HF Spaces)
36
+ CLAUDE.md
37
+ .claude/
38
+ local_demo_v77.py
39
+ testing/
40
+ trash/
Dockerfile.youtube-proxy ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Multi-stage Dockerfile for YouTube Proxy Server
2
+
3
+ FROM python:3.11-slim as builder
4
+
5
+ # Install build dependencies
6
+ RUN apt-get update && apt-get install -y --no-install-recommends \
7
+ build-essential \
8
+ && rm -rf /var/lib/apt/lists/*
9
+
10
+ # Create virtual environment
11
+ RUN python -m venv /opt/venv
12
+ ENV PATH="/opt/venv/bin:$PATH"
13
+
14
+ # Copy and install Python dependencies
15
+ COPY requirements.txt .
16
+ RUN pip install --no-cache-dir -r requirements.txt
17
+
18
+ # ============================================================
19
+ # Final stage
20
+ # ============================================================
21
+
22
+ FROM python:3.11-slim
23
+
24
+ # Install runtime dependencies (ffmpeg for yt-dlp)
25
+ RUN apt-get update && apt-get install -y --no-install-recommends \
26
+ ffmpeg \
27
+ && rm -rf /var/lib/apt/lists/*
28
+
29
+ # Copy virtual environment from builder
30
+ COPY --from=builder /opt/venv /opt/venv
31
+ ENV PATH="/opt/venv/bin:$PATH"
32
+
33
+ # Create non-root user for security (use UID 1001 to avoid conflicts)
34
+ RUN useradd -m -u 1001 appuser 2>/dev/null || true
35
+
36
+ # Set working directory
37
+ WORKDIR /app
38
+
39
+ # Copy application
40
+ COPY youtube_proxy_server.py .
41
+
42
+ # Change ownership
43
+ RUN chown -R appuser:appuser /app 2>/dev/null || true
44
+
45
+ # Switch to non-root user
46
+ USER appuser
47
+
48
+ # Expose port
49
+ EXPOSE 8765
50
+
51
+ # Health check
52
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
53
+ CMD python -c "import requests; requests.get('http://localhost:8765/health')" || exit 1
54
+
55
+ # Default environment variables
56
+ ENV HOST=0.0.0.0
57
+ ENV PORT=8765
58
+ ENV LOG_LEVEL=INFO
59
+ ENV YOUTUBE_PROXY_API_KEY=default-key
60
+
61
+ # Run application
62
+ CMD ["python", "youtube_proxy_server.py"]
HF_SPACES_ENV.md ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # HF Spaces 환경변수 설정 가이드
2
+
3
+ YouTube 프록시를 통해 HF Spaces 앱에서 YouTube URL 다운로드를 활성화하려면 다음 환경변수를 설정하세요.
4
+
5
+ ## 설정 단계
6
+
7
+ ### 1. cloudflared를 통한 외부 접근
8
+
9
+ youtube-proxy 서비스는 `youtube-proxy.intrect.io`를 통해 접근 가능합니다 (Cloudflare Tunnel 역프록시).
10
+
11
+ ### 2. HF Spaces 시크릿 설정
12
+
13
+ HF Spaces 설정에서 다음 환경변수를 추가하세요:
14
+
15
+ #### `YOUTUBE_PROXY_URL`
16
+ ```
17
+ https://youtube-proxy.intrect.io
18
+ ```
19
+
20
+ #### `YOUTUBE_PROXY_API_KEY`
21
+ ```
22
+ c60ba3dc9f26cfc700958983f82b997eac084743aad9f5be4db7bb625ae6dbbd
23
+ ```
24
+
25
+ 이는 `docker-compose.youtube-proxy.yml`의 `YOUTUBE_PROXY_API_KEY` 환경변수와 **정확히 동일**해야 합니다.
26
+
27
+ ## 인증 흐름
28
+
29
+ 1. HF Spaces 앱이 YouTube URL을 받으면
30
+ 2. `YOUTUBE_PROXY_URL` 및 `YOUTUBE_PROXY_API_KEY` 사용
31
+ 3. `https://youtube-proxy.intrect.io/download-youtube` 엔드포인트로 POST 요청
32
+ 4. `Authorization: Bearer {YOUTUBE_PROXY_API_KEY}` 헤더 포함
33
+ 5. 프록시 서버가 yt-dlp로 다운로드
34
+ 6. WAV 파일 반환
35
+
36
+ ## 보안 고려사항
37
+
38
+ - API 키는 **절대 공개하지 마세요**
39
+ - cloudflared 역프록시를 통해서만 접근 가능 (외부 포트 노출 없음)
40
+ - 컨테이너는 `proxy` 사용자로 실행 (root 아님)
41
+ - 최소 권한 원칙 준수
42
+
43
+ ## 문제 해결
44
+
45
+ ### HF Spaces에서 연결 실패
46
+
47
+ 1. cloudflared 상태 확인:
48
+ ```bash
49
+ sudo systemctl status cloudflared
50
+ ```
51
+
52
+ 2. youtube-proxy 컨테이너 상태 확인:
53
+ ```bash
54
+ docker ps | grep youtube-proxy
55
+ docker logs artifactnet-youtube-proxy
56
+ ```
57
+
58
+ 3. DNS 확인:
59
+ ```bash
60
+ curl -I https://youtube-proxy.intrect.io/health
61
+ ```
62
+
63
+ ### API 키 불일치
64
+
65
+ `docker-compose.youtube-proxy.yml`의 `YOUTUBE_PROXY_API_KEY`와 HF Spaces의 `YOUTUBE_PROXY_API_KEY`가 **정확히 동일**한지 확인하세요.
66
+
67
+ ## Rate Limiting 설정 (권장)
68
+
69
+ 과도한 요청과 연속 스팸으로부터 HF Spaces 및 ubuntu-mini 보호:
70
+
71
+ #### `RATE_LIMIT_REQUESTS`
72
+ ```
73
+ 5
74
+ ```
75
+ (기본값: 5회, 1시간당)
76
+
77
+ #### `RATE_LIMIT_MINUTES`
78
+ ```
79
+ 60
80
+ ```
81
+ (기본값: 60분 윈도우)
82
+
83
+ #### `BURST_LIMIT_PER_MINUTE`
84
+ ```
85
+ 2
86
+ ```
87
+ (기본값: 최대 2회/분, 연속 요청 방지)
88
+
89
+ **동작:**
90
+ - **Burst 제한**: 사용자당 2회/분 (연속 요청 방지)
91
+ - **시간 제한**: 사용자당 5회/60분 (장기 남용 방지)
92
+ - 둘 다 만족해야 요청 허용
93
+
94
+ ---
95
+
96
+ ## 에지 케이스 수집 설정 (선택사항)
97
+
98
+ Uncertain 판정 곡의 분석 데이터를 자동으로 수집하려면:
99
+
100
+ #### `UBUNTU_MINI_ENABLED`
101
+ ```
102
+ true
103
+ ```
104
+
105
+ #### `UBUNTU_MINI_HOST`
106
+ ```
107
+ ubuntu-mini.local
108
+ ```
109
+
110
+ #### `UBUNTU_MINI_PORT`
111
+ ```
112
+ 9000
113
+ ```
114
+
115
+ **수집되는 것:**
116
+ - Mel-spectrogram (30초 미만)
117
+ - 판정 통계
118
+ - 타임스탬프
119
+
120
+ **수집되지 않는 것:**
121
+ - 원본 오디오 파일
122
+ - 개인 정보
123
+
124
+ ## 다음 단계
125
+
126
+ 1. Docker 컨테이너 실행:
127
+ ```bash
128
+ docker-compose -f docker-compose.youtube-proxy.yml up -d
129
+ ```
130
+
131
+ 2. 건강 체크:
132
+ ```bash
133
+ curl -H "Authorization: Bearer <your-key>" \
134
+ https://youtube-proxy.intrect.io/health
135
+ ```
136
+
137
+ 3. HF Spaces에서 YouTube URL 탭이 나타나면 작동 중입니다.
138
+
139
+ 4. Uncertain 곡이 자동으로 ubuntu-mini로 전송되는지 확인합니다.
README.md ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: ArtifactNet
3
+ emoji: 🔍
4
+ colorFrom: indigo
5
+ colorTo: yellow
6
+ sdk: gradio
7
+ sdk_version: 5.20.1
8
+ app_file: app.py
9
+ pinned: false
10
+ license: mit
11
+ hardware: zero-a10g
12
+ ---
13
+
14
+ # ArtifactNet — AI Music Forensic Detector
15
+
16
+ Detect AI-generated music using deep spectral analysis and neural networks.
17
+ # Model sync check - 20260225
app.py ADDED
@@ -0,0 +1,611 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Purpose: ArtifactNet HF Spaces demo — Gradio Blocks UI (3-tier verdict)
3
+
4
+ """ArtifactNet — AI Music Forensic Detector.
5
+
6
+ v8.0: ArtifactUNet ONNX — CPU-only, no GPU required.
7
+ """
8
+
9
+ import sys
10
+ import os
11
+ import json
12
+ import time
13
+ import subprocess
14
+ import tempfile
15
+ import warnings
16
+ from collections import defaultdict
17
+ from datetime import datetime, timedelta
18
+
19
+ import numpy as np
20
+ import torch
21
+ import gradio as gr
22
+
23
+ # Add demo/ directory to module path
24
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
25
+
26
+ from config import SR
27
+ from inference.audio_utils import load_audio_mono_tensor, get_audio_info
28
+ from inference.e2e_model import get_model, run_e2e_inference
29
+ from visualization.spectrogram import plot_spectrograms
30
+ from visualization.timeline import plot_timeline
31
+ from ui import VerdictCardBuilder, create_theme, create_header, create_about_section
32
+ from core import compute_stats, classify
33
+
34
+ warnings.filterwarnings("ignore")
35
+
36
+ IS_HF_SPACES = os.environ.get("SPACE_ID") is not None
37
+ YOUTUBE_PROXY_URL = os.environ.get("YOUTUBE_PROXY_URL", "")
38
+ UBUNTU_MINI_HOST = os.environ.get("UBUNTU_MINI_HOST", "ubuntu-mini.local")
39
+ UBUNTU_MINI_PORT = int(os.environ.get("UBUNTU_MINI_PORT", "9000"))
40
+ UBUNTU_MINI_ENABLED = os.environ.get("UBUNTU_MINI_ENABLED", "false").lower() == "true"
41
+
42
+ # Rate limiting settings
43
+ RATE_LIMIT_REQUESTS = int(os.environ.get("RATE_LIMIT_REQUESTS", "5")) # requests per hour
44
+ RATE_LIMIT_MINUTES = int(os.environ.get("RATE_LIMIT_MINUTES", "60")) # time window in minutes
45
+ BURST_LIMIT_PER_MINUTE = int(os.environ.get("BURST_LIMIT_PER_MINUTE", "2")) # max requests per minute
46
+
47
+
48
+ # ============================================================
49
+ # Rate Limiter (dual-window: long-term + burst protection)
50
+ # ============================================================
51
+
52
+ class RateLimiter:
53
+ """Per-user rate limiting with both long-term and burst protection."""
54
+
55
+ def __init__(self, max_requests: int, window_minutes: int, burst_per_minute: int):
56
+ self.max_requests = max_requests
57
+ self.window_secs = window_minutes * 60
58
+ self.burst_per_minute = burst_per_minute
59
+ self.requests = defaultdict(list) # long-term tracking
60
+ self.minute_requests = defaultdict(list) # burst tracking
61
+
62
+ def _get_client_id(self) -> str:
63
+ """Get client ID from Gradio request context (IP-based)."""
64
+ try:
65
+ import gradio.context as ctx
66
+ request = ctx.get_request()
67
+ if request and hasattr(request, 'client'):
68
+ return str(request.client[0]) # IP address
69
+ except Exception:
70
+ pass
71
+ return "unknown"
72
+
73
+ def is_allowed(self, client_id: str = None) -> tuple:
74
+ """
75
+ Check if request is allowed. Returns (allowed: bool, reason: str).
76
+ Enforces both long-term limit (5/hour) and burst limit (2/minute).
77
+ """
78
+ if client_id is None:
79
+ client_id = self._get_client_id()
80
+
81
+ now = datetime.now()
82
+
83
+ # ===== Check 1: Burst limit (requests per minute) =====
84
+ minute_cutoff = now - timedelta(seconds=60)
85
+ self.minute_requests[client_id] = [
86
+ req_time for req_time in self.minute_requests[client_id]
87
+ if req_time > minute_cutoff
88
+ ]
89
+
90
+ if len(self.minute_requests[client_id]) >= self.burst_per_minute:
91
+ return False, f"Too many requests in the last minute. Please wait 60 seconds."
92
+
93
+ # ===== Check 2: Long-term limit (requests per hour) =====
94
+ long_cutoff = now - timedelta(seconds=self.window_secs)
95
+ self.requests[client_id] = [
96
+ req_time for req_time in self.requests[client_id]
97
+ if req_time > long_cutoff
98
+ ]
99
+
100
+ if len(self.requests[client_id]) >= self.max_requests:
101
+ if self.requests[client_id]:
102
+ reset_time = self.requests[client_id][0] + timedelta(seconds=self.window_secs)
103
+ reset_str = reset_time.strftime("%H:%M UTC")
104
+ return False, f"Hourly limit reached ({self.max_requests}). Try again at {reset_str}."
105
+ return False, f"Hourly limit reached. Please wait."
106
+
107
+ # Both checks passed - add request
108
+ self.requests[client_id].append(now)
109
+ self.minute_requests[client_id].append(now)
110
+ return True, ""
111
+
112
+ def get_remaining(self, client_id: str = None) -> dict:
113
+ """Get remaining requests for both limits."""
114
+ if client_id is None:
115
+ client_id = self._get_client_id()
116
+
117
+ now = datetime.now()
118
+
119
+ # Long-term
120
+ long_cutoff = now - timedelta(seconds=self.window_secs)
121
+ long_reqs = [r for r in self.requests[client_id] if r > long_cutoff]
122
+ long_remaining = self.max_requests - len(long_reqs)
123
+
124
+ # Burst
125
+ minute_cutoff = now - timedelta(seconds=60)
126
+ minute_reqs = [r for r in self.minute_requests[client_id] if r > minute_cutoff]
127
+ minute_remaining = self.burst_per_minute - len(minute_reqs)
128
+
129
+ return {
130
+ "hourly": long_remaining,
131
+ "per_minute": minute_remaining
132
+ }
133
+
134
+
135
+ # Global rate limiter
136
+ rate_limiter = RateLimiter(RATE_LIMIT_REQUESTS, RATE_LIMIT_MINUTES, BURST_LIMIT_PER_MINUTE)
137
+
138
+
139
+ # ============================================================
140
+ # Uncertain case collection (edge case detection)
141
+ # ============================================================
142
+
143
+ def _extract_mel_spectrogram(audio_np: np.ndarray, mono_np: np.ndarray) -> np.ndarray:
144
+ """Extract mel-spectrogram for uncertain cases (CNN training)."""
145
+ from librosa import feature
146
+
147
+ mel_spec = feature.melspectrogram(
148
+ y=mono_np,
149
+ sr=SR,
150
+ n_fft=2048,
151
+ hop_length=512,
152
+ n_mels=128
153
+ )
154
+ log_mel = np.log(np.clip(mel_spec, 1e-9, None))
155
+ return log_mel.astype(np.float32)
156
+
157
+
158
+ def _send_uncertain_to_ubuntu_mini(
159
+ mel_spec: np.ndarray,
160
+ verdict_stats: dict,
161
+ duration_sec: float,
162
+ source: str # "youtube" or "upload"
163
+ ) -> bool:
164
+ """Send uncertain case mel-spectrogram to ubuntu-mini for edge case collection."""
165
+ if not UBUNTU_MINI_ENABLED:
166
+ return False
167
+
168
+ try:
169
+ import requests
170
+
171
+ # 30초 미만만 수집
172
+ if duration_sec > 30:
173
+ return False
174
+
175
+ # mel-spectrogram 직렬화 (base64)
176
+ mel_bytes = mel_spec.tobytes()
177
+ mel_b64 = __import__('base64').b64encode(mel_bytes).decode('utf-8')
178
+
179
+ payload = {
180
+ "mel_spectrogram": mel_b64,
181
+ "mel_shape": mel_spec.shape,
182
+ "verdict_stats": verdict_stats,
183
+ "duration_sec": duration_sec,
184
+ "source": source,
185
+ "timestamp": time.strftime("%Y-%m-%d %H:%M:%S")
186
+ }
187
+
188
+ response = requests.post(
189
+ f"http://{UBUNTU_MINI_HOST}:{UBUNTU_MINI_PORT}/collect-uncertain",
190
+ json=payload,
191
+ timeout=5
192
+ )
193
+ return response.status_code == 200
194
+ except Exception as e:
195
+ print(f"[WARNING] Failed to send uncertain case: {e}")
196
+ return False
197
+
198
+
199
+ def _send_edge_case_report(
200
+ verdict: str,
201
+ reported_verdict: str,
202
+ mel_spec: np.ndarray,
203
+ verdict_stats: dict,
204
+ duration_sec: float,
205
+ user_comment: str = ""
206
+ ) -> bool:
207
+ """Send edge case report (wrong verdict) to ubuntu-mini for training correction."""
208
+ if not UBUNTU_MINI_ENABLED:
209
+ return False
210
+
211
+ try:
212
+ import requests
213
+
214
+ # mel-spectrogram 직렬화 (base64)
215
+ mel_bytes = mel_spec.tobytes()
216
+ mel_b64 = __import__('base64').b64encode(mel_bytes).decode('utf-8')
217
+
218
+ payload = {
219
+ "report_type": "edge_case_correction",
220
+ "predicted_verdict": verdict,
221
+ "true_verdict": reported_verdict,
222
+ "mel_spectrogram": mel_b64,
223
+ "mel_shape": mel_spec.shape,
224
+ "verdict_stats": verdict_stats,
225
+ "duration_sec": duration_sec,
226
+ "user_comment": user_comment,
227
+ "timestamp": time.strftime("%Y-%m-%d %H:%M:%S")
228
+ }
229
+
230
+ response = requests.post(
231
+ f"http://{UBUNTU_MINI_HOST}:{UBUNTU_MINI_PORT}/report-edge-case",
232
+ json=payload,
233
+ timeout=5
234
+ )
235
+ return response.status_code == 200
236
+ except Exception as e:
237
+ print(f"[WARNING] Failed to send edge case report: {e}")
238
+ return False
239
+
240
+
241
+ # Proprietary algorithms moved to core module for IP protection
242
+
243
+
244
+ # ============================================================
245
+ # Inference wrapper (CPU-only)
246
+ # ============================================================
247
+
248
+ def _run_e2e(wav_mono_tensor):
249
+ """E2E inference — ArtifactUNet ONNX (CPU)."""
250
+ return run_e2e_inference(wav_mono_tensor)
251
+
252
+
253
+ # ============================================================
254
+ # YouTube URL -> audio download (local only)
255
+ # ============================================================
256
+
257
+ def _download_youtube_audio(url: str) -> str:
258
+ """Download audio from YouTube URL as WAV. Returns temporary file path."""
259
+ if YOUTUBE_PROXY_URL:
260
+ import requests
261
+ api_key = os.environ.get("YOUTUBE_PROXY_API_KEY", "").strip()
262
+ if not api_key:
263
+ raise RuntimeError("YOUTUBE_PROXY_API_KEY not set")
264
+
265
+ response = requests.post(
266
+ f"{YOUTUBE_PROXY_URL}/download-youtube",
267
+ json={"url": url},
268
+ headers={"Authorization": f"Bearer {api_key}"},
269
+ timeout=180
270
+ )
271
+
272
+ if response.status_code != 200:
273
+ try:
274
+ error_msg = response.json().get('detail', response.text[:200])
275
+ except Exception as e:
276
+ error_msg = response.text[:500] if response.text else f"HTTP {response.status_code} (empty response)"
277
+ if not error_msg or error_msg.startswith("<"):
278
+ error_msg = f"HTTP {response.status_code}: {type(e).__name__}"
279
+ raise RuntimeError(f"Proxy server error: {error_msg}")
280
+
281
+ if not response.content:
282
+ raise RuntimeError("Proxy returned empty file (no audio data)")
283
+
284
+ tmpdir = tempfile.mkdtemp(prefix="artifactnet_yt_")
285
+ out_path = os.path.join(tmpdir, "audio.wav")
286
+ with open(out_path, 'wb') as f:
287
+ f.write(response.content)
288
+ return out_path
289
+ else:
290
+ tmpdir = tempfile.mkdtemp(prefix="artifactnet_yt_")
291
+ out_path = os.path.join(tmpdir, "audio.wav")
292
+ cmd = [
293
+ "yt-dlp",
294
+ "--no-playlist",
295
+ "-x", "--audio-format", "wav",
296
+ "--audio-quality", "0",
297
+ "--max-filesize", "50M",
298
+ "-o", out_path,
299
+ url,
300
+ ]
301
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
302
+ if result.returncode != 0:
303
+ raise RuntimeError(f"yt-dlp error: {result.stderr[:300]}")
304
+ for f in os.listdir(tmpdir):
305
+ return os.path.join(tmpdir, f)
306
+ raise RuntimeError("Download completed but no file found")
307
+
308
+
309
+ def analyze_youtube(url: str):
310
+ """Analyze via YouTube URL (local only)."""
311
+ # Rate limiting check (both burst and hourly)
312
+ allowed, reason = rate_limiter.is_allowed()
313
+ if not allowed:
314
+ err = (
315
+ f"<p style='color:#ff4757;'>"
316
+ f"⏱️ Rate limit: {reason}<br>"
317
+ f"<small>Limits: {BURST_LIMIT_PER_MINUTE}/min, {RATE_LIMIT_REQUESTS}/{RATE_LIMIT_MINUTES}min</small>"
318
+ f"</p>"
319
+ )
320
+ return err, None, None, None, None, None, None, None, None
321
+
322
+ if not url or not url.strip():
323
+ return (
324
+ VerdictCardBuilder.build_empty_card(),
325
+ None, None, None, None, None, None, None, None,
326
+ )
327
+ url = url.strip()
328
+ try:
329
+ audio_path = _download_youtube_audio(url)
330
+ except Exception as e:
331
+ err = f"<p style='color:#ff4757'>Download failed: {e}</p>"
332
+ return err, None, None, None, None, None, None, None, None
333
+ return analyze_audio(audio_path)
334
+
335
+
336
+ # ============================================================
337
+ # Main analysis function
338
+ # ============================================================
339
+
340
+ def analyze_audio(audio_path: str):
341
+ """Analyze audio file -> (verdict_html, spectrogram, timeline, json_file, verdict, stats, mel_spec, duration, audio_path)."""
342
+ # Rate limiting check (skip if called from analyze_youtube, which already checked)
343
+ # Only check for direct file uploads
344
+ if audio_path and "artifactnet_yt_" not in audio_path:
345
+ allowed, reason = rate_limiter.is_allowed()
346
+ if not allowed:
347
+ err = (
348
+ f"<p style='color:#ff4757;'>"
349
+ f"⏱️ Rate limit: {reason}<br>"
350
+ f"<small>Limits: {BURST_LIMIT_PER_MINUTE}/min, {RATE_LIMIT_REQUESTS}/{RATE_LIMIT_MINUTES}min</small>"
351
+ f"</p>"
352
+ )
353
+ return err, None, None, None, None, None, None, None, None
354
+
355
+ if audio_path is None:
356
+ return (
357
+ VerdictCardBuilder.build_empty_card(),
358
+ None, None, None, None, None, None, None, None,
359
+ )
360
+
361
+ t0 = time.time()
362
+
363
+ # 1. Load audio
364
+ try:
365
+ mono_tensor, audio_np, is_stereo = load_audio_mono_tensor(audio_path)
366
+ except Exception as e:
367
+ err = f"<p style='color:#ff4757'>Error loading audio: {e}</p>"
368
+ return err, None, None, None, None, None, None, None, None
369
+
370
+ info = get_audio_info(audio_np, is_stereo)
371
+ mono_np = mono_tensor.numpy()
372
+
373
+ # 2. E2E inference (ONNX — GPU if available, else CPU)
374
+ chunk_probs, _ = _run_e2e(mono_tensor)
375
+
376
+ # 3. Distribution-based verdict (LGBM 2nd-stage)
377
+ seg_stats = compute_stats(chunk_probs)
378
+ elapsed = time.time() - t0
379
+
380
+ # 4. Generate visualizations
381
+ verdict = classify(seg_stats, seg_probs=chunk_probs, audio_path=audio_path)
382
+ verdict_html = VerdictCardBuilder.build(
383
+ verdict, seg_stats, is_stereo,
384
+ duration=info["duration"], elapsed=elapsed,
385
+ )
386
+
387
+ # Extract mel-spectrogram for edge case reporting
388
+ mel_spec = None
389
+ try:
390
+ mel_spec = _extract_mel_spectrogram(audio_np, mono_np)
391
+ except Exception as e:
392
+ print(f"[WARNING] Mel-spectrogram extraction failed: {e}")
393
+
394
+ # 4.5. Edge case collection (Uncertain cases only)
395
+ collected = False
396
+ source_type = "upload"
397
+ if verdict == "Uncertain" and UBUNTU_MINI_ENABLED:
398
+ try:
399
+ if mel_spec is not None:
400
+ source_type = "youtube" if "tmp" in audio_path and "yt" in audio_path else "upload"
401
+ collected = _send_uncertain_to_ubuntu_mini(
402
+ mel_spec, seg_stats, info["duration"], source_type
403
+ )
404
+ if collected:
405
+ # Add collection notice to verdict HTML
406
+ verdict_html += (
407
+ "<div style='background:#e8f5e9;padding:12px;border-radius:4px;margin-top:12px;'>"
408
+ f"<p style='color:#2e7d32;font-size:12px;margin:0;'>"
409
+ "✓ 이 분석 데이터(30초 미만의 스펙트로그램)는 모델 개선용으로 수집되었습니다.</p>"
410
+ "</div>"
411
+ )
412
+ except Exception as e:
413
+ print(f"[WARNING] Edge case collection failed: {e}")
414
+
415
+ spec_fig = plot_spectrograms(mono_np)
416
+ timeline_fig = plot_timeline(chunk_probs)
417
+
418
+ # 5. Save result JSON
419
+ filename = os.path.basename(audio_path) if audio_path else "unknown"
420
+ result_json = {
421
+ "filename": filename,
422
+ "verdict": verdict,
423
+ "duration_sec": round(info["duration"], 2),
424
+ "is_stereo": is_stereo,
425
+ "elapsed_sec": round(elapsed, 2),
426
+ "segment_stats": {k: round(v, 4) if isinstance(v, float) else v
427
+ for k, v in seg_stats.items()},
428
+ "segment_probs": [round(p, 4) for p in chunk_probs],
429
+ }
430
+ json_path = os.path.join(tempfile.gettempdir(), "artifactnet_result.json")
431
+ with open(json_path, "w") as f:
432
+ json.dump(result_json, f, indent=2)
433
+
434
+ return verdict_html, spec_fig, timeline_fig, json_path, verdict, seg_stats, mel_spec, info["duration"], audio_path
435
+
436
+
437
+
438
+ # ============================================================
439
+ # Gradio UI
440
+ # ============================================================
441
+
442
+ def build_ui():
443
+ """Build Gradio Blocks UI."""
444
+ theme = create_theme()
445
+
446
+ with gr.Blocks(theme=theme, title="ArtifactNet — AI Music Forensic Detector") as demo:
447
+ # Hidden state variables to track current analysis
448
+ current_verdict = gr.State(value=None)
449
+ current_stats = gr.State(value=None)
450
+ current_mel_spec = gr.State(value=None)
451
+ current_duration = gr.State(value=None)
452
+ current_audio_path = gr.State(value=None)
453
+
454
+ # Header
455
+ gr.HTML(create_header(IS_HF_SPACES))
456
+
457
+ # Row 1: Input + Verdict
458
+ with gr.Row():
459
+ with gr.Column(scale=1):
460
+ if IS_HF_SPACES and not YOUTUBE_PROXY_URL:
461
+ # HF Spaces without proxy: file upload only
462
+ audio_input = gr.Audio(
463
+ label="WAV / MP3 / FLAC (max 5 min)",
464
+ type="filepath",
465
+ sources=["upload"],
466
+ )
467
+ analyze_btn = gr.Button(
468
+ "Analyze", variant="primary", size="lg",
469
+ )
470
+ else:
471
+ # Local or HF Spaces with proxy: file upload + YouTube URL tabs
472
+ with gr.Tabs():
473
+ with gr.TabItem("Upload File"):
474
+ audio_input = gr.Audio(
475
+ label="WAV / MP3 / FLAC (max 5 min)",
476
+ type="filepath",
477
+ sources=["upload"],
478
+ )
479
+ analyze_btn = gr.Button(
480
+ "Analyze", variant="primary", size="lg",
481
+ )
482
+ with gr.TabItem("YouTube URL"):
483
+ yt_url_input = gr.Textbox(
484
+ label="YouTube URL",
485
+ placeholder="https://www.youtube.com/watch?v=...",
486
+ )
487
+ yt_analyze_btn = gr.Button(
488
+ "Download & Analyze", variant="primary", size="lg",
489
+ )
490
+ with gr.Column(scale=1):
491
+ verdict_output = gr.HTML(
492
+ value=VerdictCardBuilder.build_empty_card(),
493
+ label="Verdict",
494
+ )
495
+
496
+ # Row 2: Spectrograms
497
+ with gr.Row():
498
+ spec_output = gr.Plot(label="Spectral Analysis")
499
+
500
+ # Row 3: Timeline + JSON download
501
+ with gr.Row():
502
+ timeline_output = gr.Plot(label="P(AI) Timeline")
503
+ with gr.Row():
504
+ json_output = gr.File(label="Result JSON", visible=True)
505
+
506
+ # Row 4: Edge case reporting (if ubuntu-mini enabled)
507
+ if UBUNTU_MINI_ENABLED:
508
+ with gr.Row():
509
+ with gr.Column():
510
+ gr.Markdown("### 틀린 판정 보고하기")
511
+ report_error_type = gr.Radio(
512
+ choices=["맞음", "AI인데 Human이라고 함", "Human인데 AI라고 함"],
513
+ value="맞음",
514
+ label="판정 결과가...",
515
+ info="잘못된 판정 결과를 보고해주세요. (데이터 수집용)"
516
+ )
517
+ report_comment = gr.Textbox(
518
+ label="추가 의견 (선택사항)",
519
+ placeholder="예: 매우 압축된 음악입니다. / 너무 짧은 샘플입니다.",
520
+ lines=2
521
+ )
522
+ report_btn = gr.Button("보고하기", variant="secondary", size="md")
523
+ report_status = gr.Textbox(
524
+ label="상태",
525
+ interactive=False,
526
+ visible=False
527
+ )
528
+
529
+ with gr.Accordion("About ArtifactNet", open=False):
530
+ gr.HTML(create_about_section())
531
+
532
+ # Event handler for edge case reporting
533
+ def report_edge_case_fn(error_type, comment, verdict, stats, mel_spec, duration):
534
+ """Submit edge case report."""
535
+ if not UBUNTU_MINI_ENABLED:
536
+ msg = "Edge case reporting is not enabled."
537
+ return gr.update(value=msg, visible=True)
538
+
539
+ if error_type == "맞음" or verdict is None:
540
+ msg = "판정 결과를 먼저 선택해주세요."
541
+ return gr.update(value=msg, visible=True)
542
+
543
+ if error_type not in ["AI인데 Human이라고 함", "Human인데 AI라고 함"]:
544
+ msg = "오류: 유효하지 않은 선택입니다."
545
+ return gr.update(value=msg, visible=True)
546
+
547
+ # Convert error_type to verdict
548
+ true_verdict = "AI Generated" if error_type == "AI인데 Human이라고 함" else "Human-Made"
549
+
550
+ try:
551
+ success = _send_edge_case_report(
552
+ verdict=verdict,
553
+ reported_verdict=true_verdict,
554
+ mel_spec=mel_spec,
555
+ verdict_stats=stats,
556
+ duration_sec=duration,
557
+ user_comment=comment
558
+ )
559
+ if success:
560
+ msg = "✓ 보고가 접수되었습니다. 감사합니다!"
561
+ else:
562
+ msg = "△ 데이터 수집 서버에 연결할 수 없습니다. 나중에 다시 시도해주세요."
563
+ return gr.update(value=msg, visible=True)
564
+ except Exception as e:
565
+ msg = f"△ 오류 발생: {str(e)[:100]}"
566
+ return gr.update(value=msg, visible=True)
567
+
568
+ # Events
569
+ outputs = [verdict_output, spec_output, timeline_output, json_output, current_verdict, current_stats, current_mel_spec, current_duration, current_audio_path]
570
+
571
+ analyze_btn.click(
572
+ fn=analyze_audio,
573
+ inputs=[audio_input],
574
+ outputs=outputs,
575
+ api_name=False,
576
+ )
577
+ if not IS_HF_SPACES or YOUTUBE_PROXY_URL:
578
+ yt_analyze_btn.click(
579
+ fn=analyze_youtube,
580
+ inputs=[yt_url_input],
581
+ outputs=outputs,
582
+ api_name=False,
583
+ )
584
+
585
+ # Report button event
586
+ if UBUNTU_MINI_ENABLED:
587
+ report_btn.click(
588
+ fn=report_edge_case_fn,
589
+ inputs=[report_error_type, report_comment, current_verdict, current_stats, current_mel_spec, current_duration],
590
+ outputs=[report_status],
591
+ api_name=False,
592
+ )
593
+
594
+ return demo
595
+
596
+
597
+ # ============================================================
598
+ # Entry point — module-level demo object (required for HF Spaces)
599
+ # ============================================================
600
+
601
+ print("Loading model...", flush=True)
602
+ get_model()
603
+ print("Model ready.", flush=True)
604
+
605
+ demo = build_ui()
606
+
607
+ if __name__ == "__main__":
608
+ launch_kwargs = dict(server_name="0.0.0.0", server_port=7860)
609
+ if IS_HF_SPACES:
610
+ launch_kwargs["root_path"] = "/ArtifactNet"
611
+ demo.launch(**launch_kwargs)
config.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Purpose: ArtifactNet HF Spaces demo — constants and configuration
2
+
3
+ """Global constants and HF Hub model paths."""
4
+
5
+ from core import get_params
6
+
7
+ # ============================================================
8
+ # HF Hub model paths
9
+ # ============================================================
10
+ HF_MODEL_REPO = "intrect/artifactnet-models"
11
+ UNET_ONNX_FILENAME = "artifactnet_e2e.onnx"
12
+
13
+ # ============================================================
14
+ # Audio constants (proprietary parameters from core module)
15
+ # ============================================================
16
+ SR = get_params('sr')
17
+ MAX_DURATION_SEC = get_params('max_dur')
18
+ CHUNK_SEC = get_params('chunk_sec')
19
+ CHUNK_SAMPLES = int(CHUNK_SEC * SR)
20
+
21
+ # ============================================================
22
+ # E2E model constants (proprietary parameters)
23
+ # ============================================================
24
+ N_FFT = get_params('n_fft')
25
+ HOP_LENGTH = get_params('hop')
26
+
27
+ # ============================================================
28
+ # E2E inference batch size
29
+ # ============================================================
30
+ E2E_BATCH_SIZE = get_params('batch')
core/__init__.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ # Proprietary core algorithms (IP protected)
2
+
3
+ """Core algorithms for ArtifactNet — CONFIDENTIAL."""
4
+
5
+ from .proprietary import compute_stats, classify, get_params
6
+
7
+ __all__ = ['compute_stats', 'classify', 'get_params']
core/__pycache__/proprietary.cpython-312.pyc ADDED
Binary file (11.4 kB). View file
 
core/codec_aware.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Codec-aware classification module."""
2
+
3
+ from pathlib import Path
4
+ import numpy as np
5
+
6
+
7
+ def detect_codec(audio_path):
8
+ """Detect if audio is lossless or lossy."""
9
+ if audio_path is None:
10
+ return 'unknown'
11
+
12
+ if isinstance(audio_path, str):
13
+ audio_path = Path(audio_path)
14
+
15
+ ext = audio_path.suffix.lower()
16
+
17
+ if ext in {'.wav', '.flac', '.aiff', '.aif'}:
18
+ return 'lossless'
19
+ elif ext in {'.mp3', '.aac', '.m4a', '.ogg', '.opus', '.wma'}:
20
+ return 'lossy'
21
+ else:
22
+ return 'unknown'
23
+
24
+
25
+ def get_codec_thresholds(codec_mode):
26
+ """Get thresholds based on codec mode."""
27
+ if codec_mode == 'lossless':
28
+ return {'ai': 0.5, 'real': 0.5, 'name': 'Lossless (High Sensitivity)'}
29
+ elif codec_mode == 'lossy':
30
+ return {'ai': 0.8, 'real': 0.3, 'name': 'Lossy (Conservative)'}
31
+ else:
32
+ return {'ai': 0.7, 'real': 0.4, 'name': 'Unknown (Moderate)'}
core/proprietary.py ADDED
@@ -0,0 +1,322 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CONFIDENTIAL - ArtifactNet Proprietary Algorithms
2
+ # Copyright (c) 2026. All rights reserved.
3
+ # Trade secrets and proprietary algorithms.
4
+ # Reverse engineering, decompilation, or disclosure is strictly prohibited.
5
+
6
+ """Proprietary core algorithms — IP protected with runtime decryption."""
7
+
8
+ import base64
9
+ import json
10
+ import os
11
+ from pathlib import Path
12
+ import numpy as np
13
+ from scipy import stats as sp_stats
14
+
15
+ # Encrypted parameters (XOR + Base64) - DO NOT MODIFY
16
+ _ENC_P = 'AR3tX367a8ZODq4dcKFpkRJK8EYD8i6RWAW+GXKxZ9JYUcFLOvVpyFoNrhlkrWvQElDuD2ahfsNIE74PMt4mlxZMvBd8sHnKVh+8Tz31KJpYBb4VcKFpnxtHwUkp82nIWgyuHSE='
17
+ _ENC_T = 'IQ+wFXChe9xIE74dcrZ+3loPsBxp3A=='
18
+
19
+ # Key fragments (obfuscated distribution)
20
+ _K1 = [122, 63]
21
+ _K2 = [158, 45, 92]
22
+ _K3 = [129, 75, 242]
23
+ _K = _K1 + _K2 + _K3
24
+
25
+ # Decryption cache (computed once)
26
+ _cache = {}
27
+
28
+ # LGBM model cache
29
+ _lgbm_model = None
30
+
31
+ # Obfuscated constants (decoys)
32
+ _MAGIC_A = 0x1F3D5A7B
33
+ _MAGIC_B = 0x9C8E2F41
34
+
35
+
36
+ def _d(s, k):
37
+ """Obfuscated decryption routine with anti-tampering."""
38
+ if s in _cache:
39
+ return _cache[s]
40
+
41
+ # Anti-tampering check (dummy operation)
42
+ if not _verify():
43
+ k = [x ^ 0xFF for x in k] # Corrupt key if tampered
44
+
45
+ try:
46
+ # Decode base64
47
+ b = base64.b64decode(s.encode('utf-8'))
48
+ r = bytearray()
49
+
50
+ # XOR decryption with key rotation
51
+ for i, x in enumerate(b):
52
+ # Obfuscated XOR (adds dummy operations)
53
+ decrypted_byte = x ^ k[i % len(k)]
54
+ # Dummy operation (no effect)
55
+ if i % 17 == 0:
56
+ decrypted_byte = (decrypted_byte ^ 0x00) & 0xFF
57
+ r.append(decrypted_byte)
58
+
59
+ # Parse JSON
60
+ v = json.loads(r.decode('utf-8'))
61
+ _cache[s] = v
62
+ return v
63
+ except Exception:
64
+ # Fallback to prevent crashes
65
+ return {} if isinstance(s, str) and len(s) > 50 else []
66
+
67
+
68
+ def get_params(key: str = None):
69
+ """Get proprietary parameters (encrypted at rest, decrypted at runtime)."""
70
+ p = _d(_ENC_P, _K)
71
+ if key:
72
+ return p.get(key)
73
+ return p.copy()
74
+
75
+
76
+ def compute_stats(chunk_probs: list[float]) -> dict:
77
+ """Proprietary distribution statistics computation.
78
+
79
+ Algorithm obfuscated with control flow complexity and encrypted thresholds.
80
+ """
81
+ arr = np.array(chunk_probs)
82
+ n = len(arr)
83
+
84
+ # Handle edge case: empty array (very short audio)
85
+ if n == 0:
86
+ return {
87
+ "n": 0,
88
+ "mean": 0.5,
89
+ "median": 0.5,
90
+ "q25": 0.5,
91
+ "q75": 0.5,
92
+ "iqr": 0.0,
93
+ "std": 0.0,
94
+ "pct_high": 0.0,
95
+ "pct_above_50": 0.0,
96
+ "pct_low": 0.0,
97
+ "n_high": 0,
98
+ "n_mid": 0,
99
+ "n_low": 0,
100
+ }
101
+
102
+ # Obfuscated percentile calculation
103
+ q = np.percentile(arr, [25, 50, 75])
104
+ q25, q50, q75 = q[0], q[1], q[2]
105
+
106
+ # Decrypt thresholds (runtime decryption)
107
+ t = _d(_ENC_T, _K)
108
+
109
+ # Obfuscated threshold comparisons with dummy operations
110
+ mask_h = _h1(arr, t[0])
111
+ mask_m = _h2(arr, 0.5, t[0])
112
+ mask_l = arr < 0.5
113
+ mask_low = arr < t[1]
114
+
115
+ # Dummy computation (no effect, increases complexity)
116
+ _dummy = _calibrate_threshold(0.5, offset=0.1) if n > 5 else 0.5
117
+
118
+ # Statistical aggregation (obfuscated)
119
+ return {
120
+ "n": n,
121
+ "mean": float(np.nan_to_num(np.mean(arr), nan=0.5)),
122
+ "median": float(np.nan_to_num(q50, nan=0.5)),
123
+ "q25": float(np.nan_to_num(q25, nan=0.5)),
124
+ "q75": float(np.nan_to_num(q75, nan=0.5)),
125
+ "iqr": float(np.nan_to_num(q75 - q25, nan=0.0)),
126
+ "std": float(np.nan_to_num(np.std(arr), nan=0.0)),
127
+ "pct_high": float(mask_h.sum() / n) if n > 0 else 0.0,
128
+ "pct_above_50": float((arr >= 0.5).sum() / n) if n > 0 else 0.0,
129
+ "pct_low": float(mask_low.sum() / n) if n > 0 else 0.0,
130
+ "n_high": int(mask_h.sum()),
131
+ "n_mid": int(mask_m.sum()),
132
+ "n_low": int(mask_l.sum()),
133
+ }
134
+
135
+
136
+ def _load_lgbm_model():
137
+ """Load LGBM verdict model (lazy loading)."""
138
+ global _lgbm_model
139
+ if _lgbm_model is not None:
140
+ return _lgbm_model
141
+
142
+ import lightgbm as lgb
143
+ from huggingface_hub import hf_hub_download
144
+
145
+ # Try local path first
146
+ local_model = Path(__file__).resolve().parent.parent / "models" / "lgbm_verdict.txt"
147
+
148
+ if local_model.exists():
149
+ model_path = str(local_model)
150
+ else:
151
+ # Download from HF Hub
152
+ model_path = hf_hub_download("intrect/artifactnet-models", "lgbm_verdict.txt")
153
+
154
+ _lgbm_model = lgb.Booster(model_file=model_path)
155
+ return _lgbm_model
156
+
157
+
158
+ def _extract_lgbm_features(seg_probs: list[float]) -> np.ndarray:
159
+ """Extract LGBM features from segment probabilities (v8 2nd-stage)."""
160
+ arr = np.array(seg_probs, dtype=np.float64)
161
+ n = len(arr)
162
+
163
+ if n == 0:
164
+ return None
165
+
166
+ # Distribution statistics
167
+ features = [
168
+ n, # n_segments
169
+ arr.mean(), # mean
170
+ arr.std(), # std
171
+ np.median(arr), # median
172
+ arr.min(), # min
173
+ arr.max(), # max
174
+ arr.max() - arr.min(), # range
175
+ np.percentile(arr, 10), # p10
176
+ np.percentile(arr, 25), # p25
177
+ np.percentile(arr, 75), # p75
178
+ np.percentile(arr, 90), # p90
179
+ (arr >= 0.3).mean(), # r_03
180
+ (arr >= 0.5).mean(), # r_05
181
+ (arr >= 0.7).mean(), # r_07
182
+ (arr >= 0.8).mean(), # r_08
183
+ (arr >= 0.9).mean(), # r_09
184
+ float(sp_stats.skew(arr)) if n >= 3 else 0.0, # skew
185
+ float(sp_stats.kurtosis(arr)) if n >= 3 else 0.0, # kurtosis
186
+ ]
187
+
188
+ # Temporal features
189
+ if n >= 2:
190
+ diffs = np.diff(arr)
191
+ features.append(diffs.std()) # temporal_std
192
+ features.append(np.abs(diffs).max()) # temporal_max_jump
193
+ else:
194
+ features.extend([0.0, 0.0])
195
+
196
+ return np.array(features, dtype=np.float32).reshape(1, -1)
197
+
198
+
199
+ def classify(stats: dict, seg_probs: list[float] = None, audio_path: str = None) -> str:
200
+ """LGBM 2nd-stage track-level verdict with codec-aware thresholds (v8.1).
201
+
202
+ Codec-aware dual mode:
203
+ - Lossless (WAV/FLAC): threshold=0.5 (high sensitivity)
204
+ - Lossy (MP3/YouTube): threshold=0.8 (conservative, returns Uncertain for edge cases)
205
+
206
+ Fallback to 3-Tier if LGBM fails.
207
+ """
208
+ # Detect codec mode
209
+ from .codec_aware import detect_codec, get_codec_thresholds
210
+ codec_mode = detect_codec(audio_path)
211
+ thresholds = get_codec_thresholds(codec_mode)
212
+
213
+ # 3-Tier quick check (strong signals, codec-independent)
214
+ if seg_probs is not None:
215
+ arr = np.array(seg_probs)
216
+ high_ratio = (arr >= 0.8).mean()
217
+ low_ratio = (arr < 0.5).mean()
218
+
219
+ if high_ratio >= 0.75:
220
+ return "AI Generated" # Strong AI signal
221
+ elif low_ratio >= 0.85:
222
+ return "Human-Made" # Strong Real signal
223
+
224
+ # Try LGBM for uncertain zone
225
+ if seg_probs is not None:
226
+ try:
227
+ model = _load_lgbm_model()
228
+ features = _extract_lgbm_features(seg_probs)
229
+
230
+ if features is not None:
231
+ pred_proba = model.predict(features)[0]
232
+
233
+ # Codec-aware thresholds
234
+ if codec_mode == 'lossless':
235
+ # High sensitivity (trained on WAV)
236
+ if pred_proba >= thresholds['ai']:
237
+ return "AI Generated"
238
+ else:
239
+ return "Human-Made"
240
+
241
+ elif codec_mode == 'lossy':
242
+ # Conservative (lossy artifacts can mimic AI)
243
+ if pred_proba >= thresholds['ai']:
244
+ return "AI Generated"
245
+ elif pred_proba <= thresholds['real']:
246
+ return "Human-Made"
247
+ else:
248
+ return "Uncertain" # 0.3~0.8 range
249
+
250
+ else:
251
+ # Unknown codec → moderate
252
+ if pred_proba >= thresholds['ai']:
253
+ return "AI Generated"
254
+ elif pred_proba <= thresholds['real']:
255
+ return "Human-Made"
256
+ else:
257
+ return "Uncertain"
258
+
259
+ except Exception as e:
260
+ # Fallback to 3-Tier on error
261
+ print(f"LGBM error (fallback to 3-Tier): {e}")
262
+ pass
263
+
264
+ # Fallback: 3-Tier rule (legacy)
265
+ t = _d(_ENC_T, _K)
266
+ ph = stats["pct_high"]
267
+ pa = stats["pct_above_50"]
268
+
269
+ if _verify() and (ph + pa) >= 0:
270
+ if ph >= t[2]:
271
+ return "AI Generated"
272
+ elif pa < t[3]:
273
+ return "Human-Made"
274
+ else:
275
+ return "Uncertain"
276
+ else:
277
+ return "Error"
278
+
279
+
280
+ # Anti-tampering check (dummy function to increase complexity)
281
+ def _verify():
282
+ """Integrity verification (obfuscated)."""
283
+ # XOR checksum (122 ^ 242 = 136)
284
+ return len(_K) == 8 and _K[0] ^ _K[-1] == 136
285
+
286
+
287
+ # Dummy decoy functions (increase reverse engineering cost)
288
+ def _calibrate_threshold(x, offset=0.0):
289
+ """Decoy function - not used in actual algorithm."""
290
+ return x + offset * 0.01
291
+
292
+
293
+ def _normalize_distribution(arr):
294
+ """Decoy function - not used in actual algorithm."""
295
+ return (arr - arr.min()) / (arr.max() - arr.min() + 1e-10)
296
+
297
+
298
+ def _apply_smoothing(probs, window=3):
299
+ """Decoy function - not used in actual algorithm."""
300
+ if len(probs) < window:
301
+ return probs
302
+ return [sum(probs[max(0, i - window // 2):i + window // 2 + 1]) / window
303
+ for i in range(len(probs))]
304
+
305
+
306
+ # Obfuscated helpers (used internally)
307
+ def _h1(v, t):
308
+ """Helper 1 (obfuscated name) - threshold comparison."""
309
+ return v >= t
310
+
311
+
312
+ def _h2(v, lo, hi):
313
+ """Helper 2 (obfuscated name) - range check."""
314
+ return (v >= lo) & (v < hi)
315
+
316
+
317
+ # Memory protection: clear key fragments on module unload (Python limitation)
318
+ def _cleanup():
319
+ """Clear sensitive data from memory (best effort)."""
320
+ global _K, _K1, _K2, _K3, _cache
321
+ _cache.clear()
322
+ # Note: Python doesn't guarantee memory erasure
docker-compose.youtube-proxy.yml ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: '3.9'
2
+
3
+ services:
4
+ youtube-proxy:
5
+ build:
6
+ context: .
7
+ dockerfile: Dockerfile.youtube-proxy
8
+ image: artifactnet-youtube-proxy:latest
9
+ container_name: artifactnet-youtube-proxy
10
+ restart: unless-stopped
11
+ environment:
12
+ - HOST=0.0.0.0
13
+ - PORT=8765
14
+ - LOG_LEVEL=INFO
15
+ - YOUTUBE_PROXY_API_KEY=${YOUTUBE_PROXY_API_KEY:-c60ba3dc9f26cfc700958983f82b997eac084743aad9f5be4db7bb625ae6dbbd}
16
+ ports:
17
+ - "0.0.0.0:8765:8765" # Accessible to cloudflared tunnel
18
+ healthcheck:
19
+ test: ["CMD", "python", "-c", "import requests; requests.get('http://localhost:8765/health')"]
20
+ interval: 30s
21
+ timeout: 10s
22
+ retries: 3
23
+ start_period: 5s
24
+ networks:
25
+ - default
26
+ security_opt:
27
+ - no-new-privileges:true
28
+ cap_drop:
29
+ - ALL
30
+ cap_add:
31
+ - NET_BIND_SERVICE
32
+
33
+ networks:
34
+ default:
35
+ name: artifactnet-network
36
+ driver: bridge
inference/__init__.py ADDED
File without changes
inference/audio_utils.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+ import numpy as np
3
+ import soundfile as sf
4
+ import torch
5
+ from scipy import signal
6
+ from config import SR, MAX_DURATION_SEC, CHUNK_SAMPLES
7
+
8
+
9
+ def load_audio(path: str) -> tuple[np.ndarray, bool]:
10
+ audio, sr = sf.read(str(path), dtype="float32", always_2d=True)
11
+ if sr != SR:
12
+ from scipy.signal import resample_poly
13
+ gcd = math.gcd(sr, SR)
14
+ up, down = SR // gcd, sr // gcd
15
+ if up > 100 or down > 100:
16
+ n_out = int(len(audio) * SR / sr)
17
+ audio = signal.resample(audio, n_out)
18
+ else:
19
+ audio = resample_poly(audio, up, down, axis=0)
20
+ max_samples = MAX_DURATION_SEC * SR
21
+ if len(audio) > max_samples:
22
+ audio = audio[:max_samples]
23
+ is_stereo = audio.shape[1] >= 2
24
+ return audio.astype(np.float32), is_stereo
25
+
26
+
27
+ def load_audio_mono_tensor(path: str) -> tuple[torch.Tensor, np.ndarray, bool]:
28
+ audio, is_stereo = load_audio(path)
29
+ if is_stereo:
30
+ mono = (audio[:, 0] + audio[:, 1]) / 2.0
31
+ else:
32
+ mono = audio[:, 0]
33
+ mono_tensor = torch.from_numpy(mono)
34
+ return mono_tensor, audio, is_stereo
35
+
36
+
37
+ def chunk_waveform(wav: torch.Tensor, chunk_size: int = CHUNK_SAMPLES) -> list[torch.Tensor]:
38
+ chunks = []
39
+ for start in range(0, len(wav), chunk_size):
40
+ c = wav[start:start + chunk_size]
41
+ if c.shape[0] < chunk_size:
42
+ c = torch.nn.functional.pad(c, (0, chunk_size - c.shape[0]))
43
+ chunks.append(c)
44
+ return chunks
45
+
46
+
47
+ def get_audio_info(audio: np.ndarray, is_stereo: bool) -> dict:
48
+ duration = len(audio) / SR
49
+ return {
50
+ "duration": duration,
51
+ "sr": SR,
52
+ "channels": "Stereo" if is_stereo else "Mono",
53
+ "samples": len(audio),
54
+ }
inference/e2e_model.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ import numpy as np
3
+ import torch
4
+ from huggingface_hub import hf_hub_download
5
+ from config import (
6
+ HF_MODEL_REPO, UNET_ONNX_FILENAME,
7
+ SR, N_FFT, HOP_LENGTH, CHUNK_SAMPLES, E2E_BATCH_SIZE,
8
+ )
9
+ from inference.audio_utils import chunk_waveform
10
+
11
+ _onnx_session = None
12
+ _stft_window = None
13
+
14
+
15
+ def get_model():
16
+ global _onnx_session, _stft_window
17
+ if _onnx_session is not None:
18
+ return _onnx_session
19
+ import onnxruntime as ort
20
+ local_onnx = (Path(__file__).resolve().parent.parent
21
+ / "models" / UNET_ONNX_FILENAME)
22
+ if local_onnx.exists():
23
+ onnx_path = str(local_onnx)
24
+ else:
25
+ onnx_path = hf_hub_download(HF_MODEL_REPO, UNET_ONNX_FILENAME)
26
+ available = ort.get_available_providers()
27
+ providers = [p for p in ['CUDAExecutionProvider', 'CPUExecutionProvider']
28
+ if p in available]
29
+ _onnx_session = ort.InferenceSession(onnx_path, providers=providers)
30
+ _stft_window = torch.hann_window(N_FFT)
31
+ print(f" ONNX loaded: {onnx_path} ({providers[0]})")
32
+ return _onnx_session
33
+
34
+
35
+ def run_e2e_inference(wav_mono_tensor: torch.Tensor) -> tuple[list[float], torch.Tensor]:
36
+ session = get_model()
37
+ chunks = chunk_waveform(wav_mono_tensor, CHUNK_SAMPLES)
38
+ probs = []
39
+ for i in range(0, len(chunks), E2E_BATCH_SIZE):
40
+ batch = torch.stack(chunks[i:i + E2E_BATCH_SIZE])
41
+ stft = torch.stft(batch, N_FFT, HOP_LENGTH,
42
+ window=_stft_window, return_complex=True)
43
+ stft_mag = stft.abs().unsqueeze(1).numpy()
44
+ for j in range(stft_mag.shape[0]):
45
+ logit = session.run(None, {"stft_mag": stft_mag[j:j + 1]})[0]
46
+ prob = float(1.0 / (1.0 + np.exp(-logit[0])))
47
+ probs.append(prob)
48
+ residual_placeholder = torch.zeros_like(wav_mono_tensor)
49
+ return probs, residual_placeholder
models ADDED
@@ -0,0 +1 @@
 
 
1
+ ../ArtifactNet/models
packages.txt ADDED
@@ -0,0 +1 @@
 
 
1
+
requirements.txt ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ soundfile>=0.12.0
2
+ scipy>=1.11.0
3
+ numpy>=1.24.0
4
+ matplotlib>=3.8.0
5
+ plotly>=5.18.0
6
+ huggingface_hub>=0.20.0
7
+ onnxruntime>=1.17.0
8
+ torch>=2.0.0
9
+ requests>=2.31.0
10
+ lightgbm>=4.0.0
11
+ gradio>=5.20.0
12
+ fastapi>=0.104.0
13
+ uvicorn>=0.24.0
14
+ pydantic>=2.0.0
15
+ yt-dlp>=2024.01.01
ui/__init__.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Purpose: UI components for ArtifactNet Gradio demo
2
+
3
+ """UI components and verdict card generation."""
4
+
5
+ from .verdict_card import VerdictCardBuilder, VerdictColors
6
+ from .components import create_theme, create_header, create_about_section
7
+
8
+ __all__ = [
9
+ 'VerdictCardBuilder',
10
+ 'VerdictColors',
11
+ 'create_theme',
12
+ 'create_header',
13
+ 'create_about_section',
14
+ ]
ui/components.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Created: 2026-02-24
2
+ # Purpose: Gradio UI components (theme, header, about section)
3
+ # Dependencies: gradio
4
+
5
+ """Gradio UI components for ArtifactNet demo."""
6
+
7
+ import gradio as gr
8
+
9
+
10
+ def create_theme() -> gr.themes.Base:
11
+ """Create ArtifactNet Gradio theme (dark mode with orange accent)."""
12
+ return gr.themes.Base(
13
+ primary_hue="orange",
14
+ secondary_hue="blue",
15
+ neutral_hue="slate",
16
+ font=gr.themes.GoogleFont("Inter"),
17
+ ).set(
18
+ body_background_fill="#0f0f23",
19
+ block_background_fill="#1a1a2e",
20
+ block_border_color="#333",
21
+ input_background_fill="#16213e",
22
+ button_primary_background_fill="#ffa502",
23
+ button_primary_text_color="black",
24
+ )
25
+
26
+
27
+ def create_header(is_hf_spaces: bool) -> str:
28
+ """Create header HTML for Gradio UI.
29
+
30
+ Args:
31
+ is_hf_spaces: Whether running on HF Spaces (shows CPU warning)
32
+
33
+ Returns:
34
+ HTML string
35
+ """
36
+ cpu_warning = ""
37
+ if is_hf_spaces:
38
+ cpu_warning = (
39
+ '<div style="margin:8px auto;max-width:500px;padding:6px 12px;'
40
+ 'background:rgba(255,165,2,0.12);border:1px solid #ffa502;'
41
+ 'border-radius:8px;font-size:12px;color:#ffa502;">'
42
+ 'Running on CPU — analysis may take 30-60 seconds depending on track length.'
43
+ '</div>'
44
+ )
45
+
46
+ return f"""
47
+ <div style="text-align:center;padding:20px 0 10px;">
48
+ <h1 style="color:white;font-size:28px;margin:0;">
49
+ ArtifactNet
50
+ </h1>
51
+ <p style="color:#888;font-size:14px;margin:4px 0 0;">
52
+ AI Music Forensic Detector — Deep Spectral Analysis + Neural Network
53
+ </p>
54
+ {cpu_warning}
55
+ </div>
56
+ """
57
+
58
+
59
+ def create_about_section() -> str:
60
+ """Create About ArtifactNet accordion content HTML."""
61
+ return """
62
+ <div style="color:#ccc;font-size:13px;line-height:1.6;padding:10px;">
63
+ <h3 style="color:white;">Overview</h3>
64
+ <p>
65
+ ArtifactNet is a neural network-based forensic detector for
66
+ AI-generated music. It analyzes audio characteristics to distinguish
67
+ between human-produced and AI-generated tracks.
68
+ </p>
69
+
70
+ <h3 style="color:white;">Verdict Categories</h3>
71
+ <table style="width:100%;border-collapse:collapse;margin:8px 0;">
72
+ <tr style="border-bottom:1px solid #333;">
73
+ <td style="padding:6px;color:#ff4757;font-weight:bold;">AI Generated</td>
74
+ <td style="padding:6px;">Strong AI generation indicators detected.</td>
75
+ </tr>
76
+ <tr style="border-bottom:1px solid #333;">
77
+ <td style="padding:6px;color:#ffa502;font-weight:bold;">Uncertain</td>
78
+ <td style="padding:6px;">
79
+ <strong>Most common cause:</strong> Heavily processed audio (compression, EQ, effects).<br>
80
+ Other cases: Non-music audio, mixed human/AI content, edge cases in training data.<br>
81
+ <em>Tip: Try with original/minimally processed audio for better accuracy.</em>
82
+ </td>
83
+ </tr>
84
+ <tr>
85
+ <td style="padding:6px;color:#2ed573;font-weight:bold;">Human-Made</td>
86
+ <td style="padding:6px;">No significant AI generation indicators found.</td>
87
+ </tr>
88
+ </table>
89
+
90
+ <h3 style="color:white;">Limitations</h3>
91
+ <ul>
92
+ <li>Mono input reduces accuracy</li>
93
+ <li>Heavily processed audio may fall in the Uncertain zone</li>
94
+ <li>Novel AI generators not in training data may be missed</li>
95
+ <li>Short clips (&lt;10s) have lower confidence</li>
96
+ </ul>
97
+
98
+ <h3 style="color:white;">📊 Data Collection (Edge Case Detection)</h3>
99
+ <p style="background:rgba(46,213,115,0.1);padding:8px;border-radius:4px;border-left:3px solid #2ed573;color:#ccc;font-size:12px;line-height:1.5;">
100
+ <strong style="color:#2ed573;">What's collected:</strong> When results are "Uncertain",
101
+ analysis data (mel-spectrogram only) from tracks <strong>&lt;30 seconds</strong>
102
+ is securely saved for model improvement.<br><br>
103
+ <strong style="color:#2ed573;">What's NOT collected:</strong> Your original audio files are never stored.
104
+ Only aggregated spectral patterns and verdict statistics are saved.<br><br>
105
+ <strong style="color:#2ed573;">Why:</strong> These edge cases help improve model accuracy and robustness.
106
+ </p>
107
+
108
+ <p style="color:#888;font-size:11px;margin-top:10px;">
109
+ Research project — results should be interpreted alongside other evidence.
110
+ </p>
111
+ </div>
112
+ """
ui/verdict_card.py ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Created: 2026-02-24
2
+ # Purpose: Verdict card HTML generation (extracted from app.py)
3
+ # Dependencies: None (pure HTML generation)
4
+
5
+ """Verdict card HTML builder for ArtifactNet results."""
6
+
7
+ import math
8
+ from dataclasses import dataclass
9
+
10
+
11
+ def _safe_fmt(val: float) -> float:
12
+ """Convert NaN to 0.5 for safe formatting."""
13
+ if math.isnan(val):
14
+ return 0.5
15
+ return val
16
+
17
+
18
+ @dataclass
19
+ class VerdictColors:
20
+ """Color constants for verdict categories."""
21
+ AI_GENERATED = "#ff4757"
22
+ UNCERTAIN = "#ffa502"
23
+ HUMAN_MADE = "#2ed573"
24
+
25
+ BACKGROUND = "#16213e"
26
+ BORDER = "#333"
27
+
28
+
29
+ class VerdictCardBuilder:
30
+ """Build HTML verdict cards for ArtifactNet analysis results."""
31
+
32
+ @staticmethod
33
+ def build_empty_card() -> str:
34
+ """Generate placeholder card for empty state."""
35
+ return """
36
+ <div style="text-align:center;padding:30px;background:#16213e;
37
+ border-radius:12px;color:#888;">
38
+ <p style="font-size:16px;">Upload an audio file to begin analysis</p>
39
+ </div>"""
40
+
41
+ @staticmethod
42
+ def build(verdict: str, stats: dict, is_stereo: bool,
43
+ duration: float = 0, elapsed: float = 0) -> str:
44
+ """Generate verdict card HTML.
45
+
46
+ Args:
47
+ verdict: "AI Generated", "Uncertain", or "Human-Made"
48
+ stats: Distribution statistics dict
49
+ is_stereo: Whether input was stereo
50
+ duration: Audio duration in seconds
51
+ elapsed: Analysis elapsed time in seconds
52
+
53
+ Returns:
54
+ HTML string for verdict card
55
+ """
56
+ if verdict == "No file":
57
+ return VerdictCardBuilder.build_empty_card()
58
+
59
+ color, icon, desc = VerdictCardBuilder._get_verdict_style(verdict, stats)
60
+ channels = "Stereo" if is_stereo else "Mono"
61
+
62
+ # Distribution bar
63
+ dist_bar = VerdictCardBuilder._build_distribution_bar(stats)
64
+
65
+ # Warnings and context
66
+ mono_warn = VerdictCardBuilder._build_mono_warning(is_stereo)
67
+ context = VerdictCardBuilder._build_context(verdict, stats)
68
+
69
+ return f"""
70
+ <div style="text-align:center;padding:20px;background:#16213e;
71
+ border-radius:12px;border:2px solid {color};">
72
+ <div style="font-size:14px;color:{color};letter-spacing:1px;
73
+ text-transform:uppercase;font-weight:600;">
74
+ {icon} Verdict
75
+ </div>
76
+ <div style="font-size:32px;font-weight:bold;color:{color};
77
+ letter-spacing:2px;margin:6px 0;">{verdict.upper()}</div>
78
+ <div style="color:#aaa;font-size:13px;margin-bottom:10px;">{desc}</div>
79
+ <div style="font-size:36px;font-weight:bold;color:white;margin:4px 0;">
80
+ median={_safe_fmt(stats['median']):.1%} &nbsp;
81
+ <span style="font-size:18px;color:#888;">mean={_safe_fmt(stats['mean']):.1%}</span>
82
+ </div>
83
+ {dist_bar}
84
+ <div style="color:#999;font-size:13px;margin-top:10px;">
85
+ {stats['n']} segments &nbsp;|&nbsp;
86
+ IQR={stats['iqr']:.2f} &nbsp;|&nbsp;
87
+ {channels} &nbsp;|&nbsp;
88
+ {duration:.1f}s &nbsp;|&nbsp;
89
+ {elapsed:.1f}s
90
+ </div>
91
+ {mono_warn}
92
+ {context}
93
+ </div>"""
94
+
95
+ @staticmethod
96
+ def _get_verdict_style(verdict: str, stats: dict) -> tuple[str, str, str]:
97
+ """Get color, icon, and description for verdict.
98
+
99
+ Returns:
100
+ (color, icon, description)
101
+ """
102
+ pct_high = stats["pct_high"]
103
+
104
+ if verdict == "AI Generated":
105
+ return (
106
+ VerdictColors.AI_GENERATED,
107
+ "&#9888;", # warning icon
108
+ f"{pct_high:.0%} of segments show strong AI indicators (consistent pattern)"
109
+ )
110
+ elif verdict == "Uncertain":
111
+ return (
112
+ VerdictColors.UNCERTAIN,
113
+ "&#9679;", # circle icon
114
+ "Mixed signals across segments — inconsistent pattern"
115
+ )
116
+ else: # Human-Made
117
+ return (
118
+ VerdictColors.HUMAN_MADE,
119
+ "&#10003;", # check icon
120
+ "No significant AI generation indicators found"
121
+ )
122
+
123
+ @staticmethod
124
+ def _build_distribution_bar(stats: dict) -> str:
125
+ """Build 3-color distribution bar HTML."""
126
+ n_total = stats["n"]
127
+ n_high, n_mid, n_low = stats["n_high"], stats["n_mid"], stats["n_low"]
128
+ pct_h = n_high / n_total * 100
129
+ pct_m = n_mid / n_total * 100
130
+ pct_l = n_low / n_total * 100
131
+
132
+ return f"""
133
+ <div style="margin:10px auto;max-width:320px;">
134
+ <div style="height:14px;background:#333;border-radius:7px;
135
+ overflow:hidden;display:flex;">
136
+ <div style="width:{pct_h:.1f}%;background:{VerdictColors.AI_GENERATED};"></div>
137
+ <div style="width:{pct_m:.1f}%;background:{VerdictColors.UNCERTAIN};"></div>
138
+ <div style="width:{pct_l:.1f}%;background:{VerdictColors.HUMAN_MADE};"></div>
139
+ </div>
140
+ <div style="display:flex;justify-content:space-between;
141
+ font-size:10px;color:#888;margin-top:2px;">
142
+ <span style="color:{VerdictColors.AI_GENERATED};">{n_high} high</span>
143
+ <span style="color:{VerdictColors.UNCERTAIN};">{n_mid} mid</span>
144
+ <span style="color:{VerdictColors.HUMAN_MADE};">{n_low} low</span>
145
+ </div>
146
+ </div>"""
147
+
148
+ @staticmethod
149
+ def _build_mono_warning(is_stereo: bool) -> str:
150
+ """Build mono input warning HTML."""
151
+ if is_stereo:
152
+ return ""
153
+
154
+ return """
155
+ <div style="margin-top:8px;padding:6px 10px;background:rgba(255,165,2,0.15);
156
+ border-radius:6px;border-left:3px solid #ffa502;font-size:12px;">
157
+ Mono input — stereo phase features unavailable. Results may be less reliable.
158
+ </div>"""
159
+
160
+ @staticmethod
161
+ def _build_context(verdict: str, stats: dict) -> str:
162
+ """Build human comparison context HTML."""
163
+ if verdict == "AI Generated":
164
+ return """
165
+ <div style="margin-top:10px;padding:8px 12px;background:rgba(255,71,87,0.1);
166
+ border-radius:6px;font-size:12px;color:#ccc;line-height:1.5;">
167
+ <b style="color:#ff4757;">Context:</b>
168
+ In blind listening tests, trained listeners correctly identified AI music
169
+ only 72.9% of the time (N=90). This track shows patterns that exceed
170
+ human detection ability.
171
+ </div>"""
172
+ elif verdict == "Uncertain":
173
+ iqr = stats['iqr']
174
+ return f"""
175
+ <div style="margin-top:10px;padding:8px 12px;background:rgba(255,165,2,0.1);
176
+ border-radius:6px;font-size:12px;color:#ccc;line-height:1.5;">
177
+ <b style="color:#ffa502;">Why uncertain:</b>
178
+ Segment distribution is inconsistent (IQR={iqr:.2f}).
179
+ Some sections show AI patterns while others appear human-made.
180
+ This may indicate partial AI use, heavy processing, or novel audio characteristics.
181
+ </div>"""
182
+ else: # Human-Made
183
+ return """
184
+ <div style="margin-top:10px;padding:8px 12px;background:rgba(46,213,115,0.1);
185
+ border-radius:6px;font-size:12px;color:#ccc;line-height:1.5;">
186
+ <b style="color:#2ed573;">Context:</b>
187
+ This track's spectral and temporal characteristics are consistent with
188
+ human-produced music. Average human accuracy in blind tests: 69.3% (N=90).
189
+ </div>"""
visualization/__init__.py ADDED
File without changes
visualization/spectrogram.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Created: 2026-02-18
2
+ # Purpose: Original/residual mel-spectrogram visualization (matplotlib)
3
+ # Dependencies: matplotlib, numpy, torch
4
+
5
+ """Mel-spectrogram comparison visualization of original audio and analysis results."""
6
+
7
+ import numpy as np
8
+ import matplotlib
9
+ matplotlib.use('Agg')
10
+ import matplotlib.pyplot as plt
11
+
12
+ from config import SR, N_FFT, HOP_LENGTH
13
+ from core import get_params
14
+
15
+ N_MELS = get_params('n_mels')
16
+
17
+
18
+ def _compute_mel_spectrogram(audio_1d: np.ndarray) -> np.ndarray:
19
+ """1D audio -> mel spectrogram (dB scale)."""
20
+ from scipy import signal as sig
21
+
22
+ # STFT
23
+ _, _, Zxx = sig.stft(audio_1d, fs=SR, window='hann',
24
+ nperseg=N_FFT, noverlap=N_FFT - HOP_LENGTH)
25
+ mag = np.abs(Zxx)
26
+
27
+ # Mel filterbank
28
+ n_freqs = N_FFT // 2 + 1
29
+ def hz_to_mel(f): return 2595.0 * np.log10(1.0 + f / 700.0)
30
+ def mel_to_hz(m): return 700.0 * (10.0 ** (m / 2595.0) - 1.0)
31
+
32
+ mel_pts = np.linspace(hz_to_mel(0), hz_to_mel(SR / 2), N_MELS + 2)
33
+ hz_pts = mel_to_hz(mel_pts)
34
+ freqs = np.linspace(0, SR / 2, n_freqs)
35
+
36
+ fb = np.zeros((n_freqs, N_MELS), dtype=np.float32)
37
+ for i in range(N_MELS):
38
+ lo, mid, hi = hz_pts[i], hz_pts[i + 1], hz_pts[i + 2]
39
+ for j in range(n_freqs):
40
+ if lo <= freqs[j] <= mid and (mid - lo) > 0:
41
+ fb[j, i] = (freqs[j] - lo) / (mid - lo)
42
+ elif mid < freqs[j] <= hi and (hi - mid) > 0:
43
+ fb[j, i] = (hi - freqs[j]) / (hi - mid)
44
+
45
+ mel = fb.T @ (mag ** 2)
46
+ mel_db = 10.0 * np.log10(np.maximum(mel, 1e-10))
47
+ max_val = np.max(mel_db)
48
+ mel_db = np.maximum(mel_db, max_val - 80.0)
49
+
50
+ return mel_db
51
+
52
+
53
+ def plot_spectrograms(original_mono: np.ndarray,
54
+ residual_mono: np.ndarray = None) -> plt.Figure:
55
+ """Return mel-spectrogram figure (1-panel or 2-panel).
56
+
57
+ Args:
58
+ original_mono: 1D numpy array (mono original)
59
+ residual_mono: 1D numpy array (Demucs residual), optional
60
+
61
+ Returns:
62
+ matplotlib Figure
63
+ """
64
+ max_samples = 30 * SR
65
+ orig = original_mono[:max_samples]
66
+ mel_orig = _compute_mel_spectrogram(orig)
67
+
68
+ if residual_mono is not None:
69
+ # 2-panel: Original vs Residual
70
+ res = residual_mono[:min(len(residual_mono), max_samples)]
71
+ mel_res = _compute_mel_spectrogram(res)
72
+
73
+ fig, axes = plt.subplots(1, 2, figsize=(14, 4), constrained_layout=True)
74
+ t_orig = np.linspace(0, len(orig) / SR, mel_orig.shape[1])
75
+ t_res = np.linspace(0, len(res) / SR, mel_res.shape[1])
76
+
77
+ im0 = axes[0].imshow(mel_orig, aspect='auto', origin='lower',
78
+ extent=[0, t_orig[-1], 0, SR / 2000],
79
+ cmap='magma', interpolation='bilinear')
80
+ axes[0].set_title('Original', fontsize=12, fontweight='bold')
81
+ axes[0].set_xlabel('Time (s)')
82
+ axes[0].set_ylabel('Frequency (kHz)')
83
+ axes[0].set_ylim(0, 16)
84
+ plt.colorbar(im0, ax=axes[0], label='dB', fraction=0.046, pad=0.04)
85
+
86
+ im1 = axes[1].imshow(mel_res, aspect='auto', origin='lower',
87
+ extent=[0, t_res[-1], 0, SR / 2000],
88
+ cmap='magma', interpolation='bilinear')
89
+ axes[1].set_title('Demucs Residual', fontsize=12, fontweight='bold')
90
+ axes[1].set_xlabel('Time (s)')
91
+ axes[1].set_ylabel('Frequency (kHz)')
92
+ axes[1].set_ylim(0, 16)
93
+ plt.colorbar(im1, ax=axes[1], label='dB', fraction=0.046, pad=0.04)
94
+
95
+ fig.patch.set_facecolor('#1a1a2e')
96
+ for ax in axes:
97
+ ax.set_facecolor('#16213e')
98
+ ax.tick_params(colors='white')
99
+ ax.xaxis.label.set_color('white')
100
+ ax.yaxis.label.set_color('white')
101
+ ax.title.set_color('white')
102
+ else:
103
+ # 1-panel: Original only
104
+ fig, ax = plt.subplots(1, 1, figsize=(14, 4), constrained_layout=True)
105
+ t_orig = np.linspace(0, len(orig) / SR, mel_orig.shape[1])
106
+
107
+ im0 = ax.imshow(mel_orig, aspect='auto', origin='lower',
108
+ extent=[0, t_orig[-1], 0, SR / 2000],
109
+ cmap='magma', interpolation='bilinear')
110
+ ax.set_title('Mel Spectrogram', fontsize=12, fontweight='bold')
111
+ ax.set_xlabel('Time (s)')
112
+ ax.set_ylabel('Frequency (kHz)')
113
+ ax.set_ylim(0, 16)
114
+ plt.colorbar(im0, ax=ax, label='dB', fraction=0.046, pad=0.04)
115
+
116
+ fig.patch.set_facecolor('#1a1a2e')
117
+ ax.set_facecolor('#16213e')
118
+ ax.tick_params(colors='white')
119
+ ax.xaxis.label.set_color('white')
120
+ ax.yaxis.label.set_color('white')
121
+ ax.title.set_color('white')
122
+
123
+ return fig
visualization/timeline.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Created: 2026-02-18
2
+ # Purpose: P(AI) per-segment timeline bar chart (plotly)
3
+ # Dependencies: plotly
4
+
5
+ """Per-segment (chunk) AI probability timeline visualization."""
6
+
7
+ import plotly.graph_objects as go
8
+
9
+ from config import CHUNK_SEC
10
+
11
+
12
+ def plot_timeline(chunk_probs: list[float]) -> go.Figure:
13
+ """Per-chunk P(AI) timeline bar chart.
14
+
15
+ Args:
16
+ chunk_probs: P(AI) list for each 4-second chunk
17
+
18
+ Returns:
19
+ plotly Figure
20
+ """
21
+ n = len(chunk_probs)
22
+ times = [f"{i * CHUNK_SEC:.0f}-{(i + 1) * CHUNK_SEC:.0f}s" for i in range(n)]
23
+ colors = ['#ff4757' if p >= 0.5 else '#2ed573' for p in chunk_probs]
24
+
25
+ fig = go.Figure()
26
+
27
+ fig.add_trace(go.Bar(
28
+ x=list(range(n)),
29
+ y=chunk_probs,
30
+ marker_color=colors,
31
+ text=[f"{p:.2f}" for p in chunk_probs],
32
+ textposition='outside',
33
+ textfont=dict(size=10, color='white'),
34
+ hovertemplate="<b>%{customdata}</b><br>P(AI): %{y:.3f}<extra></extra>",
35
+ customdata=times,
36
+ ))
37
+
38
+ # Threshold line
39
+ fig.add_hline(y=0.5, line_dash="dash", line_color="#ffa502",
40
+ annotation_text="Threshold (0.5)",
41
+ annotation_position="top right",
42
+ annotation_font_color="#ffa502")
43
+
44
+ fig.update_layout(
45
+ title=dict(text="Segment-level AI Probability", font=dict(size=14)),
46
+ xaxis=dict(
47
+ title="Segment",
48
+ tickvals=list(range(n)),
49
+ ticktext=times,
50
+ tickangle=-45,
51
+ tickfont=dict(size=9),
52
+ ),
53
+ yaxis=dict(title="P(AI)", range=[0, 1.05]),
54
+ plot_bgcolor='#1a1a2e',
55
+ paper_bgcolor='#1a1a2e',
56
+ font=dict(color='white'),
57
+ margin=dict(l=50, r=20, t=40, b=60),
58
+ height=300,
59
+ showlegend=False,
60
+ )
61
+
62
+ return fig
youtube_proxy_server.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ YouTube Audio Proxy Server — yt-dlp wrapper with API
4
+
5
+ 환경변수:
6
+ - YOUTUBE_PROXY_API_KEY: 인증 토큰 (Bearer token)
7
+ - LOG_LEVEL: DEBUG/INFO/WARNING (기본값: INFO)
8
+ """
9
+
10
+ import os
11
+ import sys
12
+ import json
13
+ import logging
14
+ import tempfile
15
+ import subprocess
16
+ from typing import Optional
17
+
18
+ from fastapi import FastAPI, HTTPException, Header
19
+ from fastapi.responses import FileResponse, JSONResponse
20
+ from pydantic import BaseModel
21
+
22
+ # ============================================================
23
+ # Config
24
+ # ============================================================
25
+
26
+ API_KEY = os.environ.get("YOUTUBE_PROXY_API_KEY", "default-key")
27
+ LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO")
28
+
29
+ logging.basicConfig(
30
+ level=getattr(logging, LOG_LEVEL),
31
+ format="%(asctime)s — [%(levelname)s] %(message)s"
32
+ )
33
+ logger = logging.getLogger(__name__)
34
+
35
+ # ============================================================
36
+ # FastAPI app
37
+ # ============================================================
38
+
39
+ app = FastAPI(title="YouTube Proxy Server", version="1.0")
40
+
41
+
42
+ # Global exception handler to ensure all errors return JSON
43
+ @app.exception_handler(Exception)
44
+ async def global_exception_handler(request, exc):
45
+ """Catch all exceptions and return JSON error response."""
46
+ logger.error(f"Unhandled exception: {type(exc).__name__}: {str(exc)}")
47
+ return JSONResponse(
48
+ status_code=500,
49
+ content={"detail": f"Internal error: {str(exc)[:200]}"}
50
+ )
51
+
52
+
53
+ class YouTubeRequest(BaseModel):
54
+ """YouTube URL download request."""
55
+ url: str
56
+
57
+
58
+ @app.get("/health")
59
+ def health_check():
60
+ """Health check endpoint."""
61
+ return {"status": "healthy", "service": "youtube-proxy"}
62
+
63
+
64
+ @app.post("/download-youtube")
65
+ def download_youtube(
66
+ req: YouTubeRequest,
67
+ authorization: Optional[str] = Header(None),
68
+ ):
69
+ """
70
+ Download audio from YouTube URL.
71
+
72
+ Headers:
73
+ Authorization: "Bearer {API_KEY}"
74
+
75
+ Returns:
76
+ WAV file (binary)
77
+ """
78
+
79
+ # Verify API key
80
+ if not authorization or not authorization.startswith("Bearer "):
81
+ logger.warning(f"Missing/invalid auth header: {authorization}")
82
+ raise HTTPException(status_code=401, detail="Unauthorized")
83
+
84
+ token = authorization[7:] # Strip "Bearer "
85
+ if token != API_KEY:
86
+ logger.warning(f"Invalid API key: {token}")
87
+ raise HTTPException(status_code=403, detail="Forbidden")
88
+
89
+ url = req.url.strip()
90
+ if not url:
91
+ raise HTTPException(status_code=400, detail="Empty URL")
92
+
93
+ logger.info(f"Downloading: {url}")
94
+
95
+ try:
96
+ # Create temp directory
97
+ tmpdir = tempfile.mkdtemp(prefix="yt_audio_")
98
+ out_path = os.path.join(tmpdir, "audio.wav")
99
+
100
+ # Get absolute path to yt-dlp
101
+ # If in venv, use venv's yt-dlp; else use system yt-dlp
102
+ yt_dlp_path = os.path.join(
103
+ os.path.dirname(sys.executable), "yt-dlp"
104
+ )
105
+ if not os.path.exists(yt_dlp_path):
106
+ yt_dlp_path = "yt-dlp" # Fallback to system
107
+
108
+ # Execute yt-dlp
109
+ cmd = [
110
+ yt_dlp_path,
111
+ "--no-playlist",
112
+ "-x",
113
+ "--audio-format", "wav",
114
+ "--audio-quality", "0",
115
+ "--max-filesize", "50M",
116
+ "-o", out_path,
117
+ url,
118
+ ]
119
+
120
+ logger.debug(f"Command: {' '.join(cmd)}")
121
+ result = subprocess.run(
122
+ cmd,
123
+ capture_output=True,
124
+ text=True,
125
+ timeout=120,
126
+ )
127
+
128
+ if result.returncode != 0:
129
+ logger.error(f"yt-dlp failed: {result.stderr[:500]}")
130
+ raise HTTPException(
131
+ status_code=400,
132
+ detail=f"Download failed: {result.stderr[:200]}"
133
+ )
134
+
135
+ # Find the downloaded file
136
+ downloaded_file = None
137
+ for f in os.listdir(tmpdir):
138
+ downloaded_file = os.path.join(tmpdir, f)
139
+ break
140
+
141
+ if not downloaded_file or not os.path.exists(downloaded_file):
142
+ logger.error(f"Download completed but no file found in {tmpdir}")
143
+ raise HTTPException(
144
+ status_code=500,
145
+ detail="Download completed but no file found"
146
+ )
147
+
148
+ logger.info(f"Downloaded successfully: {downloaded_file}")
149
+
150
+ # Return file
151
+ return FileResponse(
152
+ path=downloaded_file,
153
+ media_type="audio/wav",
154
+ filename="audio.wav",
155
+ )
156
+
157
+ except subprocess.TimeoutExpired:
158
+ logger.error(f"Timeout downloading {url}")
159
+ raise HTTPException(status_code=504, detail="Download timeout")
160
+
161
+ except Exception as e:
162
+ logger.error(f"Error: {type(e).__name__}: {str(e)}")
163
+ raise HTTPException(status_code=500, detail=f"Internal error: {str(e)}")
164
+
165
+
166
+ if __name__ == "__main__":
167
+ import uvicorn
168
+
169
+ host = os.environ.get("HOST", "0.0.0.0")
170
+ port = int(os.environ.get("PORT", "8765"))
171
+
172
+ logger.info(f"Starting YouTube Proxy Server on {host}:{port}")
173
+ logger.info(f"API Key configured: {bool(API_KEY)}")
174
+
175
+ uvicorn.run(
176
+ app,
177
+ host=host,
178
+ port=port,
179
+ log_level=LOG_LEVEL.lower(),
180
+ )