fariedalfarizi commited on
Commit
c7e434a
·
0 Parent(s):

Add profanity detection feature with 150+ Indonesian/English words

Browse files
.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()