Commit
·
c7e434a
0
Parent(s):
Add profanity detection feature with 150+ Indonesian/English words
Browse files- .gitignore +65 -0
- Dockerfile +70 -0
- Dockerfile.hf +37 -0
- README.md +41 -0
- README_HF.md +41 -0
- app/__init__.py +6 -0
- app/api/__init__.py +3 -0
- app/api/routes.py +190 -0
- app/config.py +47 -0
- app/core/__init__.py +3 -0
- app/core/device.py +83 -0
- app/core/redis_client.py +44 -0
- app/core/storage.py +60 -0
- app/main.py +57 -0
- app/models.py +60 -0
- app/services/__init__.py +19 -0
- app/services/articulation.py +332 -0
- app/services/audio_processor.py +207 -0
- app/services/keywords.py +397 -0
- app/services/speech_to_text.py +109 -0
- app/services/structure.py +221 -0
- app/services/tempo.py +143 -0
- app/tasks.py +76 -0
- app/worker.py +50 -0
- backup_old_files/REDIS_CONFIG_NOTES.md +312 -0
- kata_kunci.json +203 -0
- requirements.txt +37 -0
- start.sh +56 -0
- tempo.py +154 -0
- upload_model_to_hf.py +175 -0
.gitignore
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.so
|
| 6 |
+
.Python
|
| 7 |
+
build/
|
| 8 |
+
develop-eggs/
|
| 9 |
+
dist/
|
| 10 |
+
downloads/
|
| 11 |
+
eggs/
|
| 12 |
+
.eggs/
|
| 13 |
+
lib/
|
| 14 |
+
lib64/
|
| 15 |
+
parts/
|
| 16 |
+
sdist/
|
| 17 |
+
var/
|
| 18 |
+
wheels/
|
| 19 |
+
*.egg-info/
|
| 20 |
+
.installed.cfg
|
| 21 |
+
*.egg
|
| 22 |
+
|
| 23 |
+
# Virtual environments
|
| 24 |
+
venv/
|
| 25 |
+
ENV/
|
| 26 |
+
env/
|
| 27 |
+
|
| 28 |
+
# IDE
|
| 29 |
+
.vscode/
|
| 30 |
+
.idea/
|
| 31 |
+
*.swp
|
| 32 |
+
*.swo
|
| 33 |
+
*~
|
| 34 |
+
|
| 35 |
+
# Environment variables
|
| 36 |
+
.env
|
| 37 |
+
|
| 38 |
+
# Uploads
|
| 39 |
+
uploads/
|
| 40 |
+
*.wav
|
| 41 |
+
*.mp3
|
| 42 |
+
*.m4a
|
| 43 |
+
*.flac
|
| 44 |
+
*.ogg
|
| 45 |
+
|
| 46 |
+
# Models (uncomment if not needed in repo)
|
| 47 |
+
# best_model/
|
| 48 |
+
|
| 49 |
+
# Data
|
| 50 |
+
*.csv
|
| 51 |
+
*.xlsx
|
| 52 |
+
|
| 53 |
+
# Logs
|
| 54 |
+
*.log
|
| 55 |
+
|
| 56 |
+
# OS
|
| 57 |
+
.DS_Store
|
| 58 |
+
Thumbs.db
|
| 59 |
+
|
| 60 |
+
# Docker
|
| 61 |
+
.dockerignore
|
| 62 |
+
|
| 63 |
+
# Jupyter
|
| 64 |
+
.ipynb_checkpoints/
|
| 65 |
+
*.ipynb
|
Dockerfile
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Hugging Face Spaces - Single Container Dockerfile
|
| 2 |
+
FROM python:3.10-slim
|
| 3 |
+
|
| 4 |
+
WORKDIR /app
|
| 5 |
+
|
| 6 |
+
# Install system dependencies including Redis
|
| 7 |
+
RUN apt-get update && apt-get install -y \
|
| 8 |
+
ffmpeg \
|
| 9 |
+
libsndfile1 \
|
| 10 |
+
git \
|
| 11 |
+
redis-server \
|
| 12 |
+
curl \
|
| 13 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 14 |
+
|
| 15 |
+
# Copy requirements
|
| 16 |
+
COPY requirements.txt .
|
| 17 |
+
|
| 18 |
+
# Install Python dependencies
|
| 19 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 20 |
+
|
| 21 |
+
# Create cache directory for models BEFORE copying code
|
| 22 |
+
# This ensures model downloads are cached even when code changes
|
| 23 |
+
RUN mkdir -p /.cache && chmod 777 /.cache
|
| 24 |
+
ENV HF_HOME=/.cache
|
| 25 |
+
ENV TORCH_HOME=/.cache
|
| 26 |
+
ENV XDG_CACHE_HOME=/.cache
|
| 27 |
+
|
| 28 |
+
# Pre-download models during build (HF Pro with persistent storage)
|
| 29 |
+
# These layers will be CACHED and won't rebuild when only code changes
|
| 30 |
+
|
| 31 |
+
# 1. Download Structure Model from HF Hub (~475MB)
|
| 32 |
+
RUN python -c "from transformers import AutoTokenizer, AutoModelForSequenceClassification; \
|
| 33 |
+
print('📥 Downloading Structure Model from HF Hub...'); \
|
| 34 |
+
AutoTokenizer.from_pretrained('Cyberlace/swara-structure-model', cache_dir='/.cache'); \
|
| 35 |
+
AutoModelForSequenceClassification.from_pretrained('Cyberlace/swara-structure-model', cache_dir='/.cache'); \
|
| 36 |
+
print('✅ Structure Model cached!')"
|
| 37 |
+
|
| 38 |
+
# 2. Download Whisper Base Model (~140MB) - lighter and faster
|
| 39 |
+
RUN python -c "import whisper; \
|
| 40 |
+
print('📥 Downloading Whisper base model...'); \
|
| 41 |
+
whisper.load_model('base', download_root='/.cache'); \
|
| 42 |
+
print('✅ Whisper base cached!')"
|
| 43 |
+
|
| 44 |
+
# 3. Download Sentence Transformer for Keywords (~420MB)
|
| 45 |
+
RUN python -c "from sentence_transformers import SentenceTransformer; \
|
| 46 |
+
print('📥 Downloading Sentence Transformer...'); \
|
| 47 |
+
SentenceTransformer('paraphrase-multilingual-MiniLM-L12-v2', cache_folder='/.cache'); \
|
| 48 |
+
print('✅ Sentence Transformer cached!')"
|
| 49 |
+
|
| 50 |
+
# 4. Download Silero VAD (~10MB)
|
| 51 |
+
RUN python -c "import torch; \
|
| 52 |
+
print('📥 Downloading Silero VAD model...'); \
|
| 53 |
+
torch.hub.load(repo_or_dir='snakers4/silero-vad', model='silero_vad', force_reload=False); \
|
| 54 |
+
print('✅ Silero VAD cached!')"
|
| 55 |
+
|
| 56 |
+
# Copy application code LAST (after model downloads)
|
| 57 |
+
# This way, code changes don't invalidate model cache layers
|
| 58 |
+
COPY . .
|
| 59 |
+
|
| 60 |
+
# Create uploads directory with proper permissions
|
| 61 |
+
RUN mkdir -p uploads && chmod 777 uploads
|
| 62 |
+
|
| 63 |
+
# Make start script executable
|
| 64 |
+
RUN chmod +x start.sh
|
| 65 |
+
|
| 66 |
+
# Expose Hugging Face Spaces port
|
| 67 |
+
EXPOSE 7860
|
| 68 |
+
|
| 69 |
+
# Start script (Redis + Worker + API)
|
| 70 |
+
CMD ["./start.sh"]
|
Dockerfile.hf
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Hugging Face Spaces - Single Container Dockerfile
|
| 2 |
+
FROM python:3.10-slim
|
| 3 |
+
|
| 4 |
+
WORKDIR /app
|
| 5 |
+
|
| 6 |
+
# Install system dependencies including Redis
|
| 7 |
+
RUN apt-get update && apt-get install -y \
|
| 8 |
+
ffmpeg \
|
| 9 |
+
libsndfile1 \
|
| 10 |
+
git \
|
| 11 |
+
redis-server \
|
| 12 |
+
curl \
|
| 13 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 14 |
+
|
| 15 |
+
# Copy requirements
|
| 16 |
+
COPY requirements.txt .
|
| 17 |
+
|
| 18 |
+
# Install Python dependencies
|
| 19 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 20 |
+
|
| 21 |
+
# Download smaller Whisper model for HF Spaces
|
| 22 |
+
RUN python -c "import whisper; whisper.load_model('base')"
|
| 23 |
+
|
| 24 |
+
# Copy application code
|
| 25 |
+
COPY . .
|
| 26 |
+
|
| 27 |
+
# Create uploads directory
|
| 28 |
+
RUN mkdir -p uploads
|
| 29 |
+
|
| 30 |
+
# Make start script executable
|
| 31 |
+
RUN chmod +x start.sh
|
| 32 |
+
|
| 33 |
+
# Expose Hugging Face Spaces port
|
| 34 |
+
EXPOSE 7860
|
| 35 |
+
|
| 36 |
+
# Start script (Redis + Worker + API)
|
| 37 |
+
CMD ["./start.sh"]
|
README.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Swara API - Audio Analysis
|
| 3 |
+
emoji: 🎙️
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
license: mit
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# Swara API - Audio Analysis Service 🎙️
|
| 12 |
+
|
| 13 |
+
AI-powered audio analysis service untuk penilaian public speaking.
|
| 14 |
+
|
| 15 |
+
## Features
|
| 16 |
+
|
| 17 |
+
- 🎤 Speech-to-Text dengan Whisper
|
| 18 |
+
- ⏱️ Tempo & Jeda Analysis
|
| 19 |
+
- 🗣️ Articulation Assessment
|
| 20 |
+
- 📊 Structure Detection
|
| 21 |
+
- 🔍 Keyword Relevance Analysis
|
| 22 |
+
|
| 23 |
+
## API Documentation
|
| 24 |
+
|
| 25 |
+
Once deployed, visit:
|
| 26 |
+
|
| 27 |
+
- `/docs` - Interactive Swagger UI
|
| 28 |
+
- `/redoc` - ReDoc documentation
|
| 29 |
+
- `/api/v1/health` - Health check
|
| 30 |
+
|
| 31 |
+
## Usage
|
| 32 |
+
|
| 33 |
+
```bash
|
| 34 |
+
# Submit audio for analysis
|
| 35 |
+
curl -X POST "https://YOUR_SPACE.hf.space/api/v1/analyze" \
|
| 36 |
+
-F "audio=@your_audio.wav" \
|
| 37 |
+
-F "analyze_tempo=true" \
|
| 38 |
+
-F "analyze_structure=true"
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
For detailed documentation, see the full README in the repository.
|
README_HF.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Swara API - Audio Analysis
|
| 3 |
+
emoji: 🎙️
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
license: mit
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# Swara API - Audio Analysis Service 🎙️
|
| 12 |
+
|
| 13 |
+
AI-powered audio analysis service untuk penilaian public speaking.
|
| 14 |
+
|
| 15 |
+
## Features
|
| 16 |
+
|
| 17 |
+
- 🎤 Speech-to-Text dengan Whisper
|
| 18 |
+
- ⏱️ Tempo & Jeda Analysis
|
| 19 |
+
- 🗣️ Articulation Assessment
|
| 20 |
+
- 📊 Structure Detection
|
| 21 |
+
- 🔍 Keyword Relevance Analysis
|
| 22 |
+
|
| 23 |
+
## API Documentation
|
| 24 |
+
|
| 25 |
+
Once deployed, visit:
|
| 26 |
+
|
| 27 |
+
- `/docs` - Interactive Swagger UI
|
| 28 |
+
- `/redoc` - ReDoc documentation
|
| 29 |
+
- `/api/v1/health` - Health check
|
| 30 |
+
|
| 31 |
+
## Usage
|
| 32 |
+
|
| 33 |
+
```bash
|
| 34 |
+
# Submit audio for analysis
|
| 35 |
+
curl -X POST "https://YOUR_SPACE.hf.space/api/v1/analyze" \
|
| 36 |
+
-F "audio=@your_audio.wav" \
|
| 37 |
+
-F "analyze_tempo=true" \
|
| 38 |
+
-F "analyze_structure=true"
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
For detailed documentation, see the full README in the repository.
|
app/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Swara API - Audio Analysis Service
|
| 3 |
+
Public Speaking Analysis with AI
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
__version__ = "1.0.0"
|
app/api/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
API module
|
| 3 |
+
"""
|
app/api/routes.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
API Routes
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from fastapi import APIRouter, UploadFile, File, Form, HTTPException
|
| 6 |
+
from typing import Optional, List
|
| 7 |
+
import uuid
|
| 8 |
+
import os
|
| 9 |
+
import json
|
| 10 |
+
|
| 11 |
+
from app.models import TaskResponse, TaskStatusResponse, TaskStatus, AnalysisRequest
|
| 12 |
+
from app.core.redis_client import get_queue
|
| 13 |
+
from app.core.storage import save_uploaded_file
|
| 14 |
+
from app.config import settings
|
| 15 |
+
from app.tasks import process_audio_task
|
| 16 |
+
|
| 17 |
+
router = APIRouter()
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@router.post("/analyze", response_model=TaskResponse)
|
| 21 |
+
async def analyze_audio(
|
| 22 |
+
audio: UploadFile = File(...),
|
| 23 |
+
reference_text: Optional[str] = Form(None),
|
| 24 |
+
topic_id: Optional[str] = Form(None),
|
| 25 |
+
custom_topic: Optional[str] = Form(None),
|
| 26 |
+
custom_keywords: Optional[str] = Form(None), # JSON string dari frontend
|
| 27 |
+
analyze_tempo: bool = Form(True),
|
| 28 |
+
analyze_articulation: bool = Form(True),
|
| 29 |
+
analyze_structure: bool = Form(True),
|
| 30 |
+
analyze_keywords: bool = Form(False),
|
| 31 |
+
analyze_profanity: bool = Form(False)
|
| 32 |
+
):
|
| 33 |
+
"""
|
| 34 |
+
Submit audio file untuk analisis
|
| 35 |
+
|
| 36 |
+
Parameters:
|
| 37 |
+
- audio: File audio (.wav, .mp3, .m4a, .flac, .ogg)
|
| 38 |
+
- reference_text: Teks referensi untuk artikulasi (optional)
|
| 39 |
+
- topic_id: ID topik dari database untuk Level 1-2 (optional)
|
| 40 |
+
- custom_topic: Topik custom untuk Level 3 (optional)
|
| 41 |
+
- custom_keywords: JSON array kata kunci dari GPT, contoh: ["inovasi", "kreativitas", "perubahan"] (optional)
|
| 42 |
+
- analyze_tempo: Analisis tempo (default: true)
|
| 43 |
+
- analyze_articulation: Analisis artikulasi (default: true)
|
| 44 |
+
- analyze_structure: Analisis struktur (default: true)
|
| 45 |
+
- analyze_keywords: Analisis kata kunci (default: false)
|
| 46 |
+
- analyze_profanity: Deteksi kata tidak senonoh (default: false)
|
| 47 |
+
|
| 48 |
+
Returns task_id yang bisa digunakan untuk check status
|
| 49 |
+
"""
|
| 50 |
+
|
| 51 |
+
# Validate file extension
|
| 52 |
+
file_ext = os.path.splitext(audio.filename)[1].lower()
|
| 53 |
+
if file_ext not in settings.ALLOWED_EXTENSIONS:
|
| 54 |
+
raise HTTPException(
|
| 55 |
+
status_code=400,
|
| 56 |
+
detail=f"File type {file_ext} not allowed. Allowed: {settings.ALLOWED_EXTENSIONS}"
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
# Validate file size
|
| 60 |
+
content = await audio.read()
|
| 61 |
+
if len(content) > settings.MAX_UPLOAD_SIZE:
|
| 62 |
+
raise HTTPException(
|
| 63 |
+
status_code=400,
|
| 64 |
+
detail=f"File too large. Max size: {settings.MAX_UPLOAD_SIZE / 1024 / 1024}MB"
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
# Parse custom_keywords dari JSON string
|
| 68 |
+
parsed_custom_keywords = None
|
| 69 |
+
if custom_keywords:
|
| 70 |
+
try:
|
| 71 |
+
parsed_custom_keywords = json.loads(custom_keywords)
|
| 72 |
+
if not isinstance(parsed_custom_keywords, list):
|
| 73 |
+
raise ValueError("custom_keywords harus berupa array")
|
| 74 |
+
except json.JSONDecodeError:
|
| 75 |
+
raise HTTPException(
|
| 76 |
+
status_code=400,
|
| 77 |
+
detail="custom_keywords harus berupa JSON array valid, contoh: [\"kata1\", \"kata2\"]"
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
# Save file
|
| 81 |
+
task_id = str(uuid.uuid4())
|
| 82 |
+
filename = f"{task_id}{file_ext}"
|
| 83 |
+
file_path = save_uploaded_file(content, filename)
|
| 84 |
+
|
| 85 |
+
# Submit task to queue
|
| 86 |
+
queue = get_queue()
|
| 87 |
+
job = queue.enqueue(
|
| 88 |
+
process_audio_task,
|
| 89 |
+
audio_path=file_path,
|
| 90 |
+
reference_text=reference_text,
|
| 91 |
+
topic_id=topic_id,
|
| 92 |
+
custom_topic=custom_topic,
|
| 93 |
+
custom_keywords=parsed_custom_keywords,
|
| 94 |
+
analyze_tempo=analyze_tempo,
|
| 95 |
+
analyze_articulation=analyze_articulation,
|
| 96 |
+
analyze_structure=analyze_structure,
|
| 97 |
+
analyze_keywords=analyze_keywords,
|
| 98 |
+
analyze_profanity=analyze_profanity,
|
| 99 |
+
job_id=task_id,
|
| 100 |
+
job_timeout=settings.JOB_TIMEOUT,
|
| 101 |
+
result_ttl=settings.RESULT_TTL
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
return TaskResponse(
|
| 105 |
+
task_id=task_id,
|
| 106 |
+
status=TaskStatus.QUEUED,
|
| 107 |
+
message="Task submitted successfully"
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
@router.get("/status/{task_id}", response_model=TaskStatusResponse)
|
| 112 |
+
async def get_task_status(task_id: str):
|
| 113 |
+
"""
|
| 114 |
+
Check status dari task
|
| 115 |
+
|
| 116 |
+
Returns status dan result jika sudah selesai
|
| 117 |
+
"""
|
| 118 |
+
from rq.job import Job
|
| 119 |
+
from app.core.redis_client import get_redis_connection
|
| 120 |
+
|
| 121 |
+
try:
|
| 122 |
+
redis_conn = get_redis_connection()
|
| 123 |
+
job = Job.fetch(task_id, connection=redis_conn)
|
| 124 |
+
|
| 125 |
+
# Map job status to our TaskStatus
|
| 126 |
+
if job.is_queued:
|
| 127 |
+
status = TaskStatus.QUEUED
|
| 128 |
+
elif job.is_started:
|
| 129 |
+
status = TaskStatus.PROCESSING
|
| 130 |
+
elif job.is_finished:
|
| 131 |
+
status = TaskStatus.COMPLETED
|
| 132 |
+
elif job.is_failed:
|
| 133 |
+
status = TaskStatus.FAILED
|
| 134 |
+
else:
|
| 135 |
+
status = TaskStatus.QUEUED
|
| 136 |
+
|
| 137 |
+
# Get result if completed
|
| 138 |
+
result = None
|
| 139 |
+
error = None
|
| 140 |
+
|
| 141 |
+
if job.is_finished:
|
| 142 |
+
job_result = job.result
|
| 143 |
+
if isinstance(job_result, dict):
|
| 144 |
+
if job_result.get('status') == 'completed':
|
| 145 |
+
result = job_result.get('result')
|
| 146 |
+
elif job_result.get('status') == 'failed':
|
| 147 |
+
error = job_result.get('error')
|
| 148 |
+
status = TaskStatus.FAILED
|
| 149 |
+
|
| 150 |
+
if job.is_failed:
|
| 151 |
+
error = str(job.exc_info)
|
| 152 |
+
|
| 153 |
+
return TaskStatusResponse(
|
| 154 |
+
task_id=task_id,
|
| 155 |
+
status=status,
|
| 156 |
+
result=result,
|
| 157 |
+
error=error,
|
| 158 |
+
created_at=job.created_at.isoformat() if job.created_at else None,
|
| 159 |
+
updated_at=job.ended_at.isoformat() if job.ended_at else None
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
except Exception as e:
|
| 163 |
+
raise HTTPException(
|
| 164 |
+
status_code=404,
|
| 165 |
+
detail=f"Task not found: {str(e)}"
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
@router.get("/health")
|
| 170 |
+
async def health_check():
|
| 171 |
+
"""Health check endpoint"""
|
| 172 |
+
from app.core.redis_client import check_redis_connection
|
| 173 |
+
from app.core.device import get_device_info
|
| 174 |
+
|
| 175 |
+
is_connected, error_msg = check_redis_connection()
|
| 176 |
+
|
| 177 |
+
if is_connected:
|
| 178 |
+
redis_status = "healthy"
|
| 179 |
+
else:
|
| 180 |
+
redis_status = f"unhealthy: {error_msg}"
|
| 181 |
+
|
| 182 |
+
# Get device information
|
| 183 |
+
device_info = get_device_info()
|
| 184 |
+
|
| 185 |
+
return {
|
| 186 |
+
"status": "healthy" if is_connected else "degraded",
|
| 187 |
+
"redis": redis_status,
|
| 188 |
+
"version": settings.VERSION,
|
| 189 |
+
"device": device_info
|
| 190 |
+
}
|
app/config.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Configuration file
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
from pydantic_settings import BaseSettings
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class Settings(BaseSettings):
|
| 10 |
+
"""Application settings"""
|
| 11 |
+
|
| 12 |
+
# App Info
|
| 13 |
+
APP_NAME: str = "Swara API - Audio Analysis"
|
| 14 |
+
VERSION: str = "1.0.0"
|
| 15 |
+
DEBUG: bool = os.getenv("DEBUG", "False").lower() == "true"
|
| 16 |
+
|
| 17 |
+
# Redis Configuration
|
| 18 |
+
REDIS_HOST: str = os.getenv("REDIS_HOST", "localhost")
|
| 19 |
+
REDIS_PORT: int = int(os.getenv("REDIS_PORT", "6379"))
|
| 20 |
+
REDIS_DB: int = int(os.getenv("REDIS_DB", "0"))
|
| 21 |
+
REDIS_PASSWORD: str = os.getenv("REDIS_PASSWORD", "")
|
| 22 |
+
|
| 23 |
+
# RQ Worker Configuration
|
| 24 |
+
QUEUE_NAME: str = "audio_analysis"
|
| 25 |
+
JOB_TIMEOUT: int = 3600 # 1 hour
|
| 26 |
+
RESULT_TTL: int = 86400 # 24 hours
|
| 27 |
+
|
| 28 |
+
# File Upload
|
| 29 |
+
MAX_UPLOAD_SIZE: int = 100 * 1024 * 1024 # 100 MB
|
| 30 |
+
ALLOWED_EXTENSIONS: list = [".wav", ".mp3", ".m4a", ".flac", ".ogg"]
|
| 31 |
+
UPLOAD_DIR: str = os.getenv("UPLOAD_DIR", "./uploads")
|
| 32 |
+
|
| 33 |
+
# Model Configuration
|
| 34 |
+
WHISPER_MODEL: str = os.getenv("WHISPER_MODEL", "base")
|
| 35 |
+
KATA_KUNCI_PATH: str = os.getenv("KATA_KUNCI_PATH", "./kata_kunci.json")
|
| 36 |
+
|
| 37 |
+
# Device Configuration (CPU/GPU)
|
| 38 |
+
DEVICE: str = os.getenv("DEVICE", "auto") # 'auto', 'cpu', or 'cuda'
|
| 39 |
+
|
| 40 |
+
# CORS
|
| 41 |
+
CORS_ORIGINS: list = ["*"]
|
| 42 |
+
|
| 43 |
+
class Config:
|
| 44 |
+
env_file = ".env"
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
settings = Settings()
|
app/core/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Core module
|
| 3 |
+
"""
|
app/core/device.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Device Detection Utility
|
| 3 |
+
Auto-detect dan konfigurasi device (CPU/GPU) untuk model ML
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import torch
|
| 7 |
+
import os
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def get_device() -> str:
|
| 11 |
+
"""
|
| 12 |
+
Deteksi device yang tersedia (CPU atau CUDA GPU)
|
| 13 |
+
|
| 14 |
+
Returns:
|
| 15 |
+
str: 'cuda' jika GPU tersedia, 'cpu' jika tidak
|
| 16 |
+
"""
|
| 17 |
+
# Check environment variable override
|
| 18 |
+
device_override = os.getenv("DEVICE", "").lower()
|
| 19 |
+
if device_override in ["cpu", "cuda"]:
|
| 20 |
+
print(f"🔧 Device override from env: {device_override}")
|
| 21 |
+
return device_override
|
| 22 |
+
|
| 23 |
+
# Auto-detect
|
| 24 |
+
if torch.cuda.is_available():
|
| 25 |
+
device = "cuda"
|
| 26 |
+
gpu_name = torch.cuda.get_device_name(0)
|
| 27 |
+
gpu_memory = torch.cuda.get_device_properties(0).total_memory / 1024**3
|
| 28 |
+
print(f"🎮 GPU detected: {gpu_name} ({gpu_memory:.1f}GB)")
|
| 29 |
+
else:
|
| 30 |
+
device = "cpu"
|
| 31 |
+
print("💻 No GPU detected, using CPU")
|
| 32 |
+
|
| 33 |
+
return device
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def get_device_info() -> dict:
|
| 37 |
+
"""
|
| 38 |
+
Get detailed device information
|
| 39 |
+
|
| 40 |
+
Returns:
|
| 41 |
+
dict: Device information
|
| 42 |
+
"""
|
| 43 |
+
device = get_device()
|
| 44 |
+
|
| 45 |
+
info = {
|
| 46 |
+
"device": device,
|
| 47 |
+
"cuda_available": torch.cuda.is_available(),
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
if device == "cuda":
|
| 51 |
+
info.update({
|
| 52 |
+
"gpu_name": torch.cuda.get_device_name(0),
|
| 53 |
+
"gpu_memory_gb": round(torch.cuda.get_device_properties(0).total_memory / 1024**3, 2),
|
| 54 |
+
"cuda_version": torch.version.cuda,
|
| 55 |
+
"gpu_count": torch.cuda.device_count()
|
| 56 |
+
})
|
| 57 |
+
else:
|
| 58 |
+
info.update({
|
| 59 |
+
"cpu_count": os.cpu_count(),
|
| 60 |
+
"torch_threads": torch.get_num_threads()
|
| 61 |
+
})
|
| 62 |
+
|
| 63 |
+
return info
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def optimize_for_device(device: str):
|
| 67 |
+
"""
|
| 68 |
+
Optimize PyTorch settings based on device
|
| 69 |
+
|
| 70 |
+
Args:
|
| 71 |
+
device: 'cpu' or 'cuda'
|
| 72 |
+
"""
|
| 73 |
+
if device == "cpu":
|
| 74 |
+
# Optimize CPU performance
|
| 75 |
+
cpu_count = os.cpu_count() or 1
|
| 76 |
+
torch.set_num_threads(min(cpu_count, 4)) # Limit threads to avoid overhead
|
| 77 |
+
print(f"⚙️ PyTorch threads: {torch.get_num_threads()}")
|
| 78 |
+
|
| 79 |
+
elif device == "cuda":
|
| 80 |
+
# Optimize GPU performance
|
| 81 |
+
torch.backends.cudnn.benchmark = True # Auto-tune kernels
|
| 82 |
+
torch.backends.cuda.matmul.allow_tf32 = True # Allow TF32 for faster matmul
|
| 83 |
+
print("⚡ GPU optimizations enabled")
|
app/core/redis_client.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Redis client setup
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import redis
|
| 6 |
+
from rq import Queue
|
| 7 |
+
from app.config import settings
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def get_redis_connection():
|
| 11 |
+
"""Get Redis connection for RQ (without decode_responses)"""
|
| 12 |
+
redis_kwargs = {
|
| 13 |
+
'host': settings.REDIS_HOST,
|
| 14 |
+
'port': settings.REDIS_PORT,
|
| 15 |
+
'db': settings.REDIS_DB,
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
# Only add password if it's set
|
| 19 |
+
if settings.REDIS_PASSWORD:
|
| 20 |
+
redis_kwargs['password'] = settings.REDIS_PASSWORD
|
| 21 |
+
|
| 22 |
+
# Don't use decode_responses for RQ compatibility
|
| 23 |
+
return redis.Redis(**redis_kwargs)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def get_queue():
|
| 27 |
+
"""Get RQ Queue"""
|
| 28 |
+
conn = get_redis_connection()
|
| 29 |
+
return Queue(settings.QUEUE_NAME, connection=conn)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def check_redis_connection():
|
| 33 |
+
"""
|
| 34 |
+
Check if Redis connection is working
|
| 35 |
+
Returns tuple: (is_connected: bool, error_message: str)
|
| 36 |
+
"""
|
| 37 |
+
try:
|
| 38 |
+
conn = get_redis_connection()
|
| 39 |
+
conn.ping()
|
| 40 |
+
return True, None
|
| 41 |
+
except redis.ConnectionError as e:
|
| 42 |
+
return False, f"Redis connection error: {str(e)}"
|
| 43 |
+
except Exception as e:
|
| 44 |
+
return False, f"Redis error: {str(e)}"
|
app/core/storage.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
File storage utilities
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
import shutil
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from app.config import settings
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def ensure_upload_dir():
|
| 12 |
+
"""Ensure upload directory exists"""
|
| 13 |
+
Path(settings.UPLOAD_DIR).mkdir(parents=True, exist_ok=True)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def save_uploaded_file(file_content: bytes, filename: str) -> str:
|
| 17 |
+
"""
|
| 18 |
+
Save uploaded file
|
| 19 |
+
|
| 20 |
+
Returns:
|
| 21 |
+
str: Path to saved file
|
| 22 |
+
"""
|
| 23 |
+
ensure_upload_dir()
|
| 24 |
+
|
| 25 |
+
file_path = os.path.join(settings.UPLOAD_DIR, filename)
|
| 26 |
+
|
| 27 |
+
with open(file_path, "wb") as f:
|
| 28 |
+
f.write(file_content)
|
| 29 |
+
|
| 30 |
+
return file_path
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def delete_file(file_path: str):
|
| 34 |
+
"""Delete file if exists"""
|
| 35 |
+
if os.path.exists(file_path):
|
| 36 |
+
os.remove(file_path)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def cleanup_old_files(max_age_hours: int = 24):
|
| 40 |
+
"""Cleanup old uploaded files"""
|
| 41 |
+
import time
|
| 42 |
+
|
| 43 |
+
if not os.path.exists(settings.UPLOAD_DIR):
|
| 44 |
+
return
|
| 45 |
+
|
| 46 |
+
current_time = time.time()
|
| 47 |
+
max_age_seconds = max_age_hours * 3600
|
| 48 |
+
|
| 49 |
+
for filename in os.listdir(settings.UPLOAD_DIR):
|
| 50 |
+
file_path = os.path.join(settings.UPLOAD_DIR, filename)
|
| 51 |
+
|
| 52 |
+
if os.path.isfile(file_path):
|
| 53 |
+
file_age = current_time - os.path.getmtime(file_path)
|
| 54 |
+
|
| 55 |
+
if file_age > max_age_seconds:
|
| 56 |
+
try:
|
| 57 |
+
delete_file(file_path)
|
| 58 |
+
print(f"Deleted old file: {filename}")
|
| 59 |
+
except Exception as e:
|
| 60 |
+
print(f"Error deleting {filename}: {e}")
|
app/main.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FastAPI Application
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from fastapi import FastAPI
|
| 6 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 7 |
+
from app.config import settings
|
| 8 |
+
from app.api.routes import router
|
| 9 |
+
from app.core.storage import ensure_upload_dir
|
| 10 |
+
|
| 11 |
+
# Create FastAPI app
|
| 12 |
+
app = FastAPI(
|
| 13 |
+
title=settings.APP_NAME,
|
| 14 |
+
version=settings.VERSION,
|
| 15 |
+
description="Audio Analysis API for Public Speaking Assessment"
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
# CORS
|
| 19 |
+
app.add_middleware(
|
| 20 |
+
CORSMiddleware,
|
| 21 |
+
allow_origins=settings.CORS_ORIGINS,
|
| 22 |
+
allow_credentials=True,
|
| 23 |
+
allow_methods=["*"],
|
| 24 |
+
allow_headers=["*"],
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
# Include routers
|
| 28 |
+
app.include_router(router, prefix="/api/v1", tags=["audio-analysis"])
|
| 29 |
+
|
| 30 |
+
# Startup event
|
| 31 |
+
@app.on_event("startup")
|
| 32 |
+
async def startup_event():
|
| 33 |
+
"""Initialize on startup"""
|
| 34 |
+
print(f"🚀 Starting {settings.APP_NAME} v{settings.VERSION}")
|
| 35 |
+
ensure_upload_dir()
|
| 36 |
+
print(f"✅ Upload directory ready: {settings.UPLOAD_DIR}")
|
| 37 |
+
|
| 38 |
+
# Root endpoint
|
| 39 |
+
@app.get("/")
|
| 40 |
+
async def root():
|
| 41 |
+
"""Root endpoint"""
|
| 42 |
+
return {
|
| 43 |
+
"app": settings.APP_NAME,
|
| 44 |
+
"version": settings.VERSION,
|
| 45 |
+
"docs": "/docs",
|
| 46 |
+
"health": "/api/v1/health"
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
if __name__ == "__main__":
|
| 51 |
+
import uvicorn
|
| 52 |
+
uvicorn.run(
|
| 53 |
+
"app.main:app",
|
| 54 |
+
host="0.0.0.0",
|
| 55 |
+
port=8000,
|
| 56 |
+
reload=settings.DEBUG
|
| 57 |
+
)
|
app/models.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Pydantic models untuk request/response
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from pydantic import BaseModel, Field
|
| 6 |
+
from typing import Optional, Dict, Any, List
|
| 7 |
+
from enum import Enum
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class TaskStatus(str, Enum):
|
| 11 |
+
"""Task status enum"""
|
| 12 |
+
QUEUED = "queued"
|
| 13 |
+
PROCESSING = "processing"
|
| 14 |
+
COMPLETED = "completed"
|
| 15 |
+
FAILED = "failed"
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class AnalysisRequest(BaseModel):
|
| 19 |
+
"""Request untuk analisis audio"""
|
| 20 |
+
reference_text: Optional[str] = Field(None, description="Teks referensi untuk perbandingan")
|
| 21 |
+
topic_id: Optional[str] = Field(None, description="ID topik untuk analisis kata kunci")
|
| 22 |
+
analyze_tempo: bool = Field(True, description="Analisis tempo dan jeda")
|
| 23 |
+
analyze_articulation: bool = Field(True, description="Analisis artikulasi/pronunciation")
|
| 24 |
+
analyze_structure: bool = Field(True, description="Analisis struktur berbicara")
|
| 25 |
+
analyze_keywords: bool = Field(False, description="Analisis kata kunci (perlu topic_id)")
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class TaskResponse(BaseModel):
|
| 29 |
+
"""Response untuk submit task"""
|
| 30 |
+
task_id: str
|
| 31 |
+
status: TaskStatus
|
| 32 |
+
message: str
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class TaskStatusResponse(BaseModel):
|
| 36 |
+
"""Response untuk check status task"""
|
| 37 |
+
task_id: str
|
| 38 |
+
status: TaskStatus
|
| 39 |
+
progress: Optional[float] = None
|
| 40 |
+
result: Optional[Dict[str, Any]] = None
|
| 41 |
+
error: Optional[str] = None
|
| 42 |
+
created_at: Optional[str] = None
|
| 43 |
+
updated_at: Optional[str] = None
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class AnalysisResult(BaseModel):
|
| 47 |
+
"""Full analysis result"""
|
| 48 |
+
task_id: str
|
| 49 |
+
status: str
|
| 50 |
+
transcript: str
|
| 51 |
+
|
| 52 |
+
# Results dari masing-masing analisis
|
| 53 |
+
tempo: Optional[Dict[str, Any]] = None
|
| 54 |
+
articulation: Optional[Dict[str, Any]] = None
|
| 55 |
+
structure: Optional[Dict[str, Any]] = None
|
| 56 |
+
keywords: Optional[Dict[str, Any]] = None
|
| 57 |
+
|
| 58 |
+
# Summary
|
| 59 |
+
overall_score: Optional[float] = None
|
| 60 |
+
processing_time: Optional[float] = None
|
app/services/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Services module
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from app.services.speech_to_text import SpeechToTextService
|
| 6 |
+
from app.services.tempo import TempoService
|
| 7 |
+
from app.services.articulation import ArticulationService
|
| 8 |
+
from app.services.structure import StructureService
|
| 9 |
+
from app.services.keywords import KeywordService
|
| 10 |
+
from app.services.audio_processor import AudioProcessor
|
| 11 |
+
|
| 12 |
+
__all__ = [
|
| 13 |
+
'SpeechToTextService',
|
| 14 |
+
'TempoService',
|
| 15 |
+
'ArticulationService',
|
| 16 |
+
'StructureService',
|
| 17 |
+
'KeywordService',
|
| 18 |
+
'AudioProcessor'
|
| 19 |
+
]
|
app/services/articulation.py
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Articulation Analysis Service
|
| 3 |
+
Analisis artikulasi/pronunciation dengan BERT-based alignment
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import torch
|
| 7 |
+
import numpy as np
|
| 8 |
+
from typing import Dict, List, Tuple, Optional
|
| 9 |
+
from dataclasses import dataclass, asdict
|
| 10 |
+
import re
|
| 11 |
+
import warnings
|
| 12 |
+
from difflib import SequenceMatcher
|
| 13 |
+
warnings.filterwarnings('ignore')
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@dataclass
|
| 17 |
+
class WordScore:
|
| 18 |
+
"""Score untuk satu kata"""
|
| 19 |
+
index: int
|
| 20 |
+
expected: str
|
| 21 |
+
detected: str
|
| 22 |
+
is_correct: bool
|
| 23 |
+
similarity: float
|
| 24 |
+
is_filler: bool = False
|
| 25 |
+
match_type: str = "match"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class FillerWordsDetector:
|
| 29 |
+
"""Deteksi kata pengisi dalam Bahasa Indonesia"""
|
| 30 |
+
|
| 31 |
+
FILLER_WORDS = {
|
| 32 |
+
'um', 'umm', 'ummm', 'em', 'emm', 'emmm',
|
| 33 |
+
'eh', 'ehh', 'ehhh', 'ehm', 'ehmm', 'ehmmm',
|
| 34 |
+
'ah', 'ahh', 'ahhh', 'ahm', 'ahmm', 'ahmmm',
|
| 35 |
+
'hmm', 'hmmm', 'hmmmm',
|
| 36 |
+
'uh', 'uhh', 'uhhh', 'uhm', 'uhmm',
|
| 37 |
+
'anu', 'ano', 'gitu', 'gituloh', 'gitu loh',
|
| 38 |
+
'kayak', 'kayaknya', 'kayak gini', 'kayak gitu',
|
| 39 |
+
'apa', 'apa ya', 'apa namanya',
|
| 40 |
+
'maksudnya', 'maksud saya', 'jadi', 'jadinya',
|
| 41 |
+
'nah', 'terus', 'lalu', 'kemudian',
|
| 42 |
+
'gini', 'begini', 'begitu',
|
| 43 |
+
'semacam', 'semisal', 'ibaratnya',
|
| 44 |
+
'ya kan', 'kan', 'ya', 'yah',
|
| 45 |
+
'sepertinya', 'mungkin',
|
| 46 |
+
'toh', 'sih', 'deh', 'dong', 'lah',
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
@classmethod
|
| 50 |
+
def is_filler(cls, word: str) -> bool:
|
| 51 |
+
"""Check if word is a filler"""
|
| 52 |
+
return word.lower() in cls.FILLER_WORDS
|
| 53 |
+
|
| 54 |
+
@classmethod
|
| 55 |
+
def count_fillers(cls, words: List[str]) -> int:
|
| 56 |
+
"""Count filler words in list"""
|
| 57 |
+
return sum(1 for word in words if cls.is_filler(word))
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class ProfanityDetector:
|
| 61 |
+
"""Deteksi kata tidak senonoh dalam Bahasa Indonesia dan Inggris"""
|
| 62 |
+
|
| 63 |
+
PROFANITY_WORDS = {
|
| 64 |
+
'anjir', 'anjay', 'njir', 'njay', 'anjrit', 'njrit', 'shit', 'fuck',
|
| 65 |
+
'tolol', 'oon', 'bego', 'gak ada otak', 'goblok', 'bodoh', 'anjim',
|
| 66 |
+
'anjing', 'anjrot', 'asu', 'babi', 'bacot', 'bajingan', 'banci',
|
| 67 |
+
'bangke', 'bangor', 'bangsat', 'bejad', 'bencong', 'bodat', 'bugil',
|
| 68 |
+
'bundir', 'bunuh', 'burik', 'burit', 'cawek', 'cemen', 'cipok', 'cium',
|
| 69 |
+
'colai', 'coli', 'colmek', 'cukimai', 'cukimay', 'culun', 'cumbu',
|
| 70 |
+
'dancuk', 'dewasa', 'dick', 'dildo', 'encuk', 'gay', 'gei', 'gembel',
|
| 71 |
+
'gey', 'gigolo', 'gila', 'goblog', 'haram', 'hencet', 'hentai', 'idiot',
|
| 72 |
+
'jablai', 'jablay', 'jancok', 'jancuk', 'jangkik', 'jembut', 'jilat',
|
| 73 |
+
'jingan', 'kampang', 'keparat', 'kimak', 'kirik', 'klentit', 'klitoris',
|
| 74 |
+
'konthol', 'kontol', 'koplok', 'kunyuk', 'kutang', 'kutis', 'kwontol',
|
| 75 |
+
'lonte', 'maho', 'masturbasi', 'matane', 'mati', 'memek', 'mesum',
|
| 76 |
+
'modar', 'modyar', 'mokad', 'najis', 'nazi', 'ndhasmu', 'nenen',
|
| 77 |
+
'ngentot', 'ngolom', 'ngulum', 'nigga', 'nigger', 'onani', 'orgasme',
|
| 78 |
+
'paksa', 'pantat', 'pantek', 'pecun', 'peli', 'penis', 'pentil', 'pepek',
|
| 79 |
+
'perek', 'perkosa', 'piatu', 'porno', 'pukimak', 'qontol', 'selangkang',
|
| 80 |
+
'sempak', 'senggama', 'setan', 'setubuh', 'silet', 'silit', 'sinting',
|
| 81 |
+
'sodomi', 'stres', 'telanjang', 'telaso', 'tete', 'tewas', 'titit',
|
| 82 |
+
'togel', 'toket', 'tusbol', 'urin', 'vagina'
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
@classmethod
|
| 86 |
+
def detect_profanity(cls, text: str) -> Dict:
|
| 87 |
+
"""
|
| 88 |
+
Deteksi kata tidak senonoh dalam teks
|
| 89 |
+
|
| 90 |
+
Returns:
|
| 91 |
+
Dict dengan keys:
|
| 92 |
+
- has_profanity: bool
|
| 93 |
+
- profanity_count: int
|
| 94 |
+
- profanity_words: List[str] (kata yang terdeteksi)
|
| 95 |
+
"""
|
| 96 |
+
# Normalisasi text
|
| 97 |
+
text_lower = text.lower()
|
| 98 |
+
words = re.findall(r'\b\w+\b', text_lower)
|
| 99 |
+
|
| 100 |
+
# Cari kata tidak senonoh
|
| 101 |
+
found_profanity = []
|
| 102 |
+
for word in words:
|
| 103 |
+
if word in cls.PROFANITY_WORDS:
|
| 104 |
+
found_profanity.append(word)
|
| 105 |
+
|
| 106 |
+
# Cari phrase (2-3 kata)
|
| 107 |
+
phrases_2 = [f"{words[i]} {words[i+1]}" for i in range(len(words)-1)]
|
| 108 |
+
phrases_3 = [f"{words[i]} {words[i+1]} {words[i+2]}" for i in range(len(words)-2)]
|
| 109 |
+
|
| 110 |
+
for phrase in phrases_2 + phrases_3:
|
| 111 |
+
if phrase in cls.PROFANITY_WORDS:
|
| 112 |
+
found_profanity.append(phrase)
|
| 113 |
+
|
| 114 |
+
return {
|
| 115 |
+
'has_profanity': len(found_profanity) > 0,
|
| 116 |
+
'profanity_count': len(found_profanity),
|
| 117 |
+
'profanity_words': list(set(found_profanity)) # Remove duplicates
|
| 118 |
+
}
|
| 119 |
+
import string
|
| 120 |
+
word_clean = word.lower().strip().rstrip(string.punctuation)
|
| 121 |
+
|
| 122 |
+
if word_clean in cls.FILLER_WORDS:
|
| 123 |
+
return True
|
| 124 |
+
|
| 125 |
+
if re.match(r'^(um+|em+|eh+m*|ah+m*|uh+m*|hmm+)$', word_clean):
|
| 126 |
+
return True
|
| 127 |
+
|
| 128 |
+
return False
|
| 129 |
+
|
| 130 |
+
@classmethod
|
| 131 |
+
def count_fillers(cls, text: str) -> Tuple[int, List[str]]:
|
| 132 |
+
"""Count filler words in text"""
|
| 133 |
+
words = text.lower().split()
|
| 134 |
+
fillers = [w for w in words if cls.is_filler(w)]
|
| 135 |
+
return len(fillers), fillers
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
class SequenceAligner:
|
| 139 |
+
"""Sequence alignment untuk word matching"""
|
| 140 |
+
|
| 141 |
+
@staticmethod
|
| 142 |
+
def calculate_similarity(word1: str, word2: str) -> float:
|
| 143 |
+
"""Calculate similarity between two words"""
|
| 144 |
+
return SequenceMatcher(None, word1.lower(), word2.lower()).ratio()
|
| 145 |
+
|
| 146 |
+
@staticmethod
|
| 147 |
+
def align_sequences(
|
| 148 |
+
reference: List[str],
|
| 149 |
+
detected: List[str],
|
| 150 |
+
match_threshold: float = 0.7
|
| 151 |
+
) -> List[Tuple[Optional[str], Optional[str], str]]:
|
| 152 |
+
"""Align two sequences dengan dynamic programming"""
|
| 153 |
+
m, n = len(reference), len(detected)
|
| 154 |
+
|
| 155 |
+
dp = [[None for _ in range(n + 1)] for _ in range(m + 1)]
|
| 156 |
+
|
| 157 |
+
MATCH_SCORE = 2
|
| 158 |
+
MISMATCH_PENALTY = -1
|
| 159 |
+
GAP_PENALTY = -1
|
| 160 |
+
|
| 161 |
+
for i in range(m + 1):
|
| 162 |
+
dp[i][0] = (i * GAP_PENALTY, 'up')
|
| 163 |
+
for j in range(n + 1):
|
| 164 |
+
dp[0][j] = (j * GAP_PENALTY, 'left')
|
| 165 |
+
dp[0][0] = (0, 'done')
|
| 166 |
+
|
| 167 |
+
for i in range(1, m + 1):
|
| 168 |
+
for j in range(1, n + 1):
|
| 169 |
+
ref_word = reference[i-1]
|
| 170 |
+
det_word = detected[j-1]
|
| 171 |
+
|
| 172 |
+
similarity = SequenceAligner.calculate_similarity(ref_word, det_word)
|
| 173 |
+
|
| 174 |
+
if similarity >= match_threshold:
|
| 175 |
+
match_score = MATCH_SCORE
|
| 176 |
+
else:
|
| 177 |
+
match_score = MISMATCH_PENALTY
|
| 178 |
+
|
| 179 |
+
diagonal = dp[i-1][j-1][0] + match_score
|
| 180 |
+
up = dp[i-1][j][0] + GAP_PENALTY
|
| 181 |
+
left = dp[i][j-1][0] + GAP_PENALTY
|
| 182 |
+
|
| 183 |
+
max_score = max(diagonal, up, left)
|
| 184 |
+
|
| 185 |
+
if max_score == diagonal:
|
| 186 |
+
dp[i][j] = (max_score, 'diagonal')
|
| 187 |
+
elif max_score == up:
|
| 188 |
+
dp[i][j] = (max_score, 'up')
|
| 189 |
+
else:
|
| 190 |
+
dp[i][j] = (max_score, 'left')
|
| 191 |
+
|
| 192 |
+
alignment = []
|
| 193 |
+
i, j = m, n
|
| 194 |
+
|
| 195 |
+
while i > 0 or j > 0:
|
| 196 |
+
if dp[i][j][1] == 'diagonal':
|
| 197 |
+
ref_word = reference[i-1]
|
| 198 |
+
det_word = detected[j-1]
|
| 199 |
+
similarity = SequenceAligner.calculate_similarity(ref_word, det_word)
|
| 200 |
+
|
| 201 |
+
if similarity >= match_threshold:
|
| 202 |
+
match_type = "match"
|
| 203 |
+
else:
|
| 204 |
+
match_type = "substitution"
|
| 205 |
+
|
| 206 |
+
alignment.append((ref_word, det_word, match_type))
|
| 207 |
+
i -= 1
|
| 208 |
+
j -= 1
|
| 209 |
+
elif dp[i][j][1] == 'up':
|
| 210 |
+
alignment.append((reference[i-1], None, "deletion"))
|
| 211 |
+
i -= 1
|
| 212 |
+
else:
|
| 213 |
+
alignment.append((None, detected[j-1], "insertion"))
|
| 214 |
+
j -= 1
|
| 215 |
+
|
| 216 |
+
alignment.reverse()
|
| 217 |
+
return alignment
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
class ArticulationService:
|
| 221 |
+
"""Articulation assessment service"""
|
| 222 |
+
|
| 223 |
+
def __init__(self):
|
| 224 |
+
"""Initialize service"""
|
| 225 |
+
print("🗣️ Initializing Articulation Service")
|
| 226 |
+
self.filler_detector = FillerWordsDetector()
|
| 227 |
+
self.aligner = SequenceAligner()
|
| 228 |
+
print("✅ Articulation Service ready!\n")
|
| 229 |
+
|
| 230 |
+
def normalize_text(self, text: str) -> str:
|
| 231 |
+
"""Normalize text for comparison"""
|
| 232 |
+
text = text.lower()
|
| 233 |
+
text = re.sub(r'[,\.!?;:]+', ' ', text)
|
| 234 |
+
text = re.sub(r'\s+', ' ', text)
|
| 235 |
+
return text.strip()
|
| 236 |
+
|
| 237 |
+
def tokenize_words(self, text: str) -> List[str]:
|
| 238 |
+
"""Split text into words"""
|
| 239 |
+
text = self.normalize_text(text)
|
| 240 |
+
words = [w for w in text.split() if w]
|
| 241 |
+
return words
|
| 242 |
+
|
| 243 |
+
def analyze(self, transcribed_text: str, reference_text: str) -> Dict:
|
| 244 |
+
"""
|
| 245 |
+
Analisis artikulasi
|
| 246 |
+
|
| 247 |
+
Args:
|
| 248 |
+
transcribed_text: Teks hasil transcription
|
| 249 |
+
reference_text: Teks referensi
|
| 250 |
+
|
| 251 |
+
Returns:
|
| 252 |
+
Dict berisi hasil analisis
|
| 253 |
+
"""
|
| 254 |
+
print(f"🗣️ Analyzing articulation...")
|
| 255 |
+
|
| 256 |
+
# Tokenize
|
| 257 |
+
reference_words = self.tokenize_words(reference_text)
|
| 258 |
+
detected_words = self.tokenize_words(transcribed_text)
|
| 259 |
+
|
| 260 |
+
# Detect fillers
|
| 261 |
+
filler_count, filler_list = self.filler_detector.count_fillers(transcribed_text)
|
| 262 |
+
|
| 263 |
+
# Alignment
|
| 264 |
+
alignment = self.aligner.align_sequences(
|
| 265 |
+
reference_words,
|
| 266 |
+
detected_words,
|
| 267 |
+
match_threshold=0.7
|
| 268 |
+
)
|
| 269 |
+
|
| 270 |
+
# Convert to word scores
|
| 271 |
+
word_scores = []
|
| 272 |
+
correct_words = 0
|
| 273 |
+
|
| 274 |
+
for idx, (ref_word, det_word, match_type) in enumerate(alignment):
|
| 275 |
+
is_filler = False
|
| 276 |
+
if det_word and self.filler_detector.is_filler(det_word):
|
| 277 |
+
is_filler = True
|
| 278 |
+
|
| 279 |
+
if match_type == "match":
|
| 280 |
+
is_correct = True
|
| 281 |
+
similarity = self.aligner.calculate_similarity(ref_word or "", det_word or "")
|
| 282 |
+
if not is_filler:
|
| 283 |
+
correct_words += 1
|
| 284 |
+
else:
|
| 285 |
+
is_correct = False
|
| 286 |
+
similarity = self.aligner.calculate_similarity(ref_word or "", det_word or "") if ref_word and det_word else 0.0
|
| 287 |
+
|
| 288 |
+
word_score = WordScore(
|
| 289 |
+
index=idx,
|
| 290 |
+
expected=ref_word or "[INSERTION]",
|
| 291 |
+
detected=det_word or "[DELETION]",
|
| 292 |
+
is_correct=is_correct,
|
| 293 |
+
similarity=similarity,
|
| 294 |
+
is_filler=is_filler,
|
| 295 |
+
match_type=match_type
|
| 296 |
+
)
|
| 297 |
+
|
| 298 |
+
word_scores.append(word_score)
|
| 299 |
+
|
| 300 |
+
# Calculate metrics
|
| 301 |
+
total_words = len(reference_words)
|
| 302 |
+
accuracy_percentage = (correct_words / total_words * 100) if total_words > 0 else 0
|
| 303 |
+
|
| 304 |
+
# Determine category
|
| 305 |
+
if accuracy_percentage >= 81:
|
| 306 |
+
category = "Sangat Baik"
|
| 307 |
+
points = 5
|
| 308 |
+
elif accuracy_percentage >= 61:
|
| 309 |
+
category = "Baik"
|
| 310 |
+
points = 4
|
| 311 |
+
elif accuracy_percentage >= 41:
|
| 312 |
+
category = "Cukup"
|
| 313 |
+
points = 3
|
| 314 |
+
elif accuracy_percentage >= 21:
|
| 315 |
+
category = "Buruk"
|
| 316 |
+
points = 2
|
| 317 |
+
else:
|
| 318 |
+
category = "Perlu Ditingkatkan"
|
| 319 |
+
points = 1
|
| 320 |
+
|
| 321 |
+
print(f"✅ Articulation analysis complete!\n")
|
| 322 |
+
|
| 323 |
+
return {
|
| 324 |
+
'score': points,
|
| 325 |
+
'category': category,
|
| 326 |
+
'accuracy_percentage': round(accuracy_percentage, 1),
|
| 327 |
+
'correct_words': correct_words,
|
| 328 |
+
'total_words': total_words,
|
| 329 |
+
'filler_count': filler_count,
|
| 330 |
+
'filler_words': list(set(filler_list))[:10],
|
| 331 |
+
# 'word_scores': [asdict(ws) for ws in word_scores[:50]] # Limit to first 50 words
|
| 332 |
+
}
|
app/services/audio_processor.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Audio Processor - Main Orchestrator
|
| 3 |
+
Koordinasi semua analisis audio
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import time
|
| 7 |
+
from typing import Dict, Optional, List
|
| 8 |
+
from app.config import settings
|
| 9 |
+
from app.services.speech_to_text import SpeechToTextService
|
| 10 |
+
from app.services.tempo import TempoService
|
| 11 |
+
from app.services.articulation import ArticulationService, ProfanityDetector
|
| 12 |
+
from app.services.structure import StructureService
|
| 13 |
+
from app.services.keywords import KeywordService
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class AudioProcessor:
|
| 17 |
+
"""Main orchestrator untuk audio analysis"""
|
| 18 |
+
|
| 19 |
+
def __init__(self):
|
| 20 |
+
"""Initialize all services"""
|
| 21 |
+
print("🚀 Initializing Audio Processor...")
|
| 22 |
+
|
| 23 |
+
# Initialize services (lazy loading)
|
| 24 |
+
self._stt_service = None
|
| 25 |
+
self._tempo_service = None
|
| 26 |
+
self._articulation_service = None
|
| 27 |
+
self._structure_service = None
|
| 28 |
+
self._keyword_service = None
|
| 29 |
+
|
| 30 |
+
print("✅ Audio Processor ready!\n")
|
| 31 |
+
|
| 32 |
+
@property
|
| 33 |
+
def stt_service(self):
|
| 34 |
+
"""Lazy load STT service"""
|
| 35 |
+
if self._stt_service is None:
|
| 36 |
+
self._stt_service = SpeechToTextService(
|
| 37 |
+
model_name=settings.WHISPER_MODEL,
|
| 38 |
+
device="auto", # Auto-detect GPU/CPU
|
| 39 |
+
language="id"
|
| 40 |
+
)
|
| 41 |
+
return self._stt_service
|
| 42 |
+
|
| 43 |
+
@property
|
| 44 |
+
def tempo_service(self):
|
| 45 |
+
"""Lazy load Tempo service"""
|
| 46 |
+
if self._tempo_service is None:
|
| 47 |
+
self._tempo_service = TempoService()
|
| 48 |
+
return self._tempo_service
|
| 49 |
+
|
| 50 |
+
@property
|
| 51 |
+
def articulation_service(self):
|
| 52 |
+
"""Lazy load Articulation service"""
|
| 53 |
+
if self._articulation_service is None:
|
| 54 |
+
self._articulation_service = ArticulationService()
|
| 55 |
+
return self._articulation_service
|
| 56 |
+
|
| 57 |
+
@property
|
| 58 |
+
def structure_service(self):
|
| 59 |
+
"""Lazy load Structure service"""
|
| 60 |
+
if self._structure_service is None:
|
| 61 |
+
# Uses default 'Cyberlace/swara-structure-model' from HF Hub
|
| 62 |
+
self._structure_service = StructureService()
|
| 63 |
+
return self._structure_service
|
| 64 |
+
|
| 65 |
+
@property
|
| 66 |
+
def keyword_service(self):
|
| 67 |
+
"""Lazy load Keyword service"""
|
| 68 |
+
if self._keyword_service is None:
|
| 69 |
+
self._keyword_service = KeywordService(
|
| 70 |
+
dataset_path=settings.KATA_KUNCI_PATH
|
| 71 |
+
)
|
| 72 |
+
return self._keyword_service
|
| 73 |
+
|
| 74 |
+
def process_audio(
|
| 75 |
+
self,
|
| 76 |
+
audio_path: str,
|
| 77 |
+
reference_text: Optional[str] = None,
|
| 78 |
+
topic_id: Optional[str] = None,
|
| 79 |
+
custom_topic: Optional[str] = None,
|
| 80 |
+
custom_keywords: Optional[List[str]] = None,
|
| 81 |
+
analyze_tempo: bool = True,
|
| 82 |
+
analyze_articulation: bool = True,
|
| 83 |
+
analyze_structure: bool = True,
|
| 84 |
+
analyze_keywords: bool = False,
|
| 85 |
+
analyze_profanity: bool = False
|
| 86 |
+
) -> Dict:
|
| 87 |
+
"""
|
| 88 |
+
Process audio file dengan semua analisis yang diminta
|
| 89 |
+
|
| 90 |
+
Args:
|
| 91 |
+
audio_path: Path ke file audio
|
| 92 |
+
reference_text: Teks referensi (untuk artikulasi)
|
| 93 |
+
topic_id: ID topik dari database (untuk Level 1-2)
|
| 94 |
+
custom_topic: Topik custom dari user (untuk Level 3)
|
| 95 |
+
custom_keywords: List kata kunci dari GPT (untuk Level 3)
|
| 96 |
+
analyze_tempo: Flag untuk analisis tempo
|
| 97 |
+
analyze_articulation: Flag untuk analisis artikulasi
|
| 98 |
+
analyze_structure: Flag untuk analisis struktur
|
| 99 |
+
analyze_keywords: Flag untuk analisis kata kunci
|
| 100 |
+
analyze_profanity: Flag untuk deteksi kata tidak senonoh
|
| 101 |
+
|
| 102 |
+
Returns:
|
| 103 |
+
Dict berisi semua hasil analisis
|
| 104 |
+
"""
|
| 105 |
+
start_time = time.time()
|
| 106 |
+
|
| 107 |
+
print("="*70)
|
| 108 |
+
print("🎯 STARTING AUDIO ANALYSIS")
|
| 109 |
+
print("="*70)
|
| 110 |
+
print(f"📁 Audio file: {audio_path}")
|
| 111 |
+
print(f"⚙️ Tempo: {analyze_tempo}")
|
| 112 |
+
print(f"⚙️ Articulation: {analyze_articulation}")
|
| 113 |
+
print(f"⚙️ Structure: {analyze_structure}")
|
| 114 |
+
print(f"⚙️ Keywords: {analyze_keywords}")
|
| 115 |
+
print(f"⚙️ Profanity: {analyze_profanity}")
|
| 116 |
+
print("="*70 + "\n")
|
| 117 |
+
|
| 118 |
+
results = {}
|
| 119 |
+
|
| 120 |
+
# 1. Speech to Text (always required)
|
| 121 |
+
print("📝 Step 1/6: Transcribing audio...")
|
| 122 |
+
transcript_result = self.stt_service.transcribe(audio_path)
|
| 123 |
+
transcript = transcript_result['text']
|
| 124 |
+
results['transcript'] = transcript
|
| 125 |
+
print(f"✅ Transcript: {transcript[:100]}...\n")
|
| 126 |
+
|
| 127 |
+
# 2. Tempo Analysis
|
| 128 |
+
if analyze_tempo:
|
| 129 |
+
print("🎵 Step 2/6: Analyzing tempo...")
|
| 130 |
+
results['tempo'] = self.tempo_service.analyze(audio_path, transcript)
|
| 131 |
+
print(f"✅ Tempo score: {results['tempo']['score']}/5\n")
|
| 132 |
+
|
| 133 |
+
# 3. Articulation Analysis
|
| 134 |
+
if analyze_articulation and reference_text:
|
| 135 |
+
print("🗣️ Step 3/6: Analyzing articulation...")
|
| 136 |
+
results['articulation'] = self.articulation_service.analyze(
|
| 137 |
+
transcribed_text=transcript,
|
| 138 |
+
reference_text=reference_text
|
| 139 |
+
)
|
| 140 |
+
print(f"✅ Articulation score: {results['articulation']['score']}/5\n")
|
| 141 |
+
elif analyze_articulation:
|
| 142 |
+
print("⚠️ Step 3/6: Skipping articulation (no reference text)\n")
|
| 143 |
+
|
| 144 |
+
# 4. Structure Analysis
|
| 145 |
+
if analyze_structure:
|
| 146 |
+
print("📊 Step 4/6: Analyzing structure...")
|
| 147 |
+
results['structure'] = self.structure_service.analyze(transcript)
|
| 148 |
+
print(f"✅ Structure score: {results['structure']['score']}/5\n")
|
| 149 |
+
|
| 150 |
+
# 5. Keyword Analysis
|
| 151 |
+
if analyze_keywords:
|
| 152 |
+
print("🔍 Step 5/6: Analyzing keywords...")
|
| 153 |
+
|
| 154 |
+
# Custom keywords (Level 3 - dari GPT)
|
| 155 |
+
if custom_topic and custom_keywords:
|
| 156 |
+
results['keywords'] = self.keyword_service.analyze(
|
| 157 |
+
speech_text=transcript,
|
| 158 |
+
custom_topic=custom_topic,
|
| 159 |
+
custom_keywords=custom_keywords
|
| 160 |
+
)
|
| 161 |
+
# Predefined topic (Level 1-2 - dari database)
|
| 162 |
+
elif topic_id:
|
| 163 |
+
results['keywords'] = self.keyword_service.analyze(
|
| 164 |
+
speech_text=transcript,
|
| 165 |
+
topic_id=topic_id
|
| 166 |
+
)
|
| 167 |
+
else:
|
| 168 |
+
print("⚠️ Step 5/6: Skipping keywords (no topic_id or custom_keywords)\n")
|
| 169 |
+
|
| 170 |
+
if 'keywords' in results:
|
| 171 |
+
print(f"✅ Keyword score: {results['keywords']['score']}/5\n")
|
| 172 |
+
elif analyze_keywords:
|
| 173 |
+
print("⚠️ Step 5/6: Keywords analysis disabled\n")
|
| 174 |
+
|
| 175 |
+
# 6. Profanity Detection
|
| 176 |
+
if analyze_profanity:
|
| 177 |
+
print("🚫 Step 6/6: Detecting profanity...")
|
| 178 |
+
results['profanity'] = ProfanityDetector.detect_profanity(transcript)
|
| 179 |
+
status = "DETECTED" if results['profanity']['has_profanity'] else "CLEAN"
|
| 180 |
+
print(f"✅ Profanity check: {status} ({results['profanity']['profanity_count']} words)\n")
|
| 181 |
+
|
| 182 |
+
# Calculate overall score
|
| 183 |
+
scores = []
|
| 184 |
+
if 'tempo' in results:
|
| 185 |
+
scores.append(results['tempo']['score'])
|
| 186 |
+
if 'articulation' in results:
|
| 187 |
+
scores.append(results['articulation']['score'])
|
| 188 |
+
if 'structure' in results:
|
| 189 |
+
scores.append(results['structure']['score'])
|
| 190 |
+
if 'keywords' in results:
|
| 191 |
+
scores.append(results['keywords']['score'])
|
| 192 |
+
|
| 193 |
+
if scores:
|
| 194 |
+
results['overall_score'] = round(sum(scores) / len(scores), 2)
|
| 195 |
+
else:
|
| 196 |
+
results['overall_score'] = 0
|
| 197 |
+
|
| 198 |
+
processing_time = time.time() - start_time
|
| 199 |
+
results['processing_time'] = round(processing_time, 2)
|
| 200 |
+
|
| 201 |
+
print("="*70)
|
| 202 |
+
print(f"✅ ANALYSIS COMPLETE")
|
| 203 |
+
print(f"⏱️ Processing time: {processing_time:.2f}s")
|
| 204 |
+
print(f"📊 Overall score: {results['overall_score']}/5")
|
| 205 |
+
print("="*70 + "\n")
|
| 206 |
+
|
| 207 |
+
return results
|
app/services/keywords.py
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Keyword Relevance Service
|
| 3 |
+
Analisis relevansi kata kunci dengan topik menggunakan BERT embeddings
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import json
|
| 7 |
+
import re
|
| 8 |
+
import numpy as np
|
| 9 |
+
from typing import Dict, List, Tuple
|
| 10 |
+
from collections import defaultdict
|
| 11 |
+
|
| 12 |
+
try:
|
| 13 |
+
from sentence_transformers import SentenceTransformer
|
| 14 |
+
from sklearn.metrics.pairwise import cosine_similarity
|
| 15 |
+
from app.core.device import get_device
|
| 16 |
+
EMBEDDINGS_AVAILABLE = True
|
| 17 |
+
except ImportError:
|
| 18 |
+
EMBEDDINGS_AVAILABLE = False
|
| 19 |
+
print("⚠️ Warning: sentence-transformers not installed. Using fallback mode.")
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class KeywordService:
|
| 23 |
+
"""Analisis relevansi kata kunci"""
|
| 24 |
+
|
| 25 |
+
def __init__(self, dataset_path: str, model_name: str = 'paraphrase-multilingual-MiniLM-L12-v2'):
|
| 26 |
+
"""
|
| 27 |
+
Initialize analyzer
|
| 28 |
+
|
| 29 |
+
Args:
|
| 30 |
+
dataset_path: Path ke file JSON dataset kata kunci
|
| 31 |
+
model_name: Nama model Sentence Transformer
|
| 32 |
+
"""
|
| 33 |
+
print("🔍 Initializing Keyword Service...")
|
| 34 |
+
|
| 35 |
+
self.dataset_path = dataset_path
|
| 36 |
+
self.topics = {}
|
| 37 |
+
|
| 38 |
+
# Load dataset
|
| 39 |
+
self.load_dataset(dataset_path)
|
| 40 |
+
|
| 41 |
+
# Load BERT model
|
| 42 |
+
if EMBEDDINGS_AVAILABLE:
|
| 43 |
+
print(f"📦 Loading BERT model: {model_name}...")
|
| 44 |
+
device = get_device()
|
| 45 |
+
self.model = SentenceTransformer(model_name, device=device)
|
| 46 |
+
print("✅ Model loaded!")
|
| 47 |
+
else:
|
| 48 |
+
self.model = None
|
| 49 |
+
print("⚠️ Running in fallback mode (no embeddings)")
|
| 50 |
+
|
| 51 |
+
# Precompute embeddings
|
| 52 |
+
self.keyword_embeddings = {}
|
| 53 |
+
if self.model:
|
| 54 |
+
self._precompute_embeddings()
|
| 55 |
+
|
| 56 |
+
print("✅ Keyword Service ready!\n")
|
| 57 |
+
|
| 58 |
+
def load_dataset(self, json_path: str):
|
| 59 |
+
"""Load dataset dari file JSON"""
|
| 60 |
+
try:
|
| 61 |
+
with open(json_path, 'r', encoding='utf-8') as f:
|
| 62 |
+
self.topics = json.load(f)
|
| 63 |
+
print(f"✅ Dataset loaded: {len(self.topics)} topics")
|
| 64 |
+
except FileNotFoundError:
|
| 65 |
+
raise FileNotFoundError(f"❌ Dataset file not found: {json_path}")
|
| 66 |
+
except json.JSONDecodeError as e:
|
| 67 |
+
raise ValueError(f"❌ Invalid JSON format: {e}")
|
| 68 |
+
|
| 69 |
+
def _precompute_embeddings(self):
|
| 70 |
+
"""Precompute embeddings untuk semua keywords"""
|
| 71 |
+
print("🔄 Precomputing embeddings...")
|
| 72 |
+
|
| 73 |
+
for topic_id, topic_data in self.topics.items():
|
| 74 |
+
self.keyword_embeddings[topic_id] = {}
|
| 75 |
+
|
| 76 |
+
# Embed keywords
|
| 77 |
+
keywords = topic_data['keywords']
|
| 78 |
+
self.keyword_embeddings[topic_id]['keywords'] = self.model.encode(keywords)
|
| 79 |
+
|
| 80 |
+
# Embed variants
|
| 81 |
+
all_variants = []
|
| 82 |
+
variant_mapping = []
|
| 83 |
+
for keyword in keywords:
|
| 84 |
+
variants = topic_data['variants'].get(keyword, [])
|
| 85 |
+
for variant in variants:
|
| 86 |
+
all_variants.append(variant)
|
| 87 |
+
variant_mapping.append(keyword)
|
| 88 |
+
|
| 89 |
+
if all_variants:
|
| 90 |
+
self.keyword_embeddings[topic_id]['variants'] = {
|
| 91 |
+
'embeddings': self.model.encode(all_variants),
|
| 92 |
+
'mapping': variant_mapping,
|
| 93 |
+
'texts': all_variants
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
print("✅ Embeddings ready!")
|
| 97 |
+
|
| 98 |
+
def extract_sentences(self, text: str) -> List[str]:
|
| 99 |
+
"""Extract sentences dari text"""
|
| 100 |
+
sentences = re.split(r'[.!?]+', text)
|
| 101 |
+
sentences = [s.strip() for s in sentences if s.strip()]
|
| 102 |
+
return sentences
|
| 103 |
+
|
| 104 |
+
def semantic_keyword_detection(self, text: str, topic_id: str, threshold: float = 0.5) -> Dict:
|
| 105 |
+
"""Deteksi keyword menggunakan semantic similarity"""
|
| 106 |
+
if not self.model or topic_id not in self.keyword_embeddings:
|
| 107 |
+
return self._fallback_detection(text, topic_id)
|
| 108 |
+
|
| 109 |
+
sentences = self.extract_sentences(text)
|
| 110 |
+
sentence_embeddings = self.model.encode(sentences)
|
| 111 |
+
|
| 112 |
+
topic_data = self.topics[topic_id]
|
| 113 |
+
keyword_embs = self.keyword_embeddings[topic_id]
|
| 114 |
+
|
| 115 |
+
detected_keywords = defaultdict(list)
|
| 116 |
+
|
| 117 |
+
# Direct keyword matching
|
| 118 |
+
keyword_similarities = cosine_similarity(
|
| 119 |
+
sentence_embeddings,
|
| 120 |
+
keyword_embs['keywords']
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
for sent_idx, sentence in enumerate(sentences):
|
| 124 |
+
for kw_idx, keyword in enumerate(topic_data['keywords']):
|
| 125 |
+
similarity = keyword_similarities[sent_idx][kw_idx]
|
| 126 |
+
|
| 127 |
+
if similarity >= threshold:
|
| 128 |
+
detected_keywords[keyword].append({
|
| 129 |
+
'type': 'semantic',
|
| 130 |
+
'sentence': sentence,
|
| 131 |
+
'similarity': float(similarity)
|
| 132 |
+
})
|
| 133 |
+
|
| 134 |
+
# Variant matching
|
| 135 |
+
if 'variants' in keyword_embs:
|
| 136 |
+
variant_similarities = cosine_similarity(
|
| 137 |
+
sentence_embeddings,
|
| 138 |
+
keyword_embs['variants']['embeddings']
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
for sent_idx, sentence in enumerate(sentences):
|
| 142 |
+
for var_idx, (variant, mapped_kw) in enumerate(
|
| 143 |
+
zip(keyword_embs['variants']['texts'],
|
| 144 |
+
keyword_embs['variants']['mapping'])
|
| 145 |
+
):
|
| 146 |
+
similarity = variant_similarities[sent_idx][var_idx]
|
| 147 |
+
|
| 148 |
+
if similarity >= threshold:
|
| 149 |
+
if not any(d['type'] == 'variant' and d.get('variant') == variant
|
| 150 |
+
for d in detected_keywords[mapped_kw]):
|
| 151 |
+
detected_keywords[mapped_kw].append({
|
| 152 |
+
'type': 'variant',
|
| 153 |
+
'variant': variant,
|
| 154 |
+
'sentence': sentence,
|
| 155 |
+
'similarity': float(similarity)
|
| 156 |
+
})
|
| 157 |
+
|
| 158 |
+
# Exact string matching
|
| 159 |
+
text_lower = text.lower()
|
| 160 |
+
for keyword in topic_data['keywords']:
|
| 161 |
+
if keyword in text_lower:
|
| 162 |
+
if not any(d['type'] == 'exact' for d in detected_keywords[keyword]):
|
| 163 |
+
detected_keywords[keyword].insert(0, {
|
| 164 |
+
'type': 'exact',
|
| 165 |
+
'keyword': keyword,
|
| 166 |
+
'similarity': 1.0
|
| 167 |
+
})
|
| 168 |
+
|
| 169 |
+
# Check variants
|
| 170 |
+
for variant in topic_data['variants'].get(keyword, []):
|
| 171 |
+
if variant.lower() in text_lower:
|
| 172 |
+
if not any(d['type'] == 'exact_variant' and d.get('variant') == variant
|
| 173 |
+
for d in detected_keywords[keyword]):
|
| 174 |
+
detected_keywords[keyword].insert(0, {
|
| 175 |
+
'type': 'exact_variant',
|
| 176 |
+
'variant': variant,
|
| 177 |
+
'similarity': 1.0
|
| 178 |
+
})
|
| 179 |
+
|
| 180 |
+
return dict(detected_keywords)
|
| 181 |
+
|
| 182 |
+
def _fallback_detection(self, text: str, topic_id: str) -> Dict:
|
| 183 |
+
"""Fallback method tanpa embeddings"""
|
| 184 |
+
text_lower = text.lower()
|
| 185 |
+
topic_data = self.topics[topic_id]
|
| 186 |
+
detected_keywords = {}
|
| 187 |
+
|
| 188 |
+
for keyword in topic_data['keywords']:
|
| 189 |
+
detections = []
|
| 190 |
+
|
| 191 |
+
if keyword in text_lower:
|
| 192 |
+
detections.append({
|
| 193 |
+
'type': 'exact',
|
| 194 |
+
'keyword': keyword,
|
| 195 |
+
'similarity': 1.0
|
| 196 |
+
})
|
| 197 |
+
|
| 198 |
+
for variant in topic_data['variants'].get(keyword, []):
|
| 199 |
+
if variant.lower() in text_lower:
|
| 200 |
+
detections.append({
|
| 201 |
+
'type': 'variant',
|
| 202 |
+
'variant': variant,
|
| 203 |
+
'similarity': 0.9
|
| 204 |
+
})
|
| 205 |
+
|
| 206 |
+
if detections:
|
| 207 |
+
detected_keywords[keyword] = detections
|
| 208 |
+
|
| 209 |
+
return detected_keywords
|
| 210 |
+
|
| 211 |
+
def calculate_score(self, detected_count: int) -> Dict:
|
| 212 |
+
"""Calculate skor berdasarkan jumlah keyword terdeteksi"""
|
| 213 |
+
if detected_count >= 9:
|
| 214 |
+
return {
|
| 215 |
+
'score': 5,
|
| 216 |
+
'category': 'Sangat Baik',
|
| 217 |
+
'description': 'Coverage keyword sangat lengkap'
|
| 218 |
+
}
|
| 219 |
+
elif detected_count >= 7:
|
| 220 |
+
return {
|
| 221 |
+
'score': 4,
|
| 222 |
+
'category': 'Baik',
|
| 223 |
+
'description': 'Coverage keyword baik'
|
| 224 |
+
}
|
| 225 |
+
elif detected_count >= 5:
|
| 226 |
+
return {
|
| 227 |
+
'score': 3,
|
| 228 |
+
'category': 'Cukup',
|
| 229 |
+
'description': 'Coverage keyword cukup'
|
| 230 |
+
}
|
| 231 |
+
elif detected_count >= 3:
|
| 232 |
+
return {
|
| 233 |
+
'score': 2,
|
| 234 |
+
'category': 'Buruk',
|
| 235 |
+
'description': 'Coverage keyword kurang'
|
| 236 |
+
}
|
| 237 |
+
else:
|
| 238 |
+
return {
|
| 239 |
+
'score': 1,
|
| 240 |
+
'category': 'Perlu Ditingkatkan',
|
| 241 |
+
'description': 'Coverage keyword sangat rendah'
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
def analyze(
|
| 245 |
+
self,
|
| 246 |
+
speech_text: str,
|
| 247 |
+
topic_id: str = None,
|
| 248 |
+
custom_topic: str = None,
|
| 249 |
+
custom_keywords: List[str] = None,
|
| 250 |
+
threshold: float = 0.5
|
| 251 |
+
) -> Dict:
|
| 252 |
+
"""
|
| 253 |
+
Analisis relevansi speech dengan topik
|
| 254 |
+
|
| 255 |
+
Args:
|
| 256 |
+
speech_text: Teks speech hasil transcription
|
| 257 |
+
topic_id: ID topik dari database (untuk level 1-2)
|
| 258 |
+
custom_topic: Topik custom dari user (untuk level 3)
|
| 259 |
+
custom_keywords: List kata kunci dari GPT (untuk level 3)
|
| 260 |
+
threshold: Similarity threshold
|
| 261 |
+
|
| 262 |
+
Returns:
|
| 263 |
+
Dict berisi hasil analisis
|
| 264 |
+
"""
|
| 265 |
+
# Mode 1: Custom topic & keywords (Level 3 - dari GPT)
|
| 266 |
+
if custom_topic and custom_keywords:
|
| 267 |
+
print(f"🔍 Analyzing custom keywords for topic: {custom_topic}...")
|
| 268 |
+
return self._analyze_custom_keywords(
|
| 269 |
+
speech_text,
|
| 270 |
+
custom_topic,
|
| 271 |
+
custom_keywords,
|
| 272 |
+
threshold
|
| 273 |
+
)
|
| 274 |
+
|
| 275 |
+
# Mode 2: Predefined topic (Level 1-2 - dari database)
|
| 276 |
+
if topic_id:
|
| 277 |
+
print(f"🔍 Analyzing keywords for topic {topic_id}...")
|
| 278 |
+
|
| 279 |
+
if topic_id not in self.topics:
|
| 280 |
+
return {"error": f"Topik '{topic_id}' tidak ditemukan"}
|
| 281 |
+
|
| 282 |
+
topic_data = self.topics[topic_id]
|
| 283 |
+
|
| 284 |
+
# Deteksi keywords
|
| 285 |
+
detected_keywords = self.semantic_keyword_detection(
|
| 286 |
+
speech_text, topic_id, threshold
|
| 287 |
+
)
|
| 288 |
+
|
| 289 |
+
missing_keywords = [
|
| 290 |
+
kw for kw in topic_data['keywords']
|
| 291 |
+
if kw not in detected_keywords
|
| 292 |
+
]
|
| 293 |
+
|
| 294 |
+
# Calculate scores
|
| 295 |
+
total_keywords = len(topic_data['keywords'])
|
| 296 |
+
detected_count = len(detected_keywords)
|
| 297 |
+
coverage_percentage = (detected_count / total_keywords) * 100
|
| 298 |
+
|
| 299 |
+
score_result = self.calculate_score(detected_count)
|
| 300 |
+
|
| 301 |
+
print(f"✅ Keyword analysis complete!\n")
|
| 302 |
+
|
| 303 |
+
return {
|
| 304 |
+
'score': score_result['score'],
|
| 305 |
+
'category': score_result['category'],
|
| 306 |
+
'description': score_result['description'],
|
| 307 |
+
'topic_id': topic_id,
|
| 308 |
+
'topic_title': topic_data['title'],
|
| 309 |
+
'detected_count': detected_count,
|
| 310 |
+
'total_keywords': total_keywords,
|
| 311 |
+
'coverage_percentage': round(coverage_percentage, 1),
|
| 312 |
+
'detected_keywords': list(detected_keywords.keys()),
|
| 313 |
+
'missing_keywords': missing_keywords
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
# Mode 3: Error - tidak ada input
|
| 317 |
+
return {"error": "Harus menyediakan topic_id ATAU (custom_topic + custom_keywords)"}
|
| 318 |
+
|
| 319 |
+
def _analyze_custom_keywords(
|
| 320 |
+
self,
|
| 321 |
+
speech_text: str,
|
| 322 |
+
custom_topic: str,
|
| 323 |
+
custom_keywords: List[str],
|
| 324 |
+
threshold: float = 0.5
|
| 325 |
+
) -> Dict:
|
| 326 |
+
"""
|
| 327 |
+
Analisis dengan custom keywords dari GPT (untuk Level 3)
|
| 328 |
+
|
| 329 |
+
Menghitung berapa kali setiap keyword disebutkan dalam speech
|
| 330 |
+
"""
|
| 331 |
+
speech_lower = speech_text.lower()
|
| 332 |
+
|
| 333 |
+
# Hitung kemunculan setiap keyword
|
| 334 |
+
keyword_mentions = {}
|
| 335 |
+
total_mentions = 0
|
| 336 |
+
|
| 337 |
+
for keyword in custom_keywords:
|
| 338 |
+
keyword_lower = keyword.lower()
|
| 339 |
+
|
| 340 |
+
# Count exact matches (case-insensitive)
|
| 341 |
+
count = speech_lower.count(keyword_lower)
|
| 342 |
+
|
| 343 |
+
if count > 0:
|
| 344 |
+
keyword_mentions[keyword] = {
|
| 345 |
+
'count': count,
|
| 346 |
+
'mentioned': True
|
| 347 |
+
}
|
| 348 |
+
total_mentions += count
|
| 349 |
+
else:
|
| 350 |
+
keyword_mentions[keyword] = {
|
| 351 |
+
'count': 0,
|
| 352 |
+
'mentioned': False
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
# Hitung statistik
|
| 356 |
+
total_keywords = len(custom_keywords)
|
| 357 |
+
mentioned_count = sum(1 for kw in keyword_mentions.values() if kw['mentioned'])
|
| 358 |
+
not_mentioned = [kw for kw, data in keyword_mentions.items() if not data['mentioned']]
|
| 359 |
+
coverage_percentage = (mentioned_count / total_keywords) * 100 if total_keywords > 0 else 0
|
| 360 |
+
|
| 361 |
+
# Calculate score berdasarkan coverage
|
| 362 |
+
score_result = self.calculate_score(mentioned_count)
|
| 363 |
+
|
| 364 |
+
# Semantic analysis (optional - jika ada model)
|
| 365 |
+
semantic_relevance = None
|
| 366 |
+
if self.model:
|
| 367 |
+
try:
|
| 368 |
+
# Encode speech dan keywords
|
| 369 |
+
speech_embedding = self.model.encode([speech_text])
|
| 370 |
+
keywords_text = " ".join(custom_keywords)
|
| 371 |
+
keywords_embedding = self.model.encode([keywords_text])
|
| 372 |
+
|
| 373 |
+
# Calculate cosine similarity
|
| 374 |
+
similarity = cosine_similarity(speech_embedding, keywords_embedding)[0][0]
|
| 375 |
+
semantic_relevance = {
|
| 376 |
+
'similarity_score': round(float(similarity), 3),
|
| 377 |
+
'percentage': round(float(similarity) * 100, 1)
|
| 378 |
+
}
|
| 379 |
+
except Exception as e:
|
| 380 |
+
print(f"⚠️ Semantic analysis failed: {e}")
|
| 381 |
+
|
| 382 |
+
print(f"✅ Custom keyword analysis complete!\n")
|
| 383 |
+
|
| 384 |
+
return {
|
| 385 |
+
'score': score_result['score'],
|
| 386 |
+
'category': score_result['category'],
|
| 387 |
+
'description': score_result['description'],
|
| 388 |
+
'mode': 'custom',
|
| 389 |
+
'custom_topic': custom_topic,
|
| 390 |
+
'total_keywords': total_keywords,
|
| 391 |
+
'mentioned_count': mentioned_count,
|
| 392 |
+
'total_mentions': total_mentions,
|
| 393 |
+
'coverage_percentage': round(coverage_percentage, 1),
|
| 394 |
+
'keyword_details': keyword_mentions,
|
| 395 |
+
'not_mentioned': not_mentioned,
|
| 396 |
+
'semantic_relevance': semantic_relevance
|
| 397 |
+
}
|
app/services/speech_to_text.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Speech to Text Service
|
| 3 |
+
Wrapper untuk Whisper STT
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import whisper
|
| 7 |
+
import torch
|
| 8 |
+
import warnings
|
| 9 |
+
import os
|
| 10 |
+
from typing import Dict
|
| 11 |
+
from app.core.device import get_device, optimize_for_device
|
| 12 |
+
warnings.filterwarnings('ignore')
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class SpeechToTextService:
|
| 16 |
+
"""Speech-to-Text service using Whisper"""
|
| 17 |
+
|
| 18 |
+
def __init__(self, model_name: str = "medium", device: str = None, language: str = "id"):
|
| 19 |
+
"""Initialize Whisper model"""
|
| 20 |
+
print(f"🎙️ Initializing Speech-to-Text service")
|
| 21 |
+
print(f"📦 Loading Whisper model: {model_name}")
|
| 22 |
+
|
| 23 |
+
# Auto-detect device if not specified
|
| 24 |
+
if device is None or device == "auto":
|
| 25 |
+
self.device = get_device()
|
| 26 |
+
optimize_for_device(self.device)
|
| 27 |
+
else:
|
| 28 |
+
self.device = device
|
| 29 |
+
print(f"💻 Using device: {self.device}")
|
| 30 |
+
|
| 31 |
+
# Check if model is already cached
|
| 32 |
+
cache_dir = os.environ.get('XDG_CACHE_HOME', '/.cache')
|
| 33 |
+
model_cache_path = os.path.join(cache_dir, f'{model_name}.pt')
|
| 34 |
+
|
| 35 |
+
# Load Whisper model
|
| 36 |
+
try:
|
| 37 |
+
if os.path.exists(model_cache_path):
|
| 38 |
+
print(f"✅ Loading from cache (pre-downloaded during build)")
|
| 39 |
+
else:
|
| 40 |
+
print(f"📥 Model not in cache, downloading '{model_name}'...")
|
| 41 |
+
print(f" This may take 1-2 minutes...")
|
| 42 |
+
|
| 43 |
+
self.model = whisper.load_model(model_name, device=self.device, download_root=cache_dir)
|
| 44 |
+
print("✅ Whisper model ready!\n")
|
| 45 |
+
except Exception as e:
|
| 46 |
+
print(f"❌ Failed to load model '{model_name}': {e}")
|
| 47 |
+
print("⚙️ Falling back to 'base' model...")
|
| 48 |
+
|
| 49 |
+
base_cache_path = os.path.join(cache_dir, 'base.pt')
|
| 50 |
+
if os.path.exists(base_cache_path):
|
| 51 |
+
print(f"✅ Loading base model from cache")
|
| 52 |
+
else:
|
| 53 |
+
print(f"📥 Downloading base model...")
|
| 54 |
+
|
| 55 |
+
self.model = whisper.load_model("base", device=self.device, download_root=cache_dir)
|
| 56 |
+
print("✅ Base model ready!\n")
|
| 57 |
+
|
| 58 |
+
self.language = language
|
| 59 |
+
|
| 60 |
+
def transcribe(self, audio_path: str, **kwargs) -> Dict:
|
| 61 |
+
"""
|
| 62 |
+
Transcribe audio file to text
|
| 63 |
+
|
| 64 |
+
Args:
|
| 65 |
+
audio_path: Path ke file audio
|
| 66 |
+
**kwargs: Additional Whisper parameters
|
| 67 |
+
|
| 68 |
+
Returns:
|
| 69 |
+
Dict: {'text': str, 'segments': list, 'language': str}
|
| 70 |
+
"""
|
| 71 |
+
print(f"🎧 Transcribing: {audio_path}")
|
| 72 |
+
|
| 73 |
+
try:
|
| 74 |
+
# Try with word_timestamps first
|
| 75 |
+
# Use FP16 for GPU to reduce memory and improve speed
|
| 76 |
+
fp16 = self.device == "cuda"
|
| 77 |
+
|
| 78 |
+
result = self.model.transcribe(
|
| 79 |
+
audio_path,
|
| 80 |
+
language=self.language,
|
| 81 |
+
task="transcribe",
|
| 82 |
+
word_timestamps=True,
|
| 83 |
+
condition_on_previous_text=False,
|
| 84 |
+
fp16=fp16,
|
| 85 |
+
**kwargs
|
| 86 |
+
)
|
| 87 |
+
except Exception as e:
|
| 88 |
+
print(f"⚠️ Transcription with word_timestamps failed: {e}")
|
| 89 |
+
print(f"🔄 Retrying without word_timestamps...")
|
| 90 |
+
|
| 91 |
+
# Fallback: transcribe without word_timestamps
|
| 92 |
+
fp16 = self.device == "cuda"
|
| 93 |
+
|
| 94 |
+
result = self.model.transcribe(
|
| 95 |
+
audio_path,
|
| 96 |
+
language=self.language,
|
| 97 |
+
task="transcribe",
|
| 98 |
+
condition_on_previous_text=False,
|
| 99 |
+
fp16=fp16,
|
| 100 |
+
**kwargs
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
print("✅ Transcription complete!\n")
|
| 104 |
+
|
| 105 |
+
return {
|
| 106 |
+
'text': result['text'],
|
| 107 |
+
'segments': result.get('segments', []),
|
| 108 |
+
'language': result.get('language', self.language)
|
| 109 |
+
}
|
app/services/structure.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Structure Analysis Service
|
| 3 |
+
Analisis struktur berbicara (opening, content, closing)
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import pandas as pd
|
| 7 |
+
import torch
|
| 8 |
+
import re
|
| 9 |
+
from transformers import AutoTokenizer, AutoModelForSequenceClassification
|
| 10 |
+
from typing import List, Dict
|
| 11 |
+
from app.core.device import get_device
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class StructureService:
|
| 15 |
+
"""Analisis struktur public speaking"""
|
| 16 |
+
|
| 17 |
+
def __init__(self, model_path: str = 'Cyberlace/swara-structure-model'):
|
| 18 |
+
"""
|
| 19 |
+
Initialize model from Hugging Face Hub
|
| 20 |
+
|
| 21 |
+
Args:
|
| 22 |
+
model_path: HF Hub model name or local path
|
| 23 |
+
"""
|
| 24 |
+
print("📊 Initializing Structure Service...")
|
| 25 |
+
print(f"📦 Loading model from: {model_path}")
|
| 26 |
+
|
| 27 |
+
# Auto-detect device
|
| 28 |
+
self.device = get_device()
|
| 29 |
+
|
| 30 |
+
# Load from Hugging Face Hub (with caching)
|
| 31 |
+
self.tokenizer = AutoTokenizer.from_pretrained(
|
| 32 |
+
model_path,
|
| 33 |
+
cache_dir="/.cache"
|
| 34 |
+
)
|
| 35 |
+
self.model = AutoModelForSequenceClassification.from_pretrained(
|
| 36 |
+
model_path,
|
| 37 |
+
cache_dir="/.cache"
|
| 38 |
+
)
|
| 39 |
+
self.model.to(self.device) # Move model to device
|
| 40 |
+
self.model.eval()
|
| 41 |
+
|
| 42 |
+
self.label_map = {0: 'opening', 1: 'content', 2: 'closing'}
|
| 43 |
+
|
| 44 |
+
print("✅ Structure Service ready!\n")
|
| 45 |
+
|
| 46 |
+
def split_into_sentences(self, text: str) -> List[str]:
|
| 47 |
+
"""Split text menjadi kalimat-kalimat"""
|
| 48 |
+
sentences = re.split(r'[.!?,;\n]+', text)
|
| 49 |
+
sentences = [s.strip() for s in sentences if s.strip()]
|
| 50 |
+
return sentences
|
| 51 |
+
|
| 52 |
+
def predict_sentences(self, sentences: List[str], confidence_threshold: float = 0.7) -> List[Dict]:
|
| 53 |
+
"""Prediksi label untuk list kalimat"""
|
| 54 |
+
results = []
|
| 55 |
+
|
| 56 |
+
for idx, sentence in enumerate(sentences):
|
| 57 |
+
inputs = self.tokenizer(
|
| 58 |
+
sentence,
|
| 59 |
+
add_special_tokens=True,
|
| 60 |
+
max_length=128,
|
| 61 |
+
padding='max_length',
|
| 62 |
+
truncation=True,
|
| 63 |
+
return_tensors='pt'
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
# Move inputs to device
|
| 67 |
+
inputs = {k: v.to(self.device) for k, v in inputs.items()}
|
| 68 |
+
|
| 69 |
+
with torch.no_grad():
|
| 70 |
+
outputs = self.model(**inputs)
|
| 71 |
+
probs = torch.nn.functional.softmax(outputs.logits, dim=-1)
|
| 72 |
+
predicted_class = torch.argmax(probs, dim=-1).item()
|
| 73 |
+
confidence = probs[0][predicted_class].item()
|
| 74 |
+
|
| 75 |
+
predicted_label = self.label_map[predicted_class]
|
| 76 |
+
|
| 77 |
+
# Jika opening/closing tapi confidence rendah → ubah jadi content
|
| 78 |
+
if predicted_label in ['opening', 'closing'] and confidence < confidence_threshold:
|
| 79 |
+
predicted_label = 'content'
|
| 80 |
+
|
| 81 |
+
results.append({
|
| 82 |
+
'sentence_idx': idx,
|
| 83 |
+
'text': sentence,
|
| 84 |
+
'predicted_label': predicted_label,
|
| 85 |
+
'confidence': confidence
|
| 86 |
+
})
|
| 87 |
+
|
| 88 |
+
return results
|
| 89 |
+
|
| 90 |
+
def apply_structure_rules(self, predictions: List[Dict]) -> List[Dict]:
|
| 91 |
+
"""Terapkan rules untuk memperbaiki struktur"""
|
| 92 |
+
if not predictions:
|
| 93 |
+
return predictions
|
| 94 |
+
|
| 95 |
+
n = len(predictions)
|
| 96 |
+
|
| 97 |
+
# Rule 1: 2 kalimat pertama cenderung opening
|
| 98 |
+
for i in range(min(2, n)):
|
| 99 |
+
if predictions[i]['confidence'] > 0.5:
|
| 100 |
+
probs_opening = predictions[i].get('confidence', 0)
|
| 101 |
+
if probs_opening > 0.8:
|
| 102 |
+
predictions[i]['predicted_label'] = 'opening'
|
| 103 |
+
|
| 104 |
+
# Rule 2: 2 kalimat terakhir cenderung closing
|
| 105 |
+
for i in range(max(0, n-2), n):
|
| 106 |
+
if predictions[i]['confidence'] > 0.5:
|
| 107 |
+
probs_closing = predictions[i].get('confidence', 0)
|
| 108 |
+
if probs_closing > 0.8:
|
| 109 |
+
predictions[i]['predicted_label'] = 'closing'
|
| 110 |
+
|
| 111 |
+
# Rule 3: Detect keywords
|
| 112 |
+
closing_keywords = ['demikian', 'terima kasih', 'sekian', 'akhir kata',
|
| 113 |
+
'wassalam', 'selamat pagi dan', 'sampai jumpa']
|
| 114 |
+
opening_keywords = ['selamat pagi', 'selamat siang', 'assalamualaikum',
|
| 115 |
+
'hadirin', 'pertama-tama', 'izinkan saya']
|
| 116 |
+
|
| 117 |
+
for pred in predictions:
|
| 118 |
+
text_lower = pred['text'].lower()
|
| 119 |
+
|
| 120 |
+
if any(kw in text_lower for kw in closing_keywords):
|
| 121 |
+
pred['predicted_label'] = 'closing'
|
| 122 |
+
elif any(kw in text_lower for kw in opening_keywords):
|
| 123 |
+
pred['predicted_label'] = 'opening'
|
| 124 |
+
|
| 125 |
+
return predictions
|
| 126 |
+
|
| 127 |
+
def segment_speech_structure(self, predictions: List[Dict]) -> Dict:
|
| 128 |
+
"""Grouping kalimat berdasarkan struktur"""
|
| 129 |
+
structure = {
|
| 130 |
+
'opening': [],
|
| 131 |
+
'content': [],
|
| 132 |
+
'closing': []
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
for pred in predictions:
|
| 136 |
+
label = pred['predicted_label']
|
| 137 |
+
structure[label].append(pred)
|
| 138 |
+
|
| 139 |
+
return structure
|
| 140 |
+
|
| 141 |
+
def calculate_score(self, structure: Dict) -> Dict:
|
| 142 |
+
"""Hitung skor berdasarkan struktur"""
|
| 143 |
+
has_opening = len(structure['opening']) > 0
|
| 144 |
+
has_content = len(structure['content']) > 0
|
| 145 |
+
has_closing = len(structure['closing']) > 0
|
| 146 |
+
|
| 147 |
+
if has_opening and has_content and has_closing:
|
| 148 |
+
score = 5
|
| 149 |
+
description = "Sempurna! Struktur lengkap (Pembuka, Isi, Penutup)"
|
| 150 |
+
elif has_opening and has_content and not has_closing:
|
| 151 |
+
score = 4
|
| 152 |
+
description = "Baik. Ada pembuka dan isi, tapi kurang penutup"
|
| 153 |
+
elif has_opening and not has_content and has_closing:
|
| 154 |
+
score = 3
|
| 155 |
+
description = "Cukup. Ada pembuka dan penutup, tapi isi kurang jelas"
|
| 156 |
+
elif not has_opening and has_content and has_closing:
|
| 157 |
+
score = 2
|
| 158 |
+
description = "Perlu perbaikan. Kurang pembuka yang jelas"
|
| 159 |
+
elif has_opening and not has_content and not has_closing:
|
| 160 |
+
score = 1
|
| 161 |
+
description = "Kurang lengkap. Hanya ada pembuka"
|
| 162 |
+
else:
|
| 163 |
+
score = 0
|
| 164 |
+
description = "Struktur tidak terdeteksi dengan baik"
|
| 165 |
+
|
| 166 |
+
return {
|
| 167 |
+
'score': score,
|
| 168 |
+
'max_score': 5,
|
| 169 |
+
'description': description,
|
| 170 |
+
'category': description.split('.')[0] if '.' in description else description,
|
| 171 |
+
'has_opening': has_opening,
|
| 172 |
+
'has_content': has_content,
|
| 173 |
+
'has_closing': has_closing,
|
| 174 |
+
'opening_count': len(structure['opening']),
|
| 175 |
+
'content_count': len(structure['content']),
|
| 176 |
+
'closing_count': len(structure['closing'])
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
def analyze(self, transcript: str, apply_rules: bool = True) -> Dict:
|
| 180 |
+
"""
|
| 181 |
+
Analisis struktur speech
|
| 182 |
+
|
| 183 |
+
Args:
|
| 184 |
+
transcript: Teks lengkap dari speech
|
| 185 |
+
apply_rules: Gunakan heuristic rules
|
| 186 |
+
|
| 187 |
+
Returns:
|
| 188 |
+
Dict berisi hasil analisis
|
| 189 |
+
"""
|
| 190 |
+
print(f"📊 Analyzing structure...")
|
| 191 |
+
|
| 192 |
+
# Split into sentences
|
| 193 |
+
sentences = self.split_into_sentences(transcript)
|
| 194 |
+
|
| 195 |
+
# Predict
|
| 196 |
+
predictions = self.predict_sentences(sentences)
|
| 197 |
+
|
| 198 |
+
# Apply rules
|
| 199 |
+
if apply_rules:
|
| 200 |
+
predictions = self.apply_structure_rules(predictions)
|
| 201 |
+
|
| 202 |
+
# Segment structure
|
| 203 |
+
structure = self.segment_speech_structure(predictions)
|
| 204 |
+
|
| 205 |
+
# Calculate score
|
| 206 |
+
score_result = self.calculate_score(structure)
|
| 207 |
+
|
| 208 |
+
print("✅ Structure analysis complete!\n")
|
| 209 |
+
|
| 210 |
+
return {
|
| 211 |
+
'score': score_result['score'],
|
| 212 |
+
'category': score_result['category'],
|
| 213 |
+
'description': score_result['description'],
|
| 214 |
+
'has_opening': score_result['has_opening'],
|
| 215 |
+
'has_content': score_result['has_content'],
|
| 216 |
+
'has_closing': score_result['has_closing'],
|
| 217 |
+
'opening_count': score_result['opening_count'],
|
| 218 |
+
'content_count': score_result['content_count'],
|
| 219 |
+
'closing_count': score_result['closing_count'],
|
| 220 |
+
'total_sentences': len(sentences)
|
| 221 |
+
}
|
app/services/tempo.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tempo Analysis Service
|
| 3 |
+
Analisis tempo dan jeda bicara menggunakan Silero VAD
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import torch
|
| 7 |
+
from typing import Dict, List
|
| 8 |
+
import warnings
|
| 9 |
+
warnings.filterwarnings('ignore')
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class TempoService:
|
| 13 |
+
"""Analisis tempo dan jeda bicara"""
|
| 14 |
+
|
| 15 |
+
def __init__(self):
|
| 16 |
+
"""Initialize Silero VAD model"""
|
| 17 |
+
print("🔄 Loading Silero VAD model...")
|
| 18 |
+
torch.set_num_threads(1)
|
| 19 |
+
self.model, utils = torch.hub.load(
|
| 20 |
+
repo_or_dir='snakers4/silero-vad',
|
| 21 |
+
model='silero_vad',
|
| 22 |
+
force_reload=False
|
| 23 |
+
)
|
| 24 |
+
(self.get_speech_timestamps,
|
| 25 |
+
self.save_audio,
|
| 26 |
+
self.read_audio,
|
| 27 |
+
self.VADIterator,
|
| 28 |
+
self.collect_chunks) = utils
|
| 29 |
+
print("✅ Silero VAD model loaded!\n")
|
| 30 |
+
|
| 31 |
+
def analyze(self, audio_path: str, transcription: str, sampling_rate: int = 16000) -> Dict:
|
| 32 |
+
"""
|
| 33 |
+
Analisis tempo berdasarkan jumlah kata per menit dan deteksi jeda panjang
|
| 34 |
+
|
| 35 |
+
Kriteria penilaian:
|
| 36 |
+
- Poin 5 (Sangat Baik): 140-150 kata dalam 48-60 detik, tidak ada jeda >3 detik
|
| 37 |
+
- Poin 4 (Baik): 110-139 kata dalam 36-60 detik, tidak ada jeda >3 detik
|
| 38 |
+
- Poin 3 (Cukup): 60-109 kata dalam 60 detik, tidak ada jeda >3 detik
|
| 39 |
+
- Poin 2 (Buruk): <60 kata dalam 60 detik, tidak ada jeda >3 detik
|
| 40 |
+
- Poin 1 (Perlu Ditingkatkan): Berhenti sebelum 60 detik ATAU ada jeda >3 detik
|
| 41 |
+
|
| 42 |
+
Args:
|
| 43 |
+
audio_path: Path ke file audio
|
| 44 |
+
transcription: Teks hasil transcription untuk hitung jumlah kata
|
| 45 |
+
sampling_rate: Sample rate audio (default: 16000)
|
| 46 |
+
|
| 47 |
+
Returns:
|
| 48 |
+
Dict berisi hasil analisis lengkap
|
| 49 |
+
"""
|
| 50 |
+
print(f"🎧 Analyzing tempo: {audio_path}")
|
| 51 |
+
|
| 52 |
+
# Load audio
|
| 53 |
+
wav = self.read_audio(audio_path)
|
| 54 |
+
|
| 55 |
+
# Deteksi segmen bicara
|
| 56 |
+
speech_timestamps = self.get_speech_timestamps(
|
| 57 |
+
wav, self.model, sampling_rate=sampling_rate
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
# Hitung total durasi audio
|
| 61 |
+
total_duration_sec = len(wav) / sampling_rate
|
| 62 |
+
|
| 63 |
+
# Hitung jumlah kata dari transcription
|
| 64 |
+
word_count = len(transcription.split())
|
| 65 |
+
|
| 66 |
+
# Hitung kata per menit (normalize ke 60 detik)
|
| 67 |
+
words_per_minute = (word_count / total_duration_sec) * 60 if total_duration_sec > 0 else 0
|
| 68 |
+
|
| 69 |
+
# Deteksi jeda panjang (>3 detik)
|
| 70 |
+
long_pauses = []
|
| 71 |
+
has_long_pause = False
|
| 72 |
+
|
| 73 |
+
data = []
|
| 74 |
+
for i, seg in enumerate(speech_timestamps):
|
| 75 |
+
start_time = seg['start'] / sampling_rate
|
| 76 |
+
end_time = seg['end'] / sampling_rate
|
| 77 |
+
duration = end_time - start_time
|
| 78 |
+
|
| 79 |
+
if i == 0:
|
| 80 |
+
pause_before = start_time
|
| 81 |
+
else:
|
| 82 |
+
pause_before = start_time - (speech_timestamps[i - 1]['end'] / sampling_rate)
|
| 83 |
+
|
| 84 |
+
# Check jeda panjang
|
| 85 |
+
if pause_before > 3.0:
|
| 86 |
+
has_long_pause = True
|
| 87 |
+
long_pauses.append({
|
| 88 |
+
'after_segment': i,
|
| 89 |
+
'pause_duration': round(pause_before, 2)
|
| 90 |
+
})
|
| 91 |
+
|
| 92 |
+
data.append({
|
| 93 |
+
'segment': i + 1,
|
| 94 |
+
'start_sec': round(start_time, 2),
|
| 95 |
+
'end_sec': round(end_time, 2),
|
| 96 |
+
'duration_sec': round(duration, 2),
|
| 97 |
+
'pause_before_sec': round(pause_before, 2)
|
| 98 |
+
})
|
| 99 |
+
|
| 100 |
+
# Tentukan skor berdasarkan kriteria
|
| 101 |
+
if total_duration_sec < 60 or has_long_pause:
|
| 102 |
+
# Poin 1: Berhenti sebelum 60 detik ATAU ada jeda >3 detik
|
| 103 |
+
poin = 1
|
| 104 |
+
kategori = "Perlu Ditingkatkan"
|
| 105 |
+
if total_duration_sec < 60:
|
| 106 |
+
alasan = f"Durasi bicara hanya {round(total_duration_sec, 1)} detik (kurang dari 60 detik)"
|
| 107 |
+
else:
|
| 108 |
+
alasan = f"Terdapat {len(long_pauses)} jeda lebih dari 3 detik"
|
| 109 |
+
elif words_per_minute >= 140 and words_per_minute <= 150 and total_duration_sec >= 48:
|
| 110 |
+
# Poin 5: 140-150 kata dalam 48-60 detik
|
| 111 |
+
poin = 5
|
| 112 |
+
kategori = "Sangat Baik"
|
| 113 |
+
alasan = f"Tempo ideal: {round(words_per_minute, 1)} kata/menit dalam {round(total_duration_sec, 1)} detik"
|
| 114 |
+
elif words_per_minute >= 110 and words_per_minute <= 139 and total_duration_sec >= 36:
|
| 115 |
+
# Poin 4: 110-139 kata dalam 36-60 detik
|
| 116 |
+
poin = 4
|
| 117 |
+
kategori = "Baik"
|
| 118 |
+
alasan = f"Tempo baik: {round(words_per_minute, 1)} kata/menit dalam {round(total_duration_sec, 1)} detik"
|
| 119 |
+
elif words_per_minute >= 60 and words_per_minute <= 109:
|
| 120 |
+
# Poin 3: 60-109 kata dalam 60 detik
|
| 121 |
+
poin = 3
|
| 122 |
+
kategori = "Cukup"
|
| 123 |
+
alasan = f"Tempo cukup: {round(words_per_minute, 1)} kata/menit"
|
| 124 |
+
else:
|
| 125 |
+
# Poin 2: <60 kata dalam 60 detik
|
| 126 |
+
poin = 2
|
| 127 |
+
kategori = "Buruk"
|
| 128 |
+
alasan = f"Tempo lambat: hanya {round(words_per_minute, 1)} kata/menit"
|
| 129 |
+
|
| 130 |
+
print("✅ Tempo analysis complete!\n")
|
| 131 |
+
|
| 132 |
+
return {
|
| 133 |
+
'score': poin,
|
| 134 |
+
'category': kategori,
|
| 135 |
+
'reason': alasan,
|
| 136 |
+
'total_duration_sec': round(total_duration_sec, 2),
|
| 137 |
+
'word_count': word_count,
|
| 138 |
+
'words_per_minute': round(words_per_minute, 1),
|
| 139 |
+
'has_long_pause': has_long_pause,
|
| 140 |
+
'long_pauses': long_pauses,
|
| 141 |
+
'total_segments': len(speech_timestamps),
|
| 142 |
+
# 'segments': data
|
| 143 |
+
}
|
app/tasks.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Background tasks untuk RQ worker
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from typing import List, Optional
|
| 6 |
+
from app.services.audio_processor import AudioProcessor
|
| 7 |
+
from app.core.storage import delete_file
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def process_audio_task(
|
| 11 |
+
audio_path: str,
|
| 12 |
+
reference_text: Optional[str] = None,
|
| 13 |
+
topic_id: Optional[str] = None,
|
| 14 |
+
custom_topic: Optional[str] = None,
|
| 15 |
+
custom_keywords: Optional[List[str]] = None,
|
| 16 |
+
analyze_tempo: bool = True,
|
| 17 |
+
analyze_articulation: bool = True,
|
| 18 |
+
analyze_structure: bool = True,
|
| 19 |
+
analyze_keywords: bool = False,
|
| 20 |
+
analyze_profanity: bool = False
|
| 21 |
+
):
|
| 22 |
+
"""
|
| 23 |
+
Background task untuk process audio
|
| 24 |
+
|
| 25 |
+
This function will be executed by RQ worker
|
| 26 |
+
|
| 27 |
+
Args:
|
| 28 |
+
audio_path: Path ke file audio
|
| 29 |
+
reference_text: Teks referensi untuk artikulasi
|
| 30 |
+
topic_id: ID topik dari database (Level 1-2)
|
| 31 |
+
custom_topic: Topik custom dari user (Level 3)
|
| 32 |
+
custom_keywords: List kata kunci dari GPT (Level 3)
|
| 33 |
+
analyze_tempo: Flag tempo analysis
|
| 34 |
+
analyze_articulation: Flag articulation analysis
|
| 35 |
+
analyze_structure: Flag structure analysis
|
| 36 |
+
analyze_keywords: Flag keyword analysis
|
| 37 |
+
analyze_profanity: Flag profanity detection
|
| 38 |
+
"""
|
| 39 |
+
try:
|
| 40 |
+
processor = AudioProcessor()
|
| 41 |
+
|
| 42 |
+
result = processor.process_audio(
|
| 43 |
+
audio_path=audio_path,
|
| 44 |
+
reference_text=reference_text,
|
| 45 |
+
topic_id=topic_id,
|
| 46 |
+
custom_topic=custom_topic,
|
| 47 |
+
custom_keywords=custom_keywords,
|
| 48 |
+
analyze_tempo=analyze_tempo,
|
| 49 |
+
analyze_articulation=analyze_articulation,
|
| 50 |
+
analyze_structure=analyze_structure,
|
| 51 |
+
analyze_keywords=analyze_keywords,
|
| 52 |
+
analyze_profanity=analyze_profanity
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
# Cleanup file after processing
|
| 56 |
+
try:
|
| 57 |
+
delete_file(audio_path)
|
| 58 |
+
except Exception as e:
|
| 59 |
+
print(f"Warning: Could not delete file {audio_path}: {e}")
|
| 60 |
+
|
| 61 |
+
return {
|
| 62 |
+
'status': 'completed',
|
| 63 |
+
'result': result
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
except Exception as e:
|
| 67 |
+
# Cleanup file on error
|
| 68 |
+
try:
|
| 69 |
+
delete_file(audio_path)
|
| 70 |
+
except:
|
| 71 |
+
pass
|
| 72 |
+
|
| 73 |
+
return {
|
| 74 |
+
'status': 'failed',
|
| 75 |
+
'error': str(e)
|
| 76 |
+
}
|
app/worker.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
RQ Worker
|
| 3 |
+
Run this to start the background worker
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import time
|
| 7 |
+
import sys
|
| 8 |
+
from rq import Worker
|
| 9 |
+
from app.core.redis_client import get_redis_connection, get_queue, check_redis_connection
|
| 10 |
+
from app.config import settings
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def run_worker():
|
| 14 |
+
"""Run RQ worker with retry logic"""
|
| 15 |
+
|
| 16 |
+
print(f"🔄 Starting RQ Worker...")
|
| 17 |
+
print(f"📊 Queue: {settings.QUEUE_NAME}")
|
| 18 |
+
print(f"🔗 Redis: {settings.REDIS_HOST}:{settings.REDIS_PORT}")
|
| 19 |
+
|
| 20 |
+
# Wait for Redis to be ready
|
| 21 |
+
max_retries = 30
|
| 22 |
+
retry_interval = 2
|
| 23 |
+
|
| 24 |
+
for attempt in range(1, max_retries + 1):
|
| 25 |
+
is_connected, error_msg = check_redis_connection()
|
| 26 |
+
|
| 27 |
+
if is_connected:
|
| 28 |
+
print(f"✅ Redis connected!")
|
| 29 |
+
break
|
| 30 |
+
else:
|
| 31 |
+
if attempt < max_retries:
|
| 32 |
+
print(f"⏳ Waiting for Redis... (attempt {attempt}/{max_retries})")
|
| 33 |
+
time.sleep(retry_interval)
|
| 34 |
+
else:
|
| 35 |
+
print(f"❌ Failed to connect to Redis after {max_retries} attempts")
|
| 36 |
+
print(f" Error: {error_msg}")
|
| 37 |
+
sys.exit(1)
|
| 38 |
+
|
| 39 |
+
# Start worker
|
| 40 |
+
redis_conn = get_redis_connection()
|
| 41 |
+
queue = get_queue()
|
| 42 |
+
|
| 43 |
+
print(f"🚀 Worker ready and listening for tasks!\n")
|
| 44 |
+
|
| 45 |
+
worker = Worker([queue], connection=redis_conn)
|
| 46 |
+
worker.work()
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
if __name__ == "__main__":
|
| 50 |
+
run_worker()
|
backup_old_files/REDIS_CONFIG_NOTES.md
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 🔴 Redis Configuration - Technical Notes
|
| 2 |
+
|
| 3 |
+
## ✅ Configuration Summary
|
| 4 |
+
|
| 5 |
+
Konfigurasi Redis untuk Swara API sudah **BENAR** dan siap untuk deployment ke Hugging Face Spaces.
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## 📋 Redis Settings
|
| 10 |
+
|
| 11 |
+
### 1. **Configuration File** (`app/config.py`)
|
| 12 |
+
|
| 13 |
+
```python
|
| 14 |
+
REDIS_HOST: str = os.getenv("REDIS_HOST", "localhost")
|
| 15 |
+
REDIS_PORT: int = int(os.getenv("REDIS_PORT", "6379"))
|
| 16 |
+
REDIS_DB: int = int(os.getenv("REDIS_DB", "0"))
|
| 17 |
+
REDIS_PASSWORD: str = os.getenv("REDIS_PASSWORD", "")
|
| 18 |
+
```
|
| 19 |
+
|
| 20 |
+
✅ **Correct**: Defaults ke `localhost:6379` untuk single-container deployment
|
| 21 |
+
|
| 22 |
+
---
|
| 23 |
+
|
| 24 |
+
### 2. **Redis Client** (`app/core/redis_client.py`)
|
| 25 |
+
|
| 26 |
+
**FIXED Issues:**
|
| 27 |
+
|
| 28 |
+
- ❌ **Before**: `decode_responses=True` → Caused RQ errors
|
| 29 |
+
- ✅ **After**: Removed `decode_responses` → RQ compatible
|
| 30 |
+
|
| 31 |
+
**Current Configuration:**
|
| 32 |
+
|
| 33 |
+
```python
|
| 34 |
+
def get_redis_connection():
|
| 35 |
+
redis_kwargs = {
|
| 36 |
+
'host': settings.REDIS_HOST,
|
| 37 |
+
'port': settings.REDIS_PORT,
|
| 38 |
+
'db': settings.REDIS_DB,
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
if settings.REDIS_PASSWORD:
|
| 42 |
+
redis_kwargs['password'] = settings.REDIS_PASSWORD
|
| 43 |
+
|
| 44 |
+
return redis.Redis(**redis_kwargs) # No decode_responses!
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
✅ **Benefits:**
|
| 48 |
+
|
| 49 |
+
- Compatible with RQ (Redis Queue)
|
| 50 |
+
- Proper bytes handling
|
| 51 |
+
- Password support (optional)
|
| 52 |
+
- Clean connection management
|
| 53 |
+
|
| 54 |
+
**New Functions:**
|
| 55 |
+
|
| 56 |
+
```python
|
| 57 |
+
def check_redis_connection():
|
| 58 |
+
"""Health check function"""
|
| 59 |
+
try:
|
| 60 |
+
conn = get_redis_connection()
|
| 61 |
+
conn.ping()
|
| 62 |
+
return True, None
|
| 63 |
+
except Exception as e:
|
| 64 |
+
return False, str(e)
|
| 65 |
+
```
|
| 66 |
+
|
| 67 |
+
✅ **Use case**: Health checks & startup validation
|
| 68 |
+
|
| 69 |
+
---
|
| 70 |
+
|
| 71 |
+
### 3. **Startup Script** (`start.sh`)
|
| 72 |
+
|
| 73 |
+
**Improvements Made:**
|
| 74 |
+
|
| 75 |
+
**Before:**
|
| 76 |
+
|
| 77 |
+
```bash
|
| 78 |
+
redis-server --daemonize yes
|
| 79 |
+
until redis-cli ping; do
|
| 80 |
+
echo "Waiting for Redis..."
|
| 81 |
+
sleep 1
|
| 82 |
+
done
|
| 83 |
+
```
|
| 84 |
+
|
| 85 |
+
**After:**
|
| 86 |
+
|
| 87 |
+
```bash
|
| 88 |
+
# Set environment variables
|
| 89 |
+
export REDIS_HOST=localhost
|
| 90 |
+
export REDIS_PORT=6379
|
| 91 |
+
export REDIS_DB=0
|
| 92 |
+
|
| 93 |
+
# Start Redis with specific binding
|
| 94 |
+
redis-server --daemonize yes --bind 127.0.0.1 --port 6379
|
| 95 |
+
|
| 96 |
+
# Wait with timeout
|
| 97 |
+
REDIS_TIMEOUT=30
|
| 98 |
+
until redis-cli -h localhost -p 6379 ping 2>/dev/null | grep -q PONG; do
|
| 99 |
+
if [ $ELAPSED -ge $REDIS_TIMEOUT ]; then
|
| 100 |
+
echo "ERROR: Redis failed to start"
|
| 101 |
+
exit 1
|
| 102 |
+
fi
|
| 103 |
+
sleep 2
|
| 104 |
+
done
|
| 105 |
+
```
|
| 106 |
+
|
| 107 |
+
✅ **Improvements:**
|
| 108 |
+
|
| 109 |
+
- Environment variables explicitly set
|
| 110 |
+
- Timeout protection (30s max)
|
| 111 |
+
- Specific binding to localhost
|
| 112 |
+
- Better error handling
|
| 113 |
+
- Clearer logging
|
| 114 |
+
|
| 115 |
+
---
|
| 116 |
+
|
| 117 |
+
### 4. **Worker** (`app/worker.py`)
|
| 118 |
+
|
| 119 |
+
**Added Retry Logic:**
|
| 120 |
+
|
| 121 |
+
```python
|
| 122 |
+
def run_worker():
|
| 123 |
+
# Wait for Redis with retries
|
| 124 |
+
max_retries = 30
|
| 125 |
+
for attempt in range(1, max_retries + 1):
|
| 126 |
+
is_connected, error_msg = check_redis_connection()
|
| 127 |
+
if is_connected:
|
| 128 |
+
break
|
| 129 |
+
time.sleep(2)
|
| 130 |
+
|
| 131 |
+
# Then start worker
|
| 132 |
+
worker = Worker([queue], connection=redis_conn)
|
| 133 |
+
worker.work()
|
| 134 |
+
```
|
| 135 |
+
|
| 136 |
+
✅ **Benefits:**
|
| 137 |
+
|
| 138 |
+
- Graceful startup
|
| 139 |
+
- Handles Redis not ready yet
|
| 140 |
+
- Clear error messages
|
| 141 |
+
- Auto-retry mechanism
|
| 142 |
+
|
| 143 |
+
---
|
| 144 |
+
|
| 145 |
+
### 5. **Health Check** (`app/api/routes.py`)
|
| 146 |
+
|
| 147 |
+
**Improved Endpoint:**
|
| 148 |
+
|
| 149 |
+
```python
|
| 150 |
+
@router.get("/health")
|
| 151 |
+
async def health_check():
|
| 152 |
+
is_connected, error_msg = check_redis_connection()
|
| 153 |
+
|
| 154 |
+
return {
|
| 155 |
+
"status": "healthy" if is_connected else "degraded",
|
| 156 |
+
"redis": "healthy" if is_connected else f"unhealthy: {error_msg}",
|
| 157 |
+
"version": settings.VERSION
|
| 158 |
+
}
|
| 159 |
+
```
|
| 160 |
+
|
| 161 |
+
✅ **Benefits:**
|
| 162 |
+
|
| 163 |
+
- Real-time Redis status
|
| 164 |
+
- Degraded state detection
|
| 165 |
+
- Useful for monitoring
|
| 166 |
+
|
| 167 |
+
---
|
| 168 |
+
|
| 169 |
+
## 🏗️ Architecture for HF Spaces
|
| 170 |
+
|
| 171 |
+
```
|
| 172 |
+
┌─────────────────────────────────────────┐
|
| 173 |
+
│ Hugging Face Space (Single Container) │
|
| 174 |
+
│ │
|
| 175 |
+
│ ┌──────────────────────────────────┐ │
|
| 176 |
+
│ │ Redis Server (localhost:6379) │ │
|
| 177 |
+
│ │ - In-memory data store │ │
|
| 178 |
+
│ │ - Task queue │ │
|
| 179 |
+
│ │ - Result storage (24h TTL) │ │
|
| 180 |
+
│ └─────────┬────────────────────────┘ │
|
| 181 |
+
│ │ │
|
| 182 |
+
│ ┌─────────▼───────────┐ │
|
| 183 |
+
│ │ RQ Worker │ │
|
| 184 |
+
│ │ - Process tasks │ │
|
| 185 |
+
│ │ - Run AI models │ │
|
| 186 |
+
│ └─────────┬───────────┘ │
|
| 187 |
+
│ │ │
|
| 188 |
+
│ ┌─────────▼───────────┐ │
|
| 189 |
+
│ │ FastAPI App │ │
|
| 190 |
+
│ │ - REST API │ │
|
| 191 |
+
│ │ - Port 7860 │ │
|
| 192 |
+
│ └─────────────────────┘ │
|
| 193 |
+
│ │
|
| 194 |
+
└──────────────────────────────────────────┘
|
| 195 |
+
▲
|
| 196 |
+
│ HTTP Requests
|
| 197 |
+
│
|
| 198 |
+
┌────┴─────┐
|
| 199 |
+
│ Client │
|
| 200 |
+
└──────────┘
|
| 201 |
+
```
|
| 202 |
+
|
| 203 |
+
---
|
| 204 |
+
|
| 205 |
+
## 🔍 Configuration Validation
|
| 206 |
+
|
| 207 |
+
### Check 1: Environment Variables
|
| 208 |
+
|
| 209 |
+
```bash
|
| 210 |
+
# In HF Spaces, these are auto-set by start.sh:
|
| 211 |
+
REDIS_HOST=localhost
|
| 212 |
+
REDIS_PORT=6379
|
| 213 |
+
REDIS_DB=0
|
| 214 |
+
```
|
| 215 |
+
|
| 216 |
+
✅ **Status**: Configured in `start.sh`
|
| 217 |
+
|
| 218 |
+
### Check 2: Redis Connection
|
| 219 |
+
|
| 220 |
+
```python
|
| 221 |
+
# Test connection
|
| 222 |
+
from app.core.redis_client import check_redis_connection
|
| 223 |
+
is_connected, error = check_redis_connection()
|
| 224 |
+
print(f"Connected: {is_connected}")
|
| 225 |
+
```
|
| 226 |
+
|
| 227 |
+
✅ **Status**: Function available
|
| 228 |
+
|
| 229 |
+
### Check 3: Queue Setup
|
| 230 |
+
|
| 231 |
+
```python
|
| 232 |
+
# Test queue
|
| 233 |
+
from app.core.redis_client import get_queue
|
| 234 |
+
queue = get_queue()
|
| 235 |
+
print(f"Queue: {queue.name}")
|
| 236 |
+
```
|
| 237 |
+
|
| 238 |
+
✅ **Status**: Queue name: `audio_analysis`
|
| 239 |
+
|
| 240 |
+
---
|
| 241 |
+
|
| 242 |
+
## 🚨 Common Issues & Solutions
|
| 243 |
+
|
| 244 |
+
### Issue 1: "Connection refused"
|
| 245 |
+
|
| 246 |
+
**Cause**: Redis not started yet
|
| 247 |
+
**Solution**: ✅ Fixed with retry logic in worker
|
| 248 |
+
|
| 249 |
+
### Issue 2: "decode_responses error"
|
| 250 |
+
|
| 251 |
+
**Cause**: RQ doesn't support `decode_responses=True`
|
| 252 |
+
**Solution**: ✅ Fixed by removing from connection
|
| 253 |
+
|
| 254 |
+
### Issue 3: Worker timeout
|
| 255 |
+
|
| 256 |
+
**Cause**: Long-running tasks
|
| 257 |
+
**Solution**: ✅ Set `JOB_TIMEOUT=3600` (1 hour)
|
| 258 |
+
|
| 259 |
+
### Issue 4: Results disappear
|
| 260 |
+
|
| 261 |
+
**Cause**: Default TTL too short
|
| 262 |
+
**Solution**: ✅ Set `RESULT_TTL=86400` (24 hours)
|
| 263 |
+
|
| 264 |
+
---
|
| 265 |
+
|
| 266 |
+
## 📊 Redis Performance Settings
|
| 267 |
+
|
| 268 |
+
### Current Settings:
|
| 269 |
+
|
| 270 |
+
```python
|
| 271 |
+
QUEUE_NAME: str = "audio_analysis"
|
| 272 |
+
JOB_TIMEOUT: int = 3600 # 1 hour
|
| 273 |
+
RESULT_TTL: int = 86400 # 24 hours
|
| 274 |
+
```
|
| 275 |
+
|
| 276 |
+
### Recommended for Production:
|
| 277 |
+
|
| 278 |
+
```python
|
| 279 |
+
# For high traffic:
|
| 280 |
+
RESULT_TTL: int = 3600 # 1 hour (save memory)
|
| 281 |
+
|
| 282 |
+
# For long audio:
|
| 283 |
+
JOB_TIMEOUT: int = 7200 # 2 hours
|
| 284 |
+
```
|
| 285 |
+
|
| 286 |
+
---
|
| 287 |
+
|
| 288 |
+
## ✅ Final Checklist
|
| 289 |
+
|
| 290 |
+
- [x] Redis connection without `decode_responses`
|
| 291 |
+
- [x] Environment variables in `start.sh`
|
| 292 |
+
- [x] Retry logic in worker
|
| 293 |
+
- [x] Health check endpoint
|
| 294 |
+
- [x] Timeout protection
|
| 295 |
+
- [x] Error handling
|
| 296 |
+
- [x] Graceful startup sequence
|
| 297 |
+
- [x] Proper binding to localhost
|
| 298 |
+
- [x] TTL configuration
|
| 299 |
+
|
| 300 |
+
---
|
| 301 |
+
|
| 302 |
+
## 🎯 Status: READY FOR DEPLOYMENT
|
| 303 |
+
|
| 304 |
+
Semua konfigurasi Redis sudah **BENAR** dan **OPTIMAL** untuk:
|
| 305 |
+
|
| 306 |
+
- ✅ Hugging Face Spaces (single container)
|
| 307 |
+
- ✅ Local development
|
| 308 |
+
- ✅ Production deployment
|
| 309 |
+
- ✅ High availability
|
| 310 |
+
- ✅ Error recovery
|
| 311 |
+
|
| 312 |
+
**No further Redis configuration needed!** 🚀
|
kata_kunci.json
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"1": {
|
| 3 |
+
"title": "Generasi Z Lebih Produktif di Dunia Digital daripada di Dunia Nyata",
|
| 4 |
+
"keywords": [
|
| 5 |
+
"produktivitas", "media sosial", "digitalisasi", "multitasking",
|
| 6 |
+
"kebiasaan online", "self-branding", "fokus", "distraksi",
|
| 7 |
+
"keseimbangan", "realitas sosial"
|
| 8 |
+
],
|
| 9 |
+
"variants": {
|
| 10 |
+
"produktivitas": ["produktif", "efisiensi", "efektif", "hasil kerja"],
|
| 11 |
+
"media sosial": ["medsos", "sosmed", "platform digital", "jejaring sosial"],
|
| 12 |
+
"digitalisasi": ["digital", "teknologi digital", "dunia digital", "era digital"],
|
| 13 |
+
"multitasking": ["multi tasking", "banyak tugas", "kerja simultan"],
|
| 14 |
+
"kebiasaan online": ["kebiasaan daring", "aktivitas online", "perilaku digital"],
|
| 15 |
+
"self-branding": ["personal branding", "citra diri", "branding diri"],
|
| 16 |
+
"fokus": ["konsentrasi", "perhatian", "atensi"],
|
| 17 |
+
"distraksi": ["gangguan", "pengalih perhatian", "distorsi fokus"],
|
| 18 |
+
"keseimbangan": ["balance", "seimbang", "proporsi"],
|
| 19 |
+
"realitas sosial": ["kehidupan sosial", "interaksi sosial", "dunia nyata"]
|
| 20 |
+
}
|
| 21 |
+
},
|
| 22 |
+
"2": {
|
| 23 |
+
"title": "Kebebasan Berpendapat di Media Sosial Justru Mengancam Etika Komunikasi",
|
| 24 |
+
"keywords": [
|
| 25 |
+
"kebebasan berekspresi", "hate speech", "netizen", "literasi digital",
|
| 26 |
+
"tanggung jawab", "komentar", "polarisasi", "etika",
|
| 27 |
+
"cancel culture", "privasi"
|
| 28 |
+
],
|
| 29 |
+
"variants": {
|
| 30 |
+
"kebebasan berekspresi": ["kebebasan berpendapat", "freedom of speech", "bebas bicara"],
|
| 31 |
+
"hate speech": ["ujaran kebencian", "ucapan kebencian", "konten negatif"],
|
| 32 |
+
"netizen": ["warganet", "pengguna internet", "masyarakat digital"],
|
| 33 |
+
"literasi digital": ["melek digital", "pemahaman digital", "edukasi digital"],
|
| 34 |
+
"tanggung jawab": ["responsibility", "akuntabilitas", "pertanggungjawaban"],
|
| 35 |
+
"komentar": ["comment", "respon", "feedback"],
|
| 36 |
+
"polarisasi": ["perpecahan", "kubu-kubuan", "dikotomi"],
|
| 37 |
+
"etika": ["moral", "norma", "sopan santun", "adab"],
|
| 38 |
+
"cancel culture": ["budaya membatalkan", "boikot sosial"],
|
| 39 |
+
"privasi": ["privacy", "data pribadi", "kerahasiaan"]
|
| 40 |
+
}
|
| 41 |
+
},
|
| 42 |
+
"3": {
|
| 43 |
+
"title": "Media Sosial Lebih Banyak Merusak Kesehatan Mental daripada Membantu Ekspresi Diri",
|
| 44 |
+
"keywords": [
|
| 45 |
+
"self-esteem", "validasi sosial", "perbandingan", "overthinking",
|
| 46 |
+
"toxic positivity", "citra diri", "dopamine", "burnout",
|
| 47 |
+
"kesehatan mental", "eksposur publik"
|
| 48 |
+
],
|
| 49 |
+
"variants": {
|
| 50 |
+
"self-esteem": ["harga diri", "kepercayaan diri", "rasa percaya diri"],
|
| 51 |
+
"validasi sosial": ["pengakuan sosial", "approval", "penerimaan sosial"],
|
| 52 |
+
"perbandingan": ["comparison", "membandingkan", "komparasi"],
|
| 53 |
+
"overthinking": ["berpikir berlebihan", "overthink", "cemas berlebihan"],
|
| 54 |
+
"toxic positivity": ["positif berlebihan", "positifitas toksik"],
|
| 55 |
+
"citra diri": ["body image", "self image", "penampilan diri"],
|
| 56 |
+
"dopamine": ["hormon bahagia", "reward system"],
|
| 57 |
+
"burnout": ["kelelahan mental", "jenuh", "exhausted"],
|
| 58 |
+
"kesehatan mental": ["mental health", "kondisi mental", "psikologis"],
|
| 59 |
+
"eksposur publik": ["paparan publik", "tampil di publik", "visibilitas"]
|
| 60 |
+
}
|
| 61 |
+
},
|
| 62 |
+
"4": {
|
| 63 |
+
"title": "Budaya Gotong Royong Mulai Luntur di Era Individualisme Digital",
|
| 64 |
+
"keywords": [
|
| 65 |
+
"solidaritas", "empati", "komunitas", "kesibukan",
|
| 66 |
+
"gaya hidup modern", "isolasi sosial", "partisipasi", "nilai budaya",
|
| 67 |
+
"relasi sosial", "kebersamaan"
|
| 68 |
+
],
|
| 69 |
+
"variants": {
|
| 70 |
+
"solidaritas": ["kebersamaan", "kekompakan", "saling membantu"],
|
| 71 |
+
"empati": ["simpati", "kepedulian", "rasa iba"],
|
| 72 |
+
"komunitas": ["masyarakat", "kelompok", "perkumpulan"],
|
| 73 |
+
"kesibukan": ["busy", "aktivitas padat", "rutinitas"],
|
| 74 |
+
"gaya hidup modern": ["lifestyle modern", "kehidupan modern", "modernisasi"],
|
| 75 |
+
"isolasi sosial": ["terisolasi", "menyendiri", "kesepian sosial"],
|
| 76 |
+
"partisipasi": ["keterlibatan", "peran serta", "kontribusi"],
|
| 77 |
+
"nilai budaya": ["budaya", "tradisi", "kearifan lokal"],
|
| 78 |
+
"relasi sosial": ["hubungan sosial", "interaksi", "pertemanan"],
|
| 79 |
+
"kebersamaan": ["togetherness", "kolektif", "gotong royong"]
|
| 80 |
+
}
|
| 81 |
+
},
|
| 82 |
+
"5": {
|
| 83 |
+
"title": "Produk Lokal Layak Jadi Kebanggaan Nasional di Tengah Gempuran Globalisasi",
|
| 84 |
+
"keywords": [
|
| 85 |
+
"UMKM", "inovasi", "branding", "ekonomi kreatif",
|
| 86 |
+
"ekspor", "identitas budaya", "daya saing", "kreativitas",
|
| 87 |
+
"kemandirian", "nasionalisme"
|
| 88 |
+
],
|
| 89 |
+
"variants": {
|
| 90 |
+
"UMKM": ["usaha kecil", "UKM", "wirausaha", "pelaku usaha"],
|
| 91 |
+
"inovasi": ["inovatif", "kreasi baru", "terobosan"],
|
| 92 |
+
"branding": ["merek", "citra produk", "brand"],
|
| 93 |
+
"ekonomi kreatif": ["industri kreatif", "creative economy"],
|
| 94 |
+
"ekspor": ["export", "perdagangan luar negeri", "pasar global"],
|
| 95 |
+
"identitas budaya": ["jati diri", "ciri khas", "karakteristik budaya"],
|
| 96 |
+
"daya saing": ["kompetitif", "keunggulan", "competitiveness"],
|
| 97 |
+
"kreativitas": ["kreatif", "daya cipta", "imajinatif"],
|
| 98 |
+
"kemandirian": ["mandiri", "independen", "swasembada"],
|
| 99 |
+
"nasionalisme": ["cinta tanah air", "patriotisme", "bangga Indonesia"]
|
| 100 |
+
}
|
| 101 |
+
},
|
| 102 |
+
"6": {
|
| 103 |
+
"title": "Uang Bukan Tolak Ukur Kebahagiaan",
|
| 104 |
+
"keywords": [
|
| 105 |
+
"kesejahteraan", "mental health", "gaya hidup", "materialisme",
|
| 106 |
+
"kesederhanaan", "prioritas", "hubungan sosial", "gratitude",
|
| 107 |
+
"keseimbangan", "nilai hidup"
|
| 108 |
+
],
|
| 109 |
+
"variants": {
|
| 110 |
+
"kesejahteraan": ["well-being", "sejahtera", "kemakmuran"],
|
| 111 |
+
"mental health": ["kesehatan mental", "psikologis", "kondisi jiwa"],
|
| 112 |
+
"gaya hidup": ["lifestyle", "pola hidup", "cara hidup"],
|
| 113 |
+
"materialisme": ["materialistik", "konsumtif", "hedonisme"],
|
| 114 |
+
"kesederhanaan": ["sederhana", "simple", "minimalis"],
|
| 115 |
+
"prioritas": ["hal penting", "yang utama", "fokus utama"],
|
| 116 |
+
"hubungan sosial": ["relasi", "pertemanan", "keluarga"],
|
| 117 |
+
"gratitude": ["syukur", "bersyukur", "rasa terima kasih"],
|
| 118 |
+
"keseimbangan": ["balance", "seimbang", "harmoni"],
|
| 119 |
+
"nilai hidup": ["makna hidup", "filosofi hidup", "prinsip"]
|
| 120 |
+
}
|
| 121 |
+
},
|
| 122 |
+
"7": {
|
| 123 |
+
"title": "Teknologi Membuat Manusia Semakin Malas Berpikir Kritis",
|
| 124 |
+
"keywords": [
|
| 125 |
+
"AI", "otomatisasi", "kenyamanan", "ketergantungan",
|
| 126 |
+
"literasi digital", "algoritma", "kecepatan informasi", "refleksi",
|
| 127 |
+
"kreativitas", "kesadaran"
|
| 128 |
+
],
|
| 129 |
+
"variants": {
|
| 130 |
+
"AI": ["artificial intelligence", "kecerdasan buatan", "machine learning"],
|
| 131 |
+
"otomatisasi": ["automasi", "serba otomatis", "automation"],
|
| 132 |
+
"kenyamanan": ["kemudahan", "comfort", "efisiensi"],
|
| 133 |
+
"ketergantungan": ["addiction", "kecanduan", "bergantung"],
|
| 134 |
+
"literasi digital": ["melek digital", "pemahaman teknologi"],
|
| 135 |
+
"algoritma": ["algorithm", "sistem", "pola"],
|
| 136 |
+
"kecepatan informasi": ["informasi cepat", "instant information"],
|
| 137 |
+
"refleksi": ["renungan", "introspeksi", "contemplation"],
|
| 138 |
+
"kreativitas": ["kreatif", "inovasi", "imajinasi"],
|
| 139 |
+
"kesadaran": ["awareness", "mindfulness", "sadar"]
|
| 140 |
+
}
|
| 141 |
+
},
|
| 142 |
+
"8": {
|
| 143 |
+
"title": "Literasi Membaca di Kalangan Anak Muda Indonesia Masih Rendah",
|
| 144 |
+
"keywords": [
|
| 145 |
+
"minat baca", "gadget", "media sosial", "budaya literasi",
|
| 146 |
+
"pendidikan", "akses buku", "kebiasaan", "digital reading",
|
| 147 |
+
"perpustakaan", "edukasi"
|
| 148 |
+
],
|
| 149 |
+
"variants": {
|
| 150 |
+
"minat baca": ["reading interest", "gemar membaca", "hobi baca"],
|
| 151 |
+
"gadget": ["gawai", "smartphone", "perangkat digital"],
|
| 152 |
+
"media sosial": ["medsos", "sosmed", "platform digital"],
|
| 153 |
+
"budaya literasi": ["literacy culture", "tradisi membaca"],
|
| 154 |
+
"pendidikan": ["education", "pembelajaran", "sekolah"],
|
| 155 |
+
"akses buku": ["ketersediaan buku", "availability", "jangkauan buku"],
|
| 156 |
+
"kebiasaan": ["habit", "rutinitas", "pola"],
|
| 157 |
+
"digital reading": ["membaca digital", "e-book", "bacaan online"],
|
| 158 |
+
"perpustakaan": ["library", "taman bacaan", "pojok baca"],
|
| 159 |
+
"edukasi": ["education", "pembelajaran", "pengajaran"]
|
| 160 |
+
}
|
| 161 |
+
},
|
| 162 |
+
"9": {
|
| 163 |
+
"title": "Standar Kecantikan di Media Sosial Menyebabkan Krisis Percaya Diri",
|
| 164 |
+
"keywords": [
|
| 165 |
+
"body image", "filter", "influencer", "estetika",
|
| 166 |
+
"kesehatan mental", "tren", "citra diri", "autentisitas",
|
| 167 |
+
"tekanan sosial", "representasi"
|
| 168 |
+
],
|
| 169 |
+
"variants": {
|
| 170 |
+
"body image": ["citra tubuh", "penampilan fisik", "bentuk tubuh"],
|
| 171 |
+
"filter": ["filter wajah", "edit foto", "beautify"],
|
| 172 |
+
"influencer": ["content creator", "selebgram", "public figure"],
|
| 173 |
+
"estetika": ["aesthetic", "keindahan", "penampilan"],
|
| 174 |
+
"kesehatan mental": ["mental health", "psikologis", "kondisi jiwa"],
|
| 175 |
+
"tren": ["trend", "mode", "viral"],
|
| 176 |
+
"citra diri": ["self image", "harga diri", "kepercayaan diri"],
|
| 177 |
+
"autentisitas": ["keaslian", "authentic", "natural"],
|
| 178 |
+
"tekanan sosial": ["social pressure", "tuntutan sosial"],
|
| 179 |
+
"representasi": ["representation", "gambaran", "potret"]
|
| 180 |
+
}
|
| 181 |
+
},
|
| 182 |
+
"10": {
|
| 183 |
+
"title": "Hukuman untuk Pelaku Korupsi di Indonesia Masih Terlalu Ringan",
|
| 184 |
+
"keywords": [
|
| 185 |
+
"hukum", "integritas", "keadilan", "KPK",
|
| 186 |
+
"sistem peradilan", "sanksi", "efek jera", "moralitas",
|
| 187 |
+
"kepercayaan publik", "reformasi hukum"
|
| 188 |
+
],
|
| 189 |
+
"variants": {
|
| 190 |
+
"hukum": ["law", "peraturan", "regulasi", "legal"],
|
| 191 |
+
"integritas": ["kejujuran", "accountability", "transparansi"],
|
| 192 |
+
"keadilan": ["justice", "fairness", "adil"],
|
| 193 |
+
"KPK": ["komisi pemberantasan korupsi", "lembaga antikorupsi"],
|
| 194 |
+
"sistem peradilan": ["pengadilan", "judicial system", "proses hukum"],
|
| 195 |
+
"sanksi": ["hukuman", "punishment", "vonis"],
|
| 196 |
+
"efek jera": ["deterrent effect", "pembelajaran", "pencegahan"],
|
| 197 |
+
"moralitas": ["moral", "etika", "nilai"],
|
| 198 |
+
"kepercayaan publik": ["trust", "kredibilitas", "public trust"],
|
| 199 |
+
"reformasi hukum": ["perbaikan hukum", "pembaruan sistem"]
|
| 200 |
+
}
|
| 201 |
+
}
|
| 202 |
+
}
|
| 203 |
+
|
requirements.txt
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Core dependencies
|
| 2 |
+
fastapi==0.104.1
|
| 3 |
+
uvicorn[standard]==0.24.0
|
| 4 |
+
python-multipart==0.0.6
|
| 5 |
+
pydantic==2.5.0
|
| 6 |
+
pydantic-settings==2.1.0
|
| 7 |
+
|
| 8 |
+
# Redis and task queue
|
| 9 |
+
redis==5.0.1
|
| 10 |
+
rq==1.15.1
|
| 11 |
+
|
| 12 |
+
# AI/ML libraries
|
| 13 |
+
torch==2.1.0
|
| 14 |
+
torchaudio==2.1.0
|
| 15 |
+
transformers==4.35.0
|
| 16 |
+
whisper==1.1.10
|
| 17 |
+
openai-whisper==20231117
|
| 18 |
+
|
| 19 |
+
# NLP
|
| 20 |
+
sentence-transformers==2.2.2
|
| 21 |
+
scikit-learn==1.3.2
|
| 22 |
+
|
| 23 |
+
# Audio processing
|
| 24 |
+
librosa==0.10.1
|
| 25 |
+
soundfile==0.12.1
|
| 26 |
+
ffmpeg-python==0.2.0
|
| 27 |
+
|
| 28 |
+
# Data processing
|
| 29 |
+
pandas==2.1.3
|
| 30 |
+
numpy==1.24.3
|
| 31 |
+
|
| 32 |
+
# Utilities
|
| 33 |
+
python-dotenv==1.0.0
|
| 34 |
+
requests==2.31.0
|
| 35 |
+
|
| 36 |
+
# Optional for production
|
| 37 |
+
gunicorn==21.2.0
|
start.sh
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
|
| 3 |
+
echo "=========================================="
|
| 4 |
+
echo "Starting Swara API Services"
|
| 5 |
+
echo "=========================================="
|
| 6 |
+
|
| 7 |
+
# Fix OpenMP warning - set proper thread count
|
| 8 |
+
export OMP_NUM_THREADS=4
|
| 9 |
+
|
| 10 |
+
# Set environment variables for Redis (localhost since in same container)
|
| 11 |
+
export REDIS_HOST=localhost
|
| 12 |
+
export REDIS_PORT=6379
|
| 13 |
+
export REDIS_DB=0
|
| 14 |
+
|
| 15 |
+
# Start Redis in background with persistence DISABLED (in-memory only)
|
| 16 |
+
echo "[1/4] Starting Redis server (in-memory mode)..."
|
| 17 |
+
redis-server --daemonize yes --bind 127.0.0.1 --port 6379 \
|
| 18 |
+
--save "" \
|
| 19 |
+
--appendonly no \
|
| 20 |
+
--maxmemory 512mb \
|
| 21 |
+
--maxmemory-policy allkeys-lru
|
| 22 |
+
|
| 23 |
+
# Wait for Redis to be ready with timeout
|
| 24 |
+
echo "[2/4] Waiting for Redis to be ready..."
|
| 25 |
+
REDIS_TIMEOUT=30
|
| 26 |
+
ELAPSED=0
|
| 27 |
+
until redis-cli -h localhost -p 6379 ping 2>/dev/null | grep -q PONG; do
|
| 28 |
+
if [ $ELAPSED -ge $REDIS_TIMEOUT ]; then
|
| 29 |
+
echo "ERROR: Redis failed to start within ${REDIS_TIMEOUT}s"
|
| 30 |
+
exit 1
|
| 31 |
+
fi
|
| 32 |
+
echo " Waiting for Redis... (${ELAPSED}s)"
|
| 33 |
+
sleep 2
|
| 34 |
+
ELAPSED=$((ELAPSED + 2))
|
| 35 |
+
done
|
| 36 |
+
|
| 37 |
+
echo " ✓ Redis is ready!"
|
| 38 |
+
|
| 39 |
+
# Start RQ worker in background
|
| 40 |
+
echo "[3/4] Starting RQ Worker..."
|
| 41 |
+
python -m app.worker &
|
| 42 |
+
WORKER_PID=$!
|
| 43 |
+
echo " ✓ Worker started (PID: $WORKER_PID)"
|
| 44 |
+
|
| 45 |
+
# Give worker time to initialize
|
| 46 |
+
sleep 2
|
| 47 |
+
|
| 48 |
+
# Start FastAPI application
|
| 49 |
+
echo "[4/4] Starting FastAPI application..."
|
| 50 |
+
echo "=========================================="
|
| 51 |
+
echo "API will be available at:"
|
| 52 |
+
echo " http://localhost:7860"
|
| 53 |
+
echo " http://localhost:7860/docs (API Documentation)"
|
| 54 |
+
echo "=========================================="
|
| 55 |
+
|
| 56 |
+
uvicorn app.main:app --host 0.0.0.0 --port 7860
|
tempo.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
tempo.py
|
| 3 |
+
Analisis Tempo dan Jeda Bicara menggunakan Silero VAD
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import torch
|
| 7 |
+
import pandas as pd
|
| 8 |
+
from typing import Dict, List
|
| 9 |
+
import warnings
|
| 10 |
+
warnings.filterwarnings('ignore')
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class TempoAnalyzer:
|
| 14 |
+
"""Analisis tempo dan jeda bicara"""
|
| 15 |
+
|
| 16 |
+
def __init__(self):
|
| 17 |
+
"""Initialize Silero VAD model"""
|
| 18 |
+
print("🔄 Loading Silero VAD model...")
|
| 19 |
+
torch.set_num_threads(1)
|
| 20 |
+
self.model, utils = torch.hub.load(
|
| 21 |
+
repo_or_dir='snakers4/silero-vad',
|
| 22 |
+
model='silero_vad',
|
| 23 |
+
force_reload=False
|
| 24 |
+
)
|
| 25 |
+
(self.get_speech_timestamps,
|
| 26 |
+
self.save_audio,
|
| 27 |
+
self.read_audio,
|
| 28 |
+
self.VADIterator,
|
| 29 |
+
self.collect_chunks) = utils
|
| 30 |
+
print("✅ Silero VAD model loaded!\n")
|
| 31 |
+
|
| 32 |
+
def analyze_tempo(self, audio_path: str, sampling_rate: int = 16000) -> Dict:
|
| 33 |
+
"""
|
| 34 |
+
Analisis tempo dan jeda dari file audio
|
| 35 |
+
|
| 36 |
+
Args:
|
| 37 |
+
audio_path: Path ke file audio
|
| 38 |
+
sampling_rate: Sample rate audio (default: 16000)
|
| 39 |
+
|
| 40 |
+
Returns:
|
| 41 |
+
Dict berisi hasil analisis lengkap
|
| 42 |
+
"""
|
| 43 |
+
print(f"🎧 Analyzing tempo: {audio_path}")
|
| 44 |
+
|
| 45 |
+
# Load audio
|
| 46 |
+
wav = self.read_audio(audio_path)
|
| 47 |
+
|
| 48 |
+
# Deteksi segmen bicara
|
| 49 |
+
speech_timestamps = self.get_speech_timestamps(
|
| 50 |
+
wav, self.model, sampling_rate=sampling_rate
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
# Buat daftar data analisis
|
| 54 |
+
data = []
|
| 55 |
+
total_pause = 0
|
| 56 |
+
total_score = 0
|
| 57 |
+
num_pauses = 0
|
| 58 |
+
|
| 59 |
+
for i, seg in enumerate(speech_timestamps):
|
| 60 |
+
start_time = seg['start'] / sampling_rate
|
| 61 |
+
end_time = seg['end'] / sampling_rate
|
| 62 |
+
duration = end_time - start_time
|
| 63 |
+
|
| 64 |
+
if i == 0:
|
| 65 |
+
pause_before = start_time # jeda awal sebelum bicara pertama
|
| 66 |
+
else:
|
| 67 |
+
pause_before = start_time - (speech_timestamps[i - 1]['end'] / sampling_rate)
|
| 68 |
+
|
| 69 |
+
# Hitung skor jeda (0 atau 1)
|
| 70 |
+
# Jika jeda <= 3 detik → 1, jika > 3 detik → 0
|
| 71 |
+
skor = 1 if pause_before <= 3.0 else 0
|
| 72 |
+
|
| 73 |
+
total_pause += pause_before
|
| 74 |
+
total_score += skor
|
| 75 |
+
num_pauses += 1
|
| 76 |
+
|
| 77 |
+
data.append({
|
| 78 |
+
'Segmen': i + 1,
|
| 79 |
+
'Mulai (detik)': round(start_time, 2),
|
| 80 |
+
'Selesai (detik)': round(end_time, 2),
|
| 81 |
+
'Durasi Bicara (detik)': round(duration, 2),
|
| 82 |
+
'Jeda Sebelum (detik)': round(pause_before, 2),
|
| 83 |
+
'Skor Jeda': skor
|
| 84 |
+
})
|
| 85 |
+
|
| 86 |
+
# Hitung rata-rata jeda dan skor
|
| 87 |
+
rata_jeda = total_pause / num_pauses if num_pauses > 0 else 0
|
| 88 |
+
rata_skor = total_score / num_pauses if num_pauses > 0 else 0
|
| 89 |
+
|
| 90 |
+
# Tentukan kategori
|
| 91 |
+
if rata_skor >= 0.9:
|
| 92 |
+
kategori = "Sangat Baik"
|
| 93 |
+
poin = 5
|
| 94 |
+
elif rata_skor >= 0.7:
|
| 95 |
+
kategori = "Baik"
|
| 96 |
+
poin = 4
|
| 97 |
+
elif rata_skor >= 0.5:
|
| 98 |
+
kategori = "Cukup"
|
| 99 |
+
poin = 3
|
| 100 |
+
elif rata_skor >= 0.3:
|
| 101 |
+
kategori = "Buruk"
|
| 102 |
+
poin = 2
|
| 103 |
+
else:
|
| 104 |
+
kategori = "Perlu Ditingkatkan"
|
| 105 |
+
poin = 1
|
| 106 |
+
|
| 107 |
+
print("✅ Tempo analysis complete!\n")
|
| 108 |
+
|
| 109 |
+
return {
|
| 110 |
+
'segments': data,
|
| 111 |
+
'total_segments': len(speech_timestamps),
|
| 112 |
+
'rata_rata_jeda': round(rata_jeda, 2),
|
| 113 |
+
'rata_rata_skor': round(rata_skor, 2),
|
| 114 |
+
'kategori': kategori,
|
| 115 |
+
'poin': poin,
|
| 116 |
+
'summary': {
|
| 117 |
+
'score': poin,
|
| 118 |
+
'category': kategori,
|
| 119 |
+
'avg_pause': round(rata_jeda, 2),
|
| 120 |
+
'avg_score': round(rata_skor, 2),
|
| 121 |
+
'total_segments': len(speech_timestamps)
|
| 122 |
+
}
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
def print_report(self, result: Dict):
|
| 126 |
+
"""Print detailed report"""
|
| 127 |
+
df = pd.DataFrame(result['segments'])
|
| 128 |
+
|
| 129 |
+
print("\n" + "="*70)
|
| 130 |
+
print("📊 ANALISIS TEMPO DAN JEDA BICARA")
|
| 131 |
+
print("="*70)
|
| 132 |
+
print(df.to_string(index=False))
|
| 133 |
+
print("\n" + "="*70)
|
| 134 |
+
print(f"Total Segmen Bicara : {result['total_segments']}")
|
| 135 |
+
print(f"Rata-rata Jeda (detik) : {result['rata_rata_jeda']}")
|
| 136 |
+
print(f"Rata-rata Skor Jeda : {result['rata_rata_skor']}/1")
|
| 137 |
+
print(f"Kategori : {result['kategori']}")
|
| 138 |
+
print(f"Poin : {result['poin']}/5")
|
| 139 |
+
print("="*70 + "\n")
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
# ========== DEMO ==========
|
| 143 |
+
|
| 144 |
+
def demo():
|
| 145 |
+
"""Demo function"""
|
| 146 |
+
analyzer = TempoAnalyzer()
|
| 147 |
+
|
| 148 |
+
audio_path = "./bad.wav"
|
| 149 |
+
result = analyzer.analyze_tempo(audio_path)
|
| 150 |
+
analyzer.print_report(result)
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
if __name__ == "__main__":
|
| 154 |
+
demo()
|
upload_model_to_hf.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Script untuk upload best_model ke Hugging Face Hub
|
| 3 |
+
Run sekali saja untuk upload model
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from huggingface_hub import HfApi, create_repo, login
|
| 7 |
+
import os
|
| 8 |
+
|
| 9 |
+
# Konfigurasi
|
| 10 |
+
MODEL_PATH = "./best_model" # Path ke model lokal
|
| 11 |
+
REPO_NAME = "Cyberlace/swara-structure-model" # Nama repository di HF Hub
|
| 12 |
+
|
| 13 |
+
def upload_model():
|
| 14 |
+
"""Upload model ke Hugging Face Hub"""
|
| 15 |
+
|
| 16 |
+
print("=" * 70)
|
| 17 |
+
print("📦 Uploading Structure Model to Hugging Face Hub")
|
| 18 |
+
print("=" * 70)
|
| 19 |
+
|
| 20 |
+
# Step 1: Check if already logged in
|
| 21 |
+
print("\n🔐 Step 1: Checking Hugging Face authentication")
|
| 22 |
+
|
| 23 |
+
from huggingface_hub import HfFolder
|
| 24 |
+
token = HfFolder.get_token()
|
| 25 |
+
|
| 26 |
+
if token is None:
|
| 27 |
+
print("❌ Not logged in!")
|
| 28 |
+
print("\n💡 Please login first:")
|
| 29 |
+
print(" Run: huggingface-cli login")
|
| 30 |
+
return
|
| 31 |
+
|
| 32 |
+
print("✅ Already logged in!")
|
| 33 |
+
|
| 34 |
+
# Step 2: Buat repository (jika belum ada)
|
| 35 |
+
print(f"\n📁 Step 2: Creating repository: {REPO_NAME}")
|
| 36 |
+
try:
|
| 37 |
+
create_repo(
|
| 38 |
+
repo_id=REPO_NAME,
|
| 39 |
+
repo_type="model",
|
| 40 |
+
exist_ok=True # Skip jika sudah ada
|
| 41 |
+
)
|
| 42 |
+
print("✅ Repository ready!")
|
| 43 |
+
except Exception as e:
|
| 44 |
+
print(f"⚠️ Repository might already exist: {e}")
|
| 45 |
+
|
| 46 |
+
# Step 3: Upload semua files di best_model
|
| 47 |
+
print(f"\n📤 Step 3: Uploading model files from {MODEL_PATH}")
|
| 48 |
+
|
| 49 |
+
api = HfApi()
|
| 50 |
+
|
| 51 |
+
# List semua files di best_model
|
| 52 |
+
files_to_upload = []
|
| 53 |
+
for root, dirs, files in os.walk(MODEL_PATH):
|
| 54 |
+
for file in files:
|
| 55 |
+
file_path = os.path.join(root, file)
|
| 56 |
+
# Relative path untuk upload
|
| 57 |
+
path_in_repo = os.path.relpath(file_path, MODEL_PATH)
|
| 58 |
+
files_to_upload.append((file_path, path_in_repo))
|
| 59 |
+
|
| 60 |
+
print(f" Found {len(files_to_upload)} files to upload:")
|
| 61 |
+
for file_path, path_in_repo in files_to_upload:
|
| 62 |
+
file_size = os.path.getsize(file_path) / (1024 * 1024) # MB
|
| 63 |
+
print(f" - {path_in_repo} ({file_size:.2f} MB)")
|
| 64 |
+
|
| 65 |
+
# Upload files
|
| 66 |
+
print("\n⏳ Uploading files...")
|
| 67 |
+
try:
|
| 68 |
+
for file_path, path_in_repo in files_to_upload:
|
| 69 |
+
print(f" Uploading {path_in_repo}...", end=" ")
|
| 70 |
+
api.upload_file(
|
| 71 |
+
path_or_fileobj=file_path,
|
| 72 |
+
path_in_repo=path_in_repo,
|
| 73 |
+
repo_id=REPO_NAME,
|
| 74 |
+
repo_type="model"
|
| 75 |
+
)
|
| 76 |
+
print("✅")
|
| 77 |
+
|
| 78 |
+
print("\n🎉 Upload complete!")
|
| 79 |
+
print(f"📍 Model URL: https://huggingface.co/{REPO_NAME}")
|
| 80 |
+
|
| 81 |
+
except Exception as e:
|
| 82 |
+
print(f"\n❌ Upload failed: {e}")
|
| 83 |
+
return
|
| 84 |
+
|
| 85 |
+
# Step 4: Create README
|
| 86 |
+
print("\n📝 Step 4: Creating README.md")
|
| 87 |
+
readme_content = f"""---
|
| 88 |
+
language:
|
| 89 |
+
- id
|
| 90 |
+
license: apache-2.0
|
| 91 |
+
tags:
|
| 92 |
+
- text-classification
|
| 93 |
+
- indonesian
|
| 94 |
+
- speech-structure
|
| 95 |
+
- bert
|
| 96 |
+
datasets:
|
| 97 |
+
- custom
|
| 98 |
+
---
|
| 99 |
+
|
| 100 |
+
# Swara Structure Analysis Model
|
| 101 |
+
|
| 102 |
+
BERT model untuk analisis struktur berbicara (opening, content, closing) dalam Bahasa Indonesia.
|
| 103 |
+
|
| 104 |
+
## Model Description
|
| 105 |
+
|
| 106 |
+
Model ini dilatih untuk mengklasifikasikan kalimat dalam pidato/presentasi menjadi 3 kategori:
|
| 107 |
+
- **Opening**: Pembukaan (salam, perkenalan, pengantar)
|
| 108 |
+
- **Content**: Isi utama (poin-poin, argumen, penjelasan)
|
| 109 |
+
- **Closing**: Penutup (kesimpulan, ucapan terima kasih)
|
| 110 |
+
|
| 111 |
+
## Usage
|
| 112 |
+
|
| 113 |
+
```python
|
| 114 |
+
from transformers import BertTokenizer, BertForSequenceClassification
|
| 115 |
+
import torch
|
| 116 |
+
|
| 117 |
+
# Load model
|
| 118 |
+
model_name = "{REPO_NAME}"
|
| 119 |
+
tokenizer = BertTokenizer.from_pretrained(model_name)
|
| 120 |
+
model = BertForSequenceClassification.from_pretrained(model_name)
|
| 121 |
+
|
| 122 |
+
# Predict
|
| 123 |
+
text = "Selamat pagi hadirin sekalian"
|
| 124 |
+
inputs = tokenizer(text, return_tensors="pt", padding=True, truncation=True, max_length=128)
|
| 125 |
+
|
| 126 |
+
with torch.no_grad():
|
| 127 |
+
outputs = model(**inputs)
|
| 128 |
+
probs = torch.nn.functional.softmax(outputs.logits, dim=-1)
|
| 129 |
+
predicted_class = torch.argmax(probs, dim=1).item()
|
| 130 |
+
|
| 131 |
+
labels = {{0: "opening", 1: "content", 2: "closing"}}
|
| 132 |
+
print(f"Predicted: {{labels[predicted_class]}}")
|
| 133 |
+
```
|
| 134 |
+
|
| 135 |
+
## Training Data
|
| 136 |
+
|
| 137 |
+
Model dilatih dengan dataset pidato dan presentasi dalam Bahasa Indonesia.
|
| 138 |
+
|
| 139 |
+
## Intended Use
|
| 140 |
+
|
| 141 |
+
Model ini digunakan dalam sistem analisis public speaking untuk:
|
| 142 |
+
- Evaluasi struktur presentasi
|
| 143 |
+
- Feedback otomatis untuk pembicara
|
| 144 |
+
- Training public speaking
|
| 145 |
+
"""
|
| 146 |
+
|
| 147 |
+
try:
|
| 148 |
+
api.upload_file(
|
| 149 |
+
path_or_fileobj=readme_content.encode('utf-8'),
|
| 150 |
+
path_in_repo="README.md",
|
| 151 |
+
repo_id=REPO_NAME,
|
| 152 |
+
repo_type="model"
|
| 153 |
+
)
|
| 154 |
+
print("✅ README created!")
|
| 155 |
+
except Exception as e:
|
| 156 |
+
print(f"⚠️ README creation failed: {e}")
|
| 157 |
+
|
| 158 |
+
print("\n" + "=" * 70)
|
| 159 |
+
print("✅ ALL DONE!")
|
| 160 |
+
print("=" * 70)
|
| 161 |
+
print(f"\n📍 Model Repository: https://huggingface.co/{REPO_NAME}")
|
| 162 |
+
print("\n💡 Next steps:")
|
| 163 |
+
print(" 1. Update app/services/structure.py to use this model")
|
| 164 |
+
print(" 2. Remove best_model/ from your Space repository")
|
| 165 |
+
print(" 3. Deploy and test")
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
if __name__ == "__main__":
|
| 169 |
+
# Check if best_model exists
|
| 170 |
+
if not os.path.exists(MODEL_PATH):
|
| 171 |
+
print(f"❌ Error: Model path not found: {MODEL_PATH}")
|
| 172 |
+
print(" Please make sure best_model/ directory exists")
|
| 173 |
+
exit(1)
|
| 174 |
+
|
| 175 |
+
upload_model()
|