Spaces:
Runtime error
Runtime error
Heewon Oh commited on
Commit ·
742e266
0
Parent(s):
chore: reset repository history - ArtifactNet HF Spaces Demo v8.0
Browse files- .gitattributes +35 -0
- .gitignore +40 -0
- Dockerfile.youtube-proxy +62 -0
- HF_SPACES_ENV.md +139 -0
- README.md +17 -0
- app.py +611 -0
- config.py +30 -0
- core/__init__.py +7 -0
- core/__pycache__/proprietary.cpython-312.pyc +0 -0
- core/codec_aware.py +32 -0
- core/proprietary.py +322 -0
- docker-compose.youtube-proxy.yml +36 -0
- inference/__init__.py +0 -0
- inference/audio_utils.py +54 -0
- inference/e2e_model.py +49 -0
- models +1 -0
- packages.txt +1 -0
- requirements.txt +15 -0
- ui/__init__.py +14 -0
- ui/components.py +112 -0
- ui/verdict_card.py +189 -0
- visualization/__init__.py +0 -0
- visualization/spectrogram.py +123 -0
- visualization/timeline.py +62 -0
- youtube_proxy_server.py +180 -0
.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 (<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><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%}
|
| 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 |
|
| 86 |
+
IQR={stats['iqr']:.2f} |
|
| 87 |
+
{channels} |
|
| 88 |
+
{duration:.1f}s |
|
| 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 |
+
"⚠", # 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 |
+
"●", # circle icon
|
| 114 |
+
"Mixed signals across segments — inconsistent pattern"
|
| 115 |
+
)
|
| 116 |
+
else: # Human-Made
|
| 117 |
+
return (
|
| 118 |
+
VerdictColors.HUMAN_MADE,
|
| 119 |
+
"✓", # 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 |
+
)
|