Upload project with bundled WavLM model
Browse files- .gitignore +37 -0
- README.md +335 -0
- app.py +612 -0
- audio_loader.py +331 -0
- correlation_analysis.py +157 -0
- create_dataset.py +42 -0
- dataset_final.csv +101 -0
- dtw_similarity.py +282 -0
- requirements.txt +22 -0
- run_similarity.py +331 -0
- scoring.py +231 -0
- wavlm-base-plus/config.json +121 -0
- wavlm-base-plus/model.safetensors +3 -0
- wavlm-base-plus/preprocessor_config.json +9 -0
- wavlm_encoder.py +140 -0
.gitignore
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
.venv/
|
| 26 |
+
env/
|
| 27 |
+
.env
|
| 28 |
+
|
| 29 |
+
# IDEs
|
| 30 |
+
.vscode/
|
| 31 |
+
.idea/
|
| 32 |
+
*.swp
|
| 33 |
+
*.swo
|
| 34 |
+
|
| 35 |
+
# OS / Temp
|
| 36 |
+
.DS_Store
|
| 37 |
+
Thumbs.db
|
README.md
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Metode Penilaian Kemiripan Bacaan Al-Qur'an pada Pembelajaran DIROSA Menggunakan Representasi Audio WavLM dan Dynamic Time Warping
|
| 2 |
+
|
| 3 |
+
Repositori ini berisi implementasi dari penelitian skripsi berjudul **"Metode Penilaian Kemiripan Bacaan Al-Qur'an pada Pembelajaran DIROSA Menggunakan Representasi Audio WavLM dan Dynamic Time Warping"**.
|
| 4 |
+
|
| 5 |
+
Metode yang diajukan mengukur seberapa mirip bacaan Al-Qur'an seorang peserta DIROSA terhadap bacaan referensi, melalui pendekatan berbasis *deep audio representation learning*. Fitur audio diekstraksi menggunakan model pretrained **WavLM**, kemudian dibandingkan secara sekuensial menggunakan **Dynamic Time Warping (DTW)** untuk menghasilkan skor kemiripan.
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## Daftar Isi
|
| 10 |
+
|
| 11 |
+
- [Gambaran Umum](#gambaran-umum)
|
| 12 |
+
- [Arsitektur Pipeline](#arsitektur-pipeline)
|
| 13 |
+
- [Struktur Direktori](#struktur-direktori)
|
| 14 |
+
- [Prasyarat](#prasyarat)
|
| 15 |
+
- [Instalasi](#instalasi)
|
| 16 |
+
- [Penggunaan](#penggunaan)
|
| 17 |
+
- [Antarmuka Web (Streamlit)](#antarmuka-web-streamlit)
|
| 18 |
+
- [Command Line Interface](#command-line-interface)
|
| 19 |
+
- [Dataset Audio](#dataset-audio)
|
| 20 |
+
- [Penjelasan Modul](#penjelasan-modul)
|
| 21 |
+
- [Konfigurasi dan Parameter](#konfigurasi-dan-parameter)
|
| 22 |
+
- [Analisis Korelasi](#analisis-korelasi)
|
| 23 |
+
- [Lisensi](#lisensi)
|
| 24 |
+
|
| 25 |
+
---
|
| 26 |
+
|
| 27 |
+
## Gambaran Umum
|
| 28 |
+
|
| 29 |
+
Metode ini membandingkan dua rekaman audio bacaan Al-Qur'an dan menghasilkan skor kemiripan pada skala 0--100. Pipeline pemrosesan meliputi empat tahap utama:
|
| 30 |
+
|
| 31 |
+
1. **Pemuatan dan pra-pemrosesan audio** -- resampling ke 16 kHz, konversi mono, VAD endpoint trimming, dan normalisasi amplitudo.
|
| 32 |
+
2. **Ekstraksi fitur** -- menggunakan model pretrained WavLM Base Plus (tersimpan lokal di folder `wavlm-base-plus/`) untuk menghasilkan representasi frame-level dari 12 layer transformer.
|
| 33 |
+
3. **Pencocokan sekuens** -- Dynamic Time Warping dengan jarak cosine dan batasan Sakoe-Chiba band.
|
| 34 |
+
4. **Penilaian** -- konversi jarak DTW ternormalisasi ke skor 0--100 melalui pemetaan logistik (sigmoid) yang telah dikalibrasi.
|
| 35 |
+
|
| 36 |
+
---
|
| 37 |
+
|
| 38 |
+
## Arsitektur Pipeline
|
| 39 |
+
|
| 40 |
+
```
|
| 41 |
+
Audio WAV (Peserta) ──┐
|
| 42 |
+
├──> AudioLoader ──> WavLMEncoder ──> DTWSimilarity ──> SimilarityScorer
|
| 43 |
+
Audio WAV (Referensi) ─┘ (16 kHz, (frame-level (cosine DTW, (sigmoid score
|
| 44 |
+
mono, VAD, features, Sakoe-Chiba) 0-100)
|
| 45 |
+
normalisasi) layer 1-12)
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
---
|
| 49 |
+
|
| 50 |
+
## Struktur Direktori
|
| 51 |
+
|
| 52 |
+
```
|
| 53 |
+
kode_inti_yudisium/
|
| 54 |
+
|
|
| 55 |
+
|-- app.py # Antarmuka web Streamlit (single, batch, korelasi)
|
| 56 |
+
|-- run_similarity.py # CLI untuk single/batch processing
|
| 57 |
+
|-- scoring.py # Orchestrator pipeline end-to-end
|
| 58 |
+
|-- wavlm_encoder.py # Ekstraksi fitur WavLM (frozen, multi-layer)
|
| 59 |
+
|-- dtw_similarity.py # DTW dengan Sakoe-Chiba band dan konversi skor
|
| 60 |
+
|-- audio_loader.py # Pemuatan audio, VAD trimming, normalisasi
|
| 61 |
+
|-- correlation_analysis.py # Analisis korelasi Spearman/Pearson dan visualisasi
|
| 62 |
+
|-- create_dataset.py # Skrip pembuatan dataset final (merge skor + rating)
|
| 63 |
+
|-- dataset_final.csv # Dataset gabungan skor sistem dan rating Ustadz
|
| 64 |
+
|-- requirements.txt # Daftar dependensi Python
|
| 65 |
+
|
|
| 66 |
+
|-- audio referensi/ # 20 file WAV bacaan referensi (Pertemuan 1-20)
|
| 67 |
+
|-- audio peserta/ # Rekaman peserta, terorganisir per subfolder
|
| 68 |
+
| |-- peserta 1/
|
| 69 |
+
| |-- peserta 2/
|
| 70 |
+
| |-- peserta 3/
|
| 71 |
+
| |-- peserta 4/
|
| 72 |
+
| |-- peserta 5/
|
| 73 |
+
|
|
| 74 |
+
|-- README.md
|
| 75 |
+
```
|
| 76 |
+
|
| 77 |
+
---
|
| 78 |
+
|
| 79 |
+
## Prasyarat
|
| 80 |
+
|
| 81 |
+
- **Python** 3.9 atau lebih baru
|
| 82 |
+
- **PyTorch** dengan dukungan CUDA (opsional, untuk akselerasi GPU)
|
| 83 |
+
- Koneksi internet diperlukan saat pertama kali menjalankan program untuk mengunduh model WavLM dari HuggingFace Hub
|
| 84 |
+
|
| 85 |
+
> Model WavLM secara otomatis berjalan di GPU jika PyTorch CUDA tersedia.
|
| 86 |
+
> Jika tidak, sistem akan fallback ke CPU secara transparan.
|
| 87 |
+
|
| 88 |
+
---
|
| 89 |
+
|
| 90 |
+
## Instalasi
|
| 91 |
+
|
| 92 |
+
1. Clone atau unduh repositori ini.
|
| 93 |
+
|
| 94 |
+
2. Buat virtual environment (direkomendasikan):
|
| 95 |
+
|
| 96 |
+
```bash
|
| 97 |
+
python -m venv venv
|
| 98 |
+
# Windows
|
| 99 |
+
venv\Scripts\activate
|
| 100 |
+
# Linux / macOS
|
| 101 |
+
source venv/bin/activate
|
| 102 |
+
```
|
| 103 |
+
|
| 104 |
+
3. Install dependensi:
|
| 105 |
+
|
| 106 |
+
```bash
|
| 107 |
+
pip install -r requirements.txt
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
Dependensi utama:
|
| 111 |
+
|
| 112 |
+
| Paket | Versi | Fungsi |
|
| 113 |
+
|--------------------|----------|---------------------------------------------|
|
| 114 |
+
| `torch` | 2.12.0 | Backend deep learning, komputasi tensor |
|
| 115 |
+
| `torchaudio` | 2.11.0 | Pemuatan dan resampling audio |
|
| 116 |
+
| `transformers` | 4.57.3 | Model WavLM dari HuggingFace |
|
| 117 |
+
| `numpy` | 2.3.5 | Operasi numerik dan array |
|
| 118 |
+
| `scipy` | 1.16.3 | Uji statistik (Spearman, Pearson) |
|
| 119 |
+
| `pandas` | 2.3.3 | Manipulasi data tabular |
|
| 120 |
+
| `soundfile` | 0.13.1 | Pembacaan file audio WAV (fallback loader) |
|
| 121 |
+
| `webrtcvad-wheels` | 2.0.14 | Voice Activity Detection (endpoint trimming)|
|
| 122 |
+
| `streamlit` | 1.52.2 | Antarmuka web interaktif |
|
| 123 |
+
| `matplotlib` | 3.10.8 | Visualisasi grafik dan plot |
|
| 124 |
+
|
| 125 |
+
---
|
| 126 |
+
|
| 127 |
+
## Penggunaan
|
| 128 |
+
|
| 129 |
+
### Antarmuka Web (Streamlit)
|
| 130 |
+
|
| 131 |
+
Jalankan aplikasi web interaktif:
|
| 132 |
+
|
| 133 |
+
```bash
|
| 134 |
+
streamlit run app.py
|
| 135 |
+
```
|
| 136 |
+
|
| 137 |
+
Aplikasi menyediakan tiga tab utama:
|
| 138 |
+
|
| 139 |
+
| Tab | Fungsi |
|
| 140 |
+
|----------------------------------|----------------------------------------------------------------------------------------|
|
| 141 |
+
| **Single Processing** | Upload dua file audio (referensi dan peserta), pilih layer, lihat skor dan visualisasi |
|
| 142 |
+
| **Batch Processing (Folder)** | Proses seluruh folder `audio peserta/` terhadap `audio referensi/`, ekspor CSV |
|
| 143 |
+
| **Analisis Korelasi (Overview)** | Visualisasi hubungan skor sistem vs rating Ustadz (Spearman, Pearson, heatmap) |
|
| 144 |
+
|
| 145 |
+
Fitur antarmuka:
|
| 146 |
+
|
| 147 |
+
- Preview waveform sebelum dan sesudah pra-pemrosesan (VAD + normalisasi)
|
| 148 |
+
- Pemilihan layer WavLM (1--12) secara individual atau seluruhnya
|
| 149 |
+
- Visualisasi alignment path DTW dan cost matrix heatmap
|
| 150 |
+
- Diagnostik internal DTW (opsional)
|
| 151 |
+
- Interpretasi skor otomatis (Sangat Mirip, Mirip, Cukup Mirip, Kurang Mirip)
|
| 152 |
+
|
| 153 |
+
### Command Line Interface
|
| 154 |
+
|
| 155 |
+
**Mode Single** -- bandingkan dua file audio:
|
| 156 |
+
|
| 157 |
+
```bash
|
| 158 |
+
python run_similarity.py audio_peserta.wav audio_referensi.wav
|
| 159 |
+
```
|
| 160 |
+
|
| 161 |
+
Opsi tambahan:
|
| 162 |
+
|
| 163 |
+
```bash
|
| 164 |
+
python run_similarity.py audio1.wav audio2.wav --detailed --json
|
| 165 |
+
```
|
| 166 |
+
|
| 167 |
+
**Mode Batch** -- proses seluruh folder:
|
| 168 |
+
|
| 169 |
+
```bash
|
| 170 |
+
python run_similarity.py \
|
| 171 |
+
--participant-dir "audio peserta" \
|
| 172 |
+
--reference-dir "audio referensi" \
|
| 173 |
+
--recursive \
|
| 174 |
+
--output hasil_batch.csv
|
| 175 |
+
```
|
| 176 |
+
|
| 177 |
+
Parameter CLI yang tersedia:
|
| 178 |
+
|
| 179 |
+
| Parameter | Default | Keterangan |
|
| 180 |
+
|----------------------|-------------------------------|--------------------------------------------------|
|
| 181 |
+
| `--model` | `./wavlm-base-plus` | Model WavLM (path lokal, sudah tersedia di repo) |
|
| 182 |
+
| `--device` | auto-detect | Device komputasi (`cuda` / `cpu`) |
|
| 183 |
+
| `--distance` | `cosine` | Metrik jarak DTW (`cosine` / `euclidean`) |
|
| 184 |
+
| `--no-normalize` | _disabled_ | Nonaktifkan normalisasi jarak DTW |
|
| 185 |
+
| `--detailed` | _disabled_ | Tampilkan metrik detail (single mode) |
|
| 186 |
+
| `--json` | _disabled_ | Output dalam format JSON |
|
| 187 |
+
| `--recursive` | _disabled_ | Cari file WAV secara rekursif (batch mode) |
|
| 188 |
+
| `--output` | `similarity_results.csv` | Path output batch (`.csv` / `.xlsx`) |
|
| 189 |
+
|
| 190 |
+
---
|
| 191 |
+
|
| 192 |
+
## Dataset Audio
|
| 193 |
+
|
| 194 |
+
Dataset audio yang digunakan dalam penelitian ini terdiri dari 20 frasa bacaan DIROSA (Pertemuan 1--20) yang dibacakan oleh 5 peserta, masing-masing dibandingkan terhadap satu audio referensi per frasa.
|
| 195 |
+
|
| 196 |
+
**Unduh dataset audio:**
|
| 197 |
+
|
| 198 |
+
[https://drive.google.com/drive/folders/1wO7WvfKn4bnWfLxaVSHSOqn0psosf8oB?usp=sharing](https://drive.google.com/drive/folders/1wO7WvfKn4bnWfLxaVSHSOqn0psosf8oB?usp=sharing)
|
| 199 |
+
|
| 200 |
+
Setelah diunduh, letakkan isi folder sesuai struktur berikut:
|
| 201 |
+
|
| 202 |
+
```
|
| 203 |
+
kode_inti_yudisium/
|
| 204 |
+
|-- audio referensi/
|
| 205 |
+
| |-- Dirosa Pertemuan 1.wav
|
| 206 |
+
| |-- Dirosa Pertemuan 2.wav
|
| 207 |
+
| |-- ...
|
| 208 |
+
| |-- Dirosa Pertemuan 20.wav
|
| 209 |
+
|
|
| 210 |
+
|-- audio peserta/
|
| 211 |
+
| |-- peserta 1/
|
| 212 |
+
| | |-- Dirosa Pertemuan 1.wav
|
| 213 |
+
| | |-- Dirosa Pertemuan 2.wav
|
| 214 |
+
| | |-- ...
|
| 215 |
+
| |-- peserta 2/
|
| 216 |
+
| |-- ...
|
| 217 |
+
```
|
| 218 |
+
|
| 219 |
+
Format audio: **WAV, mono, 16-bit PCM**. Audio akan di-resample ke 16 kHz secara otomatis jika diperlukan.
|
| 220 |
+
|
| 221 |
+
---
|
| 222 |
+
|
| 223 |
+
## Penjelasan Modul
|
| 224 |
+
|
| 225 |
+
### `audio_loader.py` -- AudioLoader
|
| 226 |
+
|
| 227 |
+
Bertanggung jawab atas seluruh tahap pra-pemrosesan audio:
|
| 228 |
+
|
| 229 |
+
- **Pemuatan audio**: menggunakan `soundfile` sebagai loader utama (tanpa dependensi FFmpeg), dengan fallback ke `torchaudio`.
|
| 230 |
+
- **Resampling**: konversi otomatis ke 16 kHz (target sample rate WavLM).
|
| 231 |
+
- **Konversi mono**: audio stereo dirata-ratakan menjadi satu kanal.
|
| 232 |
+
- **VAD Endpoint Trimming**: menggunakan WebRTC VAD dengan mekanisme hysteresis (onset/offset) untuk menghapus segmen hening di awal dan akhir tanpa memotong jeda internal.
|
| 233 |
+
- **Energy Refinement**: pemangkasan berbasis RMS envelope untuk menghilangkan sisa noise atau napas yang lolos dari VAD.
|
| 234 |
+
- **Normalisasi amplitudo**: penskalaan waveform ke rentang [-1, 1].
|
| 235 |
+
|
| 236 |
+
### `wavlm_encoder.py` -- WavLMEncoder
|
| 237 |
+
|
| 238 |
+
Mengekstraksi fitur frame-level menggunakan model pretrained WavLM (frozen, tanpa fine-tuning):
|
| 239 |
+
|
| 240 |
+
- Mendukung ekstraksi dari satu layer tertentu atau beberapa layer sekaligus.
|
| 241 |
+
- Model berjalan di GPU secara otomatis jika CUDA tersedia.
|
| 242 |
+
- Pipeline PyTorch-only (TensorFlow/Flax dinonaktifkan secara eksplisit).
|
| 243 |
+
|
| 244 |
+
### `dtw_similarity.py` -- DTWSimilarity
|
| 245 |
+
|
| 246 |
+
Modul inti pencocokan sekuens:
|
| 247 |
+
|
| 248 |
+
- **Cost matrix**: jarak cosine atau Euclidean (vectorised).
|
| 249 |
+
- **DTW dengan Sakoe-Chiba band**: membatasi jalur warping untuk efisiensi dan menghindari alignment yang tidak realistis.
|
| 250 |
+
- **Backtracking**: rekonstruksi optimal warping path.
|
| 251 |
+
- **Konversi skor**: pemetaan logistik (sigmoid) dari jarak ternormalisasi ke skala 0--100.
|
| 252 |
+
|
| 253 |
+
Kalibrasi default (midpoint=0.3, steepness=10.0):
|
| 254 |
+
|
| 255 |
+
| Jarak (d) | Skor | Interpretasi |
|
| 256 |
+
|--------------|----------|---------------------|
|
| 257 |
+
| ~ 0.05 | ~ 92 | Sangat mirip |
|
| 258 |
+
| ~ 0.15 | ~ 82 | Mirip |
|
| 259 |
+
| ~ 0.30 | = 50 | Borderline |
|
| 260 |
+
| ~ 0.35 | ~ 38 | Kurang mirip |
|
| 261 |
+
| > 0.50 | < 12 | Sangat berbeda |
|
| 262 |
+
|
| 263 |
+
### `scoring.py` -- SimilarityScorer
|
| 264 |
+
|
| 265 |
+
Orchestrator yang menghubungkan seluruh komponen pipeline:
|
| 266 |
+
|
| 267 |
+
- `compute_similarity()` -- mengembalikan jarak DTW mentah.
|
| 268 |
+
- `compute_similarity_score_normalized()` -- mengembalikan skor 0--100.
|
| 269 |
+
- `compute_detailed_similarity()` -- mengembalikan hasil lengkap per layer termasuk warping path, cost matrix, diagnostik, dan waveform.
|
| 270 |
+
|
| 271 |
+
### `correlation_analysis.py`
|
| 272 |
+
|
| 273 |
+
Modul analisis statistik untuk validasi metode:
|
| 274 |
+
|
| 275 |
+
- Korelasi **Spearman** (monotonic) dan **Pearson** (linear) antara skor sistem dan rating Ustadz.
|
| 276 |
+
- Visualisasi: bar chart, scatter plot, heatmap, dan diagram pasangan frasa.
|
| 277 |
+
|
| 278 |
+
### `create_dataset.py`
|
| 279 |
+
|
| 280 |
+
Skrip utilitas untuk menggabungkan hasil batch processing (skor per layer) dengan rating manual Ustadz menjadi satu dataset (`dataset_final.csv`).
|
| 281 |
+
|
| 282 |
+
### `run_similarity.py`
|
| 283 |
+
|
| 284 |
+
CLI entry point yang mendukung mode single (dua file) dan batch (dua folder), dengan output CSV/XLSX.
|
| 285 |
+
|
| 286 |
+
### `app.py`
|
| 287 |
+
|
| 288 |
+
Antarmuka web berbasis Streamlit yang menyatukan seluruh fungsionalitas pipeline dalam tampilan interaktif.
|
| 289 |
+
|
| 290 |
+
---
|
| 291 |
+
|
| 292 |
+
## Konfigurasi dan Parameter
|
| 293 |
+
|
| 294 |
+
Parameter utama pipeline dikonfigurasi melalui `SimilarityScorer`:
|
| 295 |
+
|
| 296 |
+
| Parameter | Default | Keterangan |
|
| 297 |
+
|----------------------|-------------------------------|-----------------------------------------------------------|
|
| 298 |
+
| `model_name` | `./wavlm-base-plus` | Model WavLM (path lokal, sudah tersedia di repo) |
|
| 299 |
+
| `distance_metric` | `cosine` | Metrik jarak untuk DTW |
|
| 300 |
+
| `sakoe_chiba_ratio` | `0.1` | Lebar band Sakoe-Chiba (fraksi dari panjang sekuens) |
|
| 301 |
+
| `normalize_dtw` | `True` | Normalisasi jarak DTW berdasarkan panjang warping path |
|
| 302 |
+
| `score_midpoint` | `0.3` | Titik tengah sigmoid (jarak yang menghasilkan skor 50) |
|
| 303 |
+
| `score_steepness` | `10.0` | Ketajaman transisi sigmoid |
|
| 304 |
+
|
| 305 |
+
Parameter VAD (dikonfigurasi melalui `AudioLoader`):
|
| 306 |
+
|
| 307 |
+
| Parameter | Default | Keterangan |
|
| 308 |
+
|--------------------------|---------|-----------------------------------------------------------------|
|
| 309 |
+
| `vad_mode` | `1` | Agresivitas WebRTC VAD (0--3, 1--2 direkomendasikan) |
|
| 310 |
+
| `vad_frame_ms` | `10` | Panjang frame VAD dalam milidetik (10, 20, atau 30) |
|
| 311 |
+
| `vad_onset_frames` | `2` | Jumlah frame voiced berturut-turut untuk mendeteksi onset |
|
| 312 |
+
| `vad_offset_frames` | `4` | Jumlah frame unvoiced berturut-turut untuk mendeteksi offset |
|
| 313 |
+
| `energy_trim_threshold` | `0.06` | Threshold energi untuk pemangkasan tambahan pasca-VAD |
|
| 314 |
+
|
| 315 |
+
---
|
| 316 |
+
|
| 317 |
+
## Analisis Korelasi
|
| 318 |
+
|
| 319 |
+
File `dataset_final.csv` berisi 101 pasangan data (5 peserta x ~20 frasa) dengan kolom:
|
| 320 |
+
|
| 321 |
+
| Kolom | Keterangan |
|
| 322 |
+
|-----------------|-----------------------------------------------------|
|
| 323 |
+
| `ID_Pasangan` | Identifier unik pasangan peserta-frasa |
|
| 324 |
+
| `ID_Peserta` | ID peserta (1--5) |
|
| 325 |
+
| `ID_Frasa` | Nama file frasa (Dirosa Pertemuan X.wav) |
|
| 326 |
+
| `Score L1`--`Score L12` | Skor kemiripan dari masing-masing layer WavLM |
|
| 327 |
+
| `rating` | Penilaian manual Ustadz (ground truth) |
|
| 328 |
+
|
| 329 |
+
Analisis korelasi Spearman digunakan untuk mengidentifikasi layer WavLM yang paling berkorelasi dengan penilaian manusia, sehingga dapat dipilih representasi yang paling relevan secara perseptual.
|
| 330 |
+
|
| 331 |
+
---
|
| 332 |
+
|
| 333 |
+
## Lisensi
|
| 334 |
+
|
| 335 |
+
Proyek ini dikembangkan untuk keperluan akademis (skripsi). Model WavLM Base Plus (`wavlm-base-plus/`) bersumber dari [microsoft/wavlm-base-plus](https://huggingface.co/microsoft/wavlm-base-plus) dan mengikuti lisensi yang ditetapkan oleh Microsoft Research.
|
app.py
ADDED
|
@@ -0,0 +1,612 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Streamlit App - Metode Penilaian Kemiripan Bacaan Al-Qur'an
|
| 3 |
+
pada Pembelajaran DIROSA Menggunakan Representasi Audio WavLM dan DTW
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
import tempfile
|
| 8 |
+
import re
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
import matplotlib
|
| 12 |
+
matplotlib.use("Agg")
|
| 13 |
+
import matplotlib.pyplot as plt
|
| 14 |
+
import numpy as np
|
| 15 |
+
import pandas as pd
|
| 16 |
+
import streamlit as st
|
| 17 |
+
import torch
|
| 18 |
+
|
| 19 |
+
from scoring import SimilarityScorer
|
| 20 |
+
from correlation_analysis import (
|
| 21 |
+
run_correlation_analysis,
|
| 22 |
+
plot_correlation_bar,
|
| 23 |
+
plot_scatter_best_layer,
|
| 24 |
+
plot_heatmap,
|
| 25 |
+
plot_pairing_diagram
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
# ---------------------------------------------------------------------------
|
| 29 |
+
# Model caching
|
| 30 |
+
# ---------------------------------------------------------------------------
|
| 31 |
+
|
| 32 |
+
@st.cache_resource(show_spinner="Memuat model WavLM dan Pipeline ...")
|
| 33 |
+
def load_pipeline():
|
| 34 |
+
"""Load pipeline components once and cache them.
|
| 35 |
+
|
| 36 |
+
Model WavLM otomatis berjalan di GPU (CUDA) jika tersedia,
|
| 37 |
+
atau fallback ke CPU jika GPU tidak terdeteksi.
|
| 38 |
+
Deteksi device dilakukan oleh WavLMEncoder secara otomatis.
|
| 39 |
+
"""
|
| 40 |
+
scorer = SimilarityScorer(
|
| 41 |
+
model_name="./wavlm-base-plus",
|
| 42 |
+
distance_metric="cosine",
|
| 43 |
+
sakoe_chiba_ratio=0.1,
|
| 44 |
+
normalize_dtw=True
|
| 45 |
+
)
|
| 46 |
+
return scorer
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def get_device_info() -> str:
|
| 50 |
+
"""Kembalikan string info device yang sedang digunakan (GPU/CPU)."""
|
| 51 |
+
if torch.cuda.is_available():
|
| 52 |
+
gpu_name = torch.cuda.get_device_name(0)
|
| 53 |
+
return f"⚡ GPU — {gpu_name}"
|
| 54 |
+
return "🖥️ CPU (GPU tidak terdeteksi / PyTorch tanpa CUDA)"
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def run_pipeline(ref_path: str, test_path: str, use_vad: bool = True,
|
| 58 |
+
layer_indices: list = None):
|
| 59 |
+
"""Run full pipeline using SimilarityScorer."""
|
| 60 |
+
scorer = load_pipeline()
|
| 61 |
+
|
| 62 |
+
# Delegate the processing to SimilarityScorer's multi-layer handler
|
| 63 |
+
detailed_data = scorer.compute_detailed_similarity(
|
| 64 |
+
audio_path1=ref_path,
|
| 65 |
+
audio_path2=test_path,
|
| 66 |
+
use_vad=use_vad,
|
| 67 |
+
layer_indices=layer_indices
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
results = detailed_data["results"]
|
| 71 |
+
waveforms = detailed_data["waveforms"]
|
| 72 |
+
|
| 73 |
+
return (
|
| 74 |
+
results,
|
| 75 |
+
waveforms["ref_raw"],
|
| 76 |
+
waveforms["ref_vad"],
|
| 77 |
+
waveforms.get("ref_normalized", waveforms["ref_vad"]),
|
| 78 |
+
waveforms["test_raw"],
|
| 79 |
+
waveforms["test_vad"],
|
| 80 |
+
waveforms.get("test_normalized", waveforms["test_vad"]),
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def plot_alignment(warping_path):
|
| 86 |
+
"""Create a simple DTW alignment line plot."""
|
| 87 |
+
path = np.array(warping_path)
|
| 88 |
+
fig, ax = plt.subplots(figsize=(6, 3.5))
|
| 89 |
+
ax.plot(path[:, 0], path[:, 1], linewidth=0.8, color="black")
|
| 90 |
+
ax.set_xlabel("Frame Referensi")
|
| 91 |
+
ax.set_ylabel("Frame Peserta")
|
| 92 |
+
ax.set_title("Alignment DTW")
|
| 93 |
+
fig.tight_layout()
|
| 94 |
+
return fig
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def plot_dtw_heatmap(dtw_matrix: np.ndarray, warping_path):
|
| 98 |
+
"""Heatmap of the accumulated DTW cost matrix with the warping path overlay."""
|
| 99 |
+
# Remove the padding row/col used during DP (index 0)
|
| 100 |
+
matrix = dtw_matrix[1:, 1:]
|
| 101 |
+
# Replace inf with max finite value for colour mapping
|
| 102 |
+
finite_vals = matrix[np.isfinite(matrix)]
|
| 103 |
+
if finite_vals.size > 0:
|
| 104 |
+
matrix = np.where(np.isfinite(matrix), matrix, finite_vals.max())
|
| 105 |
+
|
| 106 |
+
path = np.array(warping_path)
|
| 107 |
+
fig, ax = plt.subplots(figsize=(6, 5))
|
| 108 |
+
im = ax.imshow(matrix.T, origin="lower", aspect="auto", cmap="magma_r",
|
| 109 |
+
interpolation="nearest")
|
| 110 |
+
ax.plot(path[:, 0], path[:, 1], color="cyan", linewidth=1.0, alpha=0.85)
|
| 111 |
+
ax.set_xlabel("Frame Referensi")
|
| 112 |
+
ax.set_ylabel("Frame Peserta")
|
| 113 |
+
ax.set_title("DTW Cost Matrix & Warping Path")
|
| 114 |
+
fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04, label="Accumulated Cost")
|
| 115 |
+
fig.tight_layout()
|
| 116 |
+
return fig
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def plot_waveforms(
|
| 120 |
+
raw: np.ndarray,
|
| 121 |
+
vad: np.ndarray | None,
|
| 122 |
+
normalized: np.ndarray | None,
|
| 123 |
+
sr: int,
|
| 124 |
+
title: str,
|
| 125 |
+
):
|
| 126 |
+
"""Plot two preprocessing stages vertically for thesis report.
|
| 127 |
+
|
| 128 |
+
Stages shown:
|
| 129 |
+
1. Sebelum Pre-processing (raw waveform)
|
| 130 |
+
2. Setelah Pre-processing Lengkap (VAD + normalisasi amplitudo)
|
| 131 |
+
|
| 132 |
+
All subplots share a fixed Y-axis [-1, 1] so amplitude differences
|
| 133 |
+
before/after normalisation are visually obvious.
|
| 134 |
+
"""
|
| 135 |
+
|
| 136 |
+
def _fmt_dur(n_samples: int) -> str:
|
| 137 |
+
"""Format duration string: 'Durasi: xx,xx detik'."""
|
| 138 |
+
dur = n_samples / sr
|
| 139 |
+
return f"Durasi: {dur:,.2f} detik".replace(",", "X").replace(".", ",").replace("X", ".")
|
| 140 |
+
|
| 141 |
+
# Build stage list ---------------------------------------------------
|
| 142 |
+
stages: list[tuple[np.ndarray, str, str]] = []
|
| 143 |
+
|
| 144 |
+
# Stage 1 – Raw
|
| 145 |
+
stages.append((
|
| 146 |
+
raw,
|
| 147 |
+
f"{title} — Sebelum Pre-processing ({_fmt_dur(len(raw))})",
|
| 148 |
+
"#4A90D9",
|
| 149 |
+
))
|
| 150 |
+
|
| 151 |
+
# Stage 2 – After full pre-processing (normalised)
|
| 152 |
+
if normalized is not None:
|
| 153 |
+
stages.append((
|
| 154 |
+
normalized,
|
| 155 |
+
f"{title} — Setelah Pre-processing Lengkap ({_fmt_dur(len(normalized))})",
|
| 156 |
+
"#2ECC71",
|
| 157 |
+
))
|
| 158 |
+
elif vad is not None:
|
| 159 |
+
# Fallback: show VAD result as final stage if normalised is absent
|
| 160 |
+
stages.append((
|
| 161 |
+
vad,
|
| 162 |
+
f"{title} — Setelah Pre-processing Lengkap ({_fmt_dur(len(vad))})",
|
| 163 |
+
"#2ECC71",
|
| 164 |
+
))
|
| 165 |
+
|
| 166 |
+
n_plots = len(stages)
|
| 167 |
+
fig, axes = plt.subplots(
|
| 168 |
+
n_plots, 1,
|
| 169 |
+
figsize=(8, 1.7 * n_plots + 0.6),
|
| 170 |
+
sharex=False,
|
| 171 |
+
sharey=True,
|
| 172 |
+
constrained_layout=True,
|
| 173 |
+
)
|
| 174 |
+
if n_plots == 1:
|
| 175 |
+
axes = [axes]
|
| 176 |
+
|
| 177 |
+
for i, (data, label, color) in enumerate(stages):
|
| 178 |
+
ax = axes[i]
|
| 179 |
+
t = np.arange(len(data)) / sr
|
| 180 |
+
ax.plot(t, data, linewidth=0.35, color=color)
|
| 181 |
+
ax.set_title(label, fontsize=11, fontweight="bold", pad=6)
|
| 182 |
+
ax.set_ylabel("Amplitudo", fontsize=10)
|
| 183 |
+
ax.set_xlim(t[0], t[-1])
|
| 184 |
+
ax.set_ylim(-1, 1)
|
| 185 |
+
ax.tick_params(labelsize=9)
|
| 186 |
+
ax.grid(True, linewidth=0.3, alpha=0.5)
|
| 187 |
+
|
| 188 |
+
axes[-1].set_xlabel("Waktu (detik)", fontsize=10)
|
| 189 |
+
|
| 190 |
+
return fig
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def interpret_score(score: float) -> str:
|
| 194 |
+
"""Interpret normalized similarity score."""
|
| 195 |
+
if score >= 80:
|
| 196 |
+
return "Sangat Mirip (>80)"
|
| 197 |
+
elif score >= 65:
|
| 198 |
+
return "Mirip (65 - 80)"
|
| 199 |
+
elif score >= 50:
|
| 200 |
+
return "Cukup Mirip (50 - 65)"
|
| 201 |
+
else:
|
| 202 |
+
return "Kurang Mirip (<50)"
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
# ---------------------------------------------------------------------------
|
| 206 |
+
# UI
|
| 207 |
+
# ---------------------------------------------------------------------------
|
| 208 |
+
|
| 209 |
+
st.set_page_config(
|
| 210 |
+
page_title="Penilaian Kemiripan Bacaan Al-Qur'an - DIROSA WavLM-DTW",
|
| 211 |
+
layout="centered",
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
# Tampilkan info device di sidebar
|
| 215 |
+
with st.sidebar:
|
| 216 |
+
st.markdown("### ⚙️ Info Sistem")
|
| 217 |
+
st.info(f"**Device:** {get_device_info()}")
|
| 218 |
+
st.caption("Model WavLM berjalan di GPU jika PyTorch CUDA tersedia, " \
|
| 219 |
+
"atau fallback ke CPU secara otomatis.")
|
| 220 |
+
|
| 221 |
+
st.markdown(
|
| 222 |
+
"""
|
| 223 |
+
<style>
|
| 224 |
+
/* Mengubah max-width dari block container bawaan Streamlit */
|
| 225 |
+
.block-container {
|
| 226 |
+
max-width: 1000px !important;
|
| 227 |
+
}
|
| 228 |
+
</style>
|
| 229 |
+
""",
|
| 230 |
+
unsafe_allow_html=True
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
st.markdown(
|
| 234 |
+
"<h3 style='text-align:center;'>"
|
| 235 |
+
"Metode Penilaian Kemiripan Bacaan Al-Qur'an<br>"
|
| 236 |
+
"pada Pembelajaran DIROSA — WavLM + DTW"
|
| 237 |
+
"</h3>",
|
| 238 |
+
unsafe_allow_html=True,
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
st.divider()
|
| 242 |
+
|
| 243 |
+
tab1, tab2, tab3 = st.tabs(["Single Processing", "Batch Processing (Folder)", "Analisis Korelasi (Overview)"])
|
| 244 |
+
|
| 245 |
+
with tab1:
|
| 246 |
+
st.markdown("#### Uji Audio Individu")
|
| 247 |
+
|
| 248 |
+
# Upload section
|
| 249 |
+
col_ref, col_test = st.columns(2)
|
| 250 |
+
|
| 251 |
+
with col_ref:
|
| 252 |
+
st.subheader("Audio Referensi")
|
| 253 |
+
ref_file = st.file_uploader(
|
| 254 |
+
"Upload audio referensi",
|
| 255 |
+
type=["wav"],
|
| 256 |
+
key="ref",
|
| 257 |
+
label_visibility="collapsed",
|
| 258 |
+
)
|
| 259 |
+
|
| 260 |
+
with col_test:
|
| 261 |
+
st.subheader("Audio Peserta")
|
| 262 |
+
test_file = st.file_uploader(
|
| 263 |
+
"Upload audio peserta",
|
| 264 |
+
type=["wav"],
|
| 265 |
+
key="test",
|
| 266 |
+
label_visibility="collapsed",
|
| 267 |
+
)
|
| 268 |
+
|
| 269 |
+
st.write("") # spacer
|
| 270 |
+
|
| 271 |
+
use_vad = st.checkbox("Aktifkan VAD (Voice Activity Detection)", value=True, key="vad_single",
|
| 272 |
+
help="Menghapus bagian hening di awal dan akhir audio sebelum diproses.")
|
| 273 |
+
|
| 274 |
+
show_diagnostics = st.checkbox("Tampilkan diagnostik DTW", value=False,
|
| 275 |
+
help="Menampilkan metrik internal DTW untuk analisis lanjutan.")
|
| 276 |
+
|
| 277 |
+
st.write("")
|
| 278 |
+
st.markdown("#### Parameter Model")
|
| 279 |
+
select_all_layers = st.checkbox("Pilih Semua Layer (1-12)")
|
| 280 |
+
|
| 281 |
+
if select_all_layers:
|
| 282 |
+
sel_layers_single = list(range(1, 13))
|
| 283 |
+
st.multiselect(
|
| 284 |
+
"Pilih Layer WavLM",
|
| 285 |
+
options=list(range(1, 13)),
|
| 286 |
+
default=list(range(1, 13)),
|
| 287 |
+
disabled=True,
|
| 288 |
+
help="Semua layer telah dipilih."
|
| 289 |
+
)
|
| 290 |
+
else:
|
| 291 |
+
sel_layers_single = st.multiselect(
|
| 292 |
+
"Pilih Layer WavLM",
|
| 293 |
+
options=list(range(1, 13)),
|
| 294 |
+
default=[9, 10, 11, 12],
|
| 295 |
+
help="Pilih satu atau lebih layer transformer WavLM (1-12) untuk diekstrak menjadi representasi khusus masing-masing layer."
|
| 296 |
+
)
|
| 297 |
+
|
| 298 |
+
st.write("")
|
| 299 |
+
btn = st.button("Proses Penilaian Single", use_container_width=True)
|
| 300 |
+
|
| 301 |
+
# ---------------------------------------------------------------------------
|
| 302 |
+
# Processing & results
|
| 303 |
+
# ---------------------------------------------------------------------------
|
| 304 |
+
|
| 305 |
+
if btn:
|
| 306 |
+
if ref_file is None or test_file is None:
|
| 307 |
+
st.warning("Upload kedua file audio terlebih dahulu.")
|
| 308 |
+
elif not sel_layers_single:
|
| 309 |
+
st.warning("Pilih minimal satu layer WavLM.")
|
| 310 |
+
else:
|
| 311 |
+
# Save uploaded files to temp directory
|
| 312 |
+
tmp_dir = tempfile.mkdtemp()
|
| 313 |
+
ref_path = os.path.join(tmp_dir, "ref.wav")
|
| 314 |
+
test_path = os.path.join(tmp_dir, "test.wav")
|
| 315 |
+
|
| 316 |
+
with open(ref_path, "wb") as f:
|
| 317 |
+
f.write(ref_file.getbuffer())
|
| 318 |
+
with open(test_path, "wb") as f:
|
| 319 |
+
f.write(test_file.getbuffer())
|
| 320 |
+
|
| 321 |
+
with st.spinner("Memproses audio ..."):
|
| 322 |
+
((results,
|
| 323 |
+
wf_ref_raw, wf_ref_vad, wf_ref_norm,
|
| 324 |
+
wf_test_raw, wf_test_vad, wf_test_norm)) = run_pipeline(
|
| 325 |
+
ref_path, test_path, use_vad,
|
| 326 |
+
layer_indices=sel_layers_single,
|
| 327 |
+
)
|
| 328 |
+
|
| 329 |
+
sr = 16_000 # pipeline target sample rate
|
| 330 |
+
|
| 331 |
+
# --- Input Validation (Panjang Audio & Keheningan) ----------------
|
| 332 |
+
ref_raw_np = wf_ref_raw.squeeze().numpy()
|
| 333 |
+
test_raw_np = wf_test_raw.squeeze().numpy()
|
| 334 |
+
ref_vad_np = wf_ref_vad.squeeze().numpy() if use_vad else None
|
| 335 |
+
test_vad_np = wf_test_vad.squeeze().numpy() if use_vad else None
|
| 336 |
+
ref_norm_np = wf_ref_norm.squeeze().numpy() if use_vad else None
|
| 337 |
+
test_norm_np = wf_test_norm.squeeze().numpy() if use_vad else None
|
| 338 |
+
|
| 339 |
+
ref_dur = len(ref_raw_np) / sr
|
| 340 |
+
test_dur = len(test_raw_np) / sr
|
| 341 |
+
if ref_dur < 0.5 or test_dur < 0.5:
|
| 342 |
+
st.warning("**Peringatan Validation:** Salah satu atau kedua audio sangat pendek (< 0.5 detik). Hasil DTW mungkin menjadi kurang representatif.")
|
| 343 |
+
|
| 344 |
+
silence_threshold = 0.005
|
| 345 |
+
if np.max(np.abs(ref_raw_np)) < silence_threshold or np.max(np.abs(test_raw_np)) < silence_threshold:
|
| 346 |
+
st.warning("**Peringatan Validation:** Terdeteksi audio yang hampir tidak bersuara (near-silent). VAD dan DTW kemungkinan kesulitan mencocokkan pola.")
|
| 347 |
+
|
| 348 |
+
# --- Audio Preview ------------------------------------------------
|
| 349 |
+
st.divider()
|
| 350 |
+
st.subheader("Preview Audio & Tahapan Pre-Processing")
|
| 351 |
+
st.caption(
|
| 352 |
+
"Menampilkan dua tahap sinyal audio: "
|
| 353 |
+
"**Sebelum Pre-processing** (audio asli) dan "
|
| 354 |
+
"**Setelah Pre-processing Lengkap** (VAD + normalisasi amplitudo ke [-1, 1]). \n"
|
| 355 |
+
"Seluruh grafik menggunakan skala sumbu Y tetap **[-1, 1]** agar perbedaan "
|
| 356 |
+
"amplitudo sebelum dan sesudah normalisasi terlihat jelas."
|
| 357 |
+
)
|
| 358 |
+
|
| 359 |
+
# --- Referensi ---
|
| 360 |
+
st.markdown("##### Audio Referensi")
|
| 361 |
+
fig_ref = plot_waveforms(ref_raw_np, ref_vad_np, ref_norm_np, sr, "Referensi")
|
| 362 |
+
st.pyplot(fig_ref, use_container_width=True)
|
| 363 |
+
plt.close(fig_ref)
|
| 364 |
+
if use_vad and ref_vad_np is not None:
|
| 365 |
+
dur_raw_ref = len(ref_raw_np) / sr
|
| 366 |
+
dur_trim_ref = len(ref_vad_np) / sr
|
| 367 |
+
delta_ref = dur_raw_ref - dur_trim_ref
|
| 368 |
+
st.caption(
|
| 369 |
+
f"📐 **Efek Pre-processing (VAD):** "
|
| 370 |
+
f"Durasi asli = {dur_raw_ref:.2f} detik → "
|
| 371 |
+
f"Setelah VAD = {dur_trim_ref:.2f} detik "
|
| 372 |
+
f"(terpotong {delta_ref:.2f} detik)"
|
| 373 |
+
)
|
| 374 |
+
st.audio(ref_norm_np if use_vad else ref_raw_np, sample_rate=sr)
|
| 375 |
+
|
| 376 |
+
# --- Peserta ---
|
| 377 |
+
st.markdown("##### Audio Peserta")
|
| 378 |
+
fig_test = plot_waveforms(test_raw_np, test_vad_np, test_norm_np, sr, "Peserta")
|
| 379 |
+
st.pyplot(fig_test, use_container_width=True)
|
| 380 |
+
plt.close(fig_test)
|
| 381 |
+
if use_vad and test_vad_np is not None:
|
| 382 |
+
dur_raw_test = len(test_raw_np) / sr
|
| 383 |
+
dur_trim_test = len(test_vad_np) / sr
|
| 384 |
+
delta_test = dur_raw_test - dur_trim_test
|
| 385 |
+
st.caption(
|
| 386 |
+
f"📐 **Efek Pre-processing (VAD):** "
|
| 387 |
+
f"Durasi asli = {dur_raw_test:.2f} detik → "
|
| 388 |
+
f"Setelah VAD = {dur_trim_test:.2f} detik "
|
| 389 |
+
f"(terpotong {delta_test:.2f} detik)"
|
| 390 |
+
)
|
| 391 |
+
st.audio(test_norm_np if use_vad else test_raw_np, sample_rate=sr)
|
| 392 |
+
|
| 393 |
+
# --- Aggregated Results -------------------------------------------
|
| 394 |
+
st.divider()
|
| 395 |
+
st.subheader("Ringkasan Hasil Penilaian (Agregasi)")
|
| 396 |
+
|
| 397 |
+
scores = {layer: results[layer]["score"] for layer in sel_layers_single}
|
| 398 |
+
mean_score = sum(scores.values()) / len(scores)
|
| 399 |
+
best_layer = max(scores, key=scores.get)
|
| 400 |
+
best_score = scores[best_layer]
|
| 401 |
+
|
| 402 |
+
# --- Durasi Audio dalam ms ---
|
| 403 |
+
ref_dur_ms = round(len(ref_raw_np) / sr * 1000)
|
| 404 |
+
test_dur_ms = round(len(test_raw_np) / sr * 1000)
|
| 405 |
+
ref_dur_vad_ms = round(len(ref_vad_np) / sr * 1000) if ref_vad_np is not None else ref_dur_ms
|
| 406 |
+
test_dur_vad_ms = round(len(test_vad_np) / sr * 1000) if test_vad_np is not None else test_dur_ms
|
| 407 |
+
|
| 408 |
+
col_dur1, col_dur2 = st.columns(2)
|
| 409 |
+
with col_dur1:
|
| 410 |
+
st.metric("Durasi Audio Referensi", f"{ref_dur_ms} ms",
|
| 411 |
+
delta=f"{ref_dur_vad_ms} ms setelah VAD" if use_vad else None,
|
| 412 |
+
delta_color="off")
|
| 413 |
+
with col_dur2:
|
| 414 |
+
st.metric("Durasi Audio Peserta", f"{test_dur_ms} ms",
|
| 415 |
+
delta=f"{test_dur_vad_ms} ms setelah VAD" if use_vad else None,
|
| 416 |
+
delta_color="off")
|
| 417 |
+
|
| 418 |
+
col_agg1, col_agg2 = st.columns(2)
|
| 419 |
+
with col_agg1:
|
| 420 |
+
st.metric("Skor Rata-rata (Agregasi)", f"{mean_score:.2f} / 100")
|
| 421 |
+
st.info(f"**Interpretasi:** {interpret_score(mean_score)}")
|
| 422 |
+
with col_agg2:
|
| 423 |
+
st.metric(f"Skor Tertinggi (Layer {best_layer})", f"{best_score:.2f} / 100")
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
st.caption("Skor di atas berbasis kalibrasi sigmoid pada jarak _cosine_ DTW.")
|
| 427 |
+
|
| 428 |
+
# --- Results Breakdown --------------------------------------------
|
| 429 |
+
st.divider()
|
| 430 |
+
st.subheader("Detail per Layer")
|
| 431 |
+
|
| 432 |
+
layer_tabs = st.tabs([f"Layer {l}" for l in sel_layers_single])
|
| 433 |
+
|
| 434 |
+
for idx, layer in enumerate(sel_layers_single):
|
| 435 |
+
with layer_tabs[idx]:
|
| 436 |
+
layer_data = results[layer]
|
| 437 |
+
score = layer_data["score"]
|
| 438 |
+
warping_path = layer_data["warping_path"]
|
| 439 |
+
dtw_matrix = layer_data["dtw_matrix"]
|
| 440 |
+
diagnostics = layer_data["diagnostics"]
|
| 441 |
+
|
| 442 |
+
with st.container():
|
| 443 |
+
st.metric(f"Skor Kemiripan (Layer {layer})", f"{score:.2f} / 100")
|
| 444 |
+
|
| 445 |
+
st.write("") # spacer
|
| 446 |
+
|
| 447 |
+
with st.container():
|
| 448 |
+
fig = plot_alignment(warping_path)
|
| 449 |
+
st.pyplot(fig, use_container_width=True)
|
| 450 |
+
plt.close(fig)
|
| 451 |
+
|
| 452 |
+
# --- DTW Diagnostics (optional) -----------------------------------
|
| 453 |
+
if show_diagnostics:
|
| 454 |
+
st.divider()
|
| 455 |
+
st.subheader(f"Diagnostik DTW - Layer {layer}")
|
| 456 |
+
|
| 457 |
+
d = diagnostics # shorthand
|
| 458 |
+
c1, c2, c3 = st.columns(3)
|
| 459 |
+
c1.metric("Raw DTW Distance", f"{d['raw_dtw_distance']:.6f}")
|
| 460 |
+
c2.metric("Normalized Distance", f"{d['normalized_distance']:.6f}")
|
| 461 |
+
c3.metric("Path Length", d["path_length"])
|
| 462 |
+
|
| 463 |
+
c4, c5, c6 = st.columns(3)
|
| 464 |
+
c4.metric("Frames Referensi", d["num_frames_ref"])
|
| 465 |
+
c5.metric("Frames Peserta", d["num_frames_test"])
|
| 466 |
+
c6.metric("Sakoe-Chiba Ratio", d["sakoe_chiba_ratio"])
|
| 467 |
+
|
| 468 |
+
c7, c8, c9 = st.columns(3)
|
| 469 |
+
c7.metric("Durasi Ref (detik)", f"{d['ref_duration_sec']:.3f}")
|
| 470 |
+
c8.metric("Durasi Peserta (detik)", f"{d['test_duration_sec']:.3f}")
|
| 471 |
+
c9.metric("Rasio Durasi", f"{d['duration_ratio']:.4f}")
|
| 472 |
+
|
| 473 |
+
# Heatmap DTW
|
| 474 |
+
st.write("")
|
| 475 |
+
fig_hm = plot_dtw_heatmap(dtw_matrix, warping_path)
|
| 476 |
+
st.pyplot(fig_hm, use_container_width=True)
|
| 477 |
+
plt.close(fig_hm)
|
| 478 |
+
|
| 479 |
+
|
| 480 |
+
with tab2:
|
| 481 |
+
st.markdown("#### Batch Processing (Dari Folder Lokal)")
|
| 482 |
+
st.info("Fitur ini akan memproses semua audio di folder `audio peserta` dan membandingkannya dengan folder `audio referensi`.")
|
| 483 |
+
|
| 484 |
+
use_vad_batch = st.checkbox("Aktifkan VAD", value=True, key="vad_batch")
|
| 485 |
+
|
| 486 |
+
# Langsung pakai semua layer (1-12)
|
| 487 |
+
sel_layers = list(range(1, 13))
|
| 488 |
+
|
| 489 |
+
btn_batch = st.button("Jalankan Batch Processing", use_container_width=True)
|
| 490 |
+
|
| 491 |
+
if btn_batch:
|
| 492 |
+
if not sel_layers:
|
| 493 |
+
st.warning("Pilih minimal satu layer untuk diproses.")
|
| 494 |
+
else:
|
| 495 |
+
peserta_dir = Path("audio peserta")
|
| 496 |
+
referensi_dir = Path("audio referensi")
|
| 497 |
+
|
| 498 |
+
if not peserta_dir.exists() or not referensi_dir.exists():
|
| 499 |
+
st.error("Folder `audio peserta` atau `audio referensi` tidak ditemukan di direktori saat ini.")
|
| 500 |
+
else:
|
| 501 |
+
with st.spinner("Memproses seluruh audio dalam batch..."):
|
| 502 |
+
scorer = load_pipeline()
|
| 503 |
+
|
| 504 |
+
pesertas = sorted(
|
| 505 |
+
peserta_dir.glob("peserta *"),
|
| 506 |
+
key=lambda x: int(re.search(r"\d+", x.name).group()) if re.search(r"\d+", x.name) else 0
|
| 507 |
+
)
|
| 508 |
+
|
| 509 |
+
rows = []
|
| 510 |
+
prog_bar = st.progress(0)
|
| 511 |
+
total_p = len(pesertas)
|
| 512 |
+
|
| 513 |
+
for idx_p, p in enumerate(pesertas):
|
| 514 |
+
audios = [f for f in p.glob("*.wav") if re.search(r"\d+", f.name)]
|
| 515 |
+
audios = sorted(audios, key=lambda x: int(re.search(r"\d+", x.name).group()))
|
| 516 |
+
|
| 517 |
+
if not audios:
|
| 518 |
+
continue
|
| 519 |
+
|
| 520 |
+
for audio in audios:
|
| 521 |
+
ref_audio = referensi_dir / audio.name
|
| 522 |
+
if not ref_audio.exists(): continue
|
| 523 |
+
|
| 524 |
+
detailed_data = scorer.compute_detailed_similarity(
|
| 525 |
+
audio_path1=str(ref_audio),
|
| 526 |
+
audio_path2=str(audio),
|
| 527 |
+
use_vad=use_vad_batch,
|
| 528 |
+
layer_indices=sel_layers
|
| 529 |
+
)
|
| 530 |
+
|
| 531 |
+
row = {
|
| 532 |
+
"Peserta": p.name,
|
| 533 |
+
"File": audio.name,
|
| 534 |
+
}
|
| 535 |
+
|
| 536 |
+
for layer in sel_layers:
|
| 537 |
+
l_res = detailed_data["results"][layer]
|
| 538 |
+
row[f"Score L{layer}"] = round(l_res["score"], 2)
|
| 539 |
+
row[f"Dist L{layer}"] = round(l_res["dtw_distance"], 4)
|
| 540 |
+
|
| 541 |
+
rows.append(row)
|
| 542 |
+
|
| 543 |
+
prog_bar.progress((idx_p + 1) / total_p)
|
| 544 |
+
|
| 545 |
+
if rows:
|
| 546 |
+
df = pd.DataFrame(rows)
|
| 547 |
+
st.success("Batch processing selesai!")
|
| 548 |
+
st.dataframe(df, use_container_width=True)
|
| 549 |
+
|
| 550 |
+
csv = df.to_csv(index=False).encode("utf-8")
|
| 551 |
+
st.download_button(
|
| 552 |
+
label="Download Hasil CSV",
|
| 553 |
+
data=csv,
|
| 554 |
+
file_name="hasil_batch_multi_layer.csv",
|
| 555 |
+
mime="text/csv",
|
| 556 |
+
use_container_width=True
|
| 557 |
+
)
|
| 558 |
+
else:
|
| 559 |
+
st.warning("Tidak ada data valid yang diproses.")
|
| 560 |
+
|
| 561 |
+
|
| 562 |
+
with tab3:
|
| 563 |
+
st.markdown("#### Analisis Korelasi (Overview)")
|
| 564 |
+
st.info("Visualisasi hubungan antara skor sistem (DTW) dan penilaian Ustadz (rating).")
|
| 565 |
+
|
| 566 |
+
st.markdown("### Struktur Dataset Pasangan Frasa")
|
| 567 |
+
csv_path = "dataset_final.csv"
|
| 568 |
+
if os.path.exists(csv_path):
|
| 569 |
+
df_pairing = pd.read_csv(csv_path)
|
| 570 |
+
fig_pairing = plot_pairing_diagram(df_pairing)
|
| 571 |
+
st.pyplot(fig_pairing, use_container_width=True)
|
| 572 |
+
plt.close(fig_pairing)
|
| 573 |
+
|
| 574 |
+
st.markdown("""
|
| 575 |
+
**Penjelasan Singkat:**
|
| 576 |
+
- Setiap peserta membaca frasa yang sama
|
| 577 |
+
- Setiap frasa memiliki satu audio referensi
|
| 578 |
+
- Sistem membandingkan pasangan audio pada frasa yang sama
|
| 579 |
+
""")
|
| 580 |
+
st.divider()
|
| 581 |
+
|
| 582 |
+
|
| 583 |
+
if not os.path.exists(csv_path):
|
| 584 |
+
st.warning(f"File {csv_path} tidak ditemukan. Jalankan grid_search atau buat dataset terlebih dahulu.")
|
| 585 |
+
else:
|
| 586 |
+
with st.spinner("Menjalankan analisis korelasi..."):
|
| 587 |
+
df, df_results = run_correlation_analysis(csv_path)
|
| 588 |
+
|
| 589 |
+
# Cari best layer (Spearman tertinggi)
|
| 590 |
+
best_idx = df_results['spearman_rho'].idxmax()
|
| 591 |
+
best_layer_name = df_results.loc[best_idx, 'layer']
|
| 592 |
+
best_spearman = df_results.loc[best_idx, 'spearman_rho']
|
| 593 |
+
|
| 594 |
+
st.subheader(f"Data Korelasi Per Layer")
|
| 595 |
+
st.dataframe(df_results, use_container_width=True)
|
| 596 |
+
|
| 597 |
+
st.markdown(f"**Layer Terbaik:** `{best_layer_name}` dengan korelasi Spearman **{best_spearman:.4f}**")
|
| 598 |
+
|
| 599 |
+
st.subheader("Bar Chart: Spearman Rho")
|
| 600 |
+
fig_bar = plot_correlation_bar(df_results)
|
| 601 |
+
st.pyplot(fig_bar, use_container_width=True)
|
| 602 |
+
plt.close(fig_bar)
|
| 603 |
+
|
| 604 |
+
st.subheader(f"Scatter Plot: {best_layer_name} vs Rating")
|
| 605 |
+
fig_scatter = plot_scatter_best_layer(df, best_layer_name)
|
| 606 |
+
st.pyplot(fig_scatter, use_container_width=True)
|
| 607 |
+
plt.close(fig_scatter)
|
| 608 |
+
|
| 609 |
+
st.subheader("Heatmap Korelasi")
|
| 610 |
+
fig_hm = plot_heatmap(df_results)
|
| 611 |
+
st.pyplot(fig_hm, use_container_width=True)
|
| 612 |
+
plt.close(fig_hm)
|
audio_loader.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Audio Loader Module.
|
| 2 |
+
|
| 3 |
+
Loads and preprocesses WAV audio files for the similarity pipeline.
|
| 4 |
+
|
| 5 |
+
Notes:
|
| 6 |
+
- This pipeline uses a strict single-backend mechanism (torchaudio) for reading audio.
|
| 7 |
+
This methodological decision ensures strict experimental reproducibility by
|
| 8 |
+
avoiding backend-dependent variations in decoding or numerical representation.
|
| 9 |
+
- Optional WebRTC VAD-based endpoint trimming removes leading/trailing silence
|
| 10 |
+
without cutting internal pauses.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import struct
|
| 16 |
+
from typing import Tuple
|
| 17 |
+
|
| 18 |
+
import numpy as np
|
| 19 |
+
import torch
|
| 20 |
+
import torchaudio
|
| 21 |
+
|
| 22 |
+
try:
|
| 23 |
+
import soundfile as sf # type: ignore
|
| 24 |
+
|
| 25 |
+
_SOUNDFILE_AVAILABLE = True
|
| 26 |
+
except ImportError:
|
| 27 |
+
_SOUNDFILE_AVAILABLE = False
|
| 28 |
+
|
| 29 |
+
try:
|
| 30 |
+
import webrtcvad # type: ignore
|
| 31 |
+
|
| 32 |
+
_WEBRTCVAD_AVAILABLE = True
|
| 33 |
+
except ImportError:
|
| 34 |
+
_WEBRTCVAD_AVAILABLE = False
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class AudioLoader:
|
| 38 |
+
"""Loads and preprocesses audio files."""
|
| 39 |
+
|
| 40 |
+
def __init__(
|
| 41 |
+
self,
|
| 42 |
+
target_sr: int = 16000,
|
| 43 |
+
use_vad: bool = True,
|
| 44 |
+
vad_mode: int = 1,
|
| 45 |
+
vad_frame_ms: int = 10,
|
| 46 |
+
vad_onset_frames: int = 2,
|
| 47 |
+
vad_offset_frames: int = 4,
|
| 48 |
+
energy_trim_threshold: float = 0.06,
|
| 49 |
+
):
|
| 50 |
+
"""
|
| 51 |
+
Initialize AudioLoader.
|
| 52 |
+
|
| 53 |
+
Args:
|
| 54 |
+
target_sr: Target sampling rate (default: 16000 Hz for WavLM).
|
| 55 |
+
use_vad: If True, apply WebRTC VAD endpoint trimming to strip
|
| 56 |
+
leading/trailing silence.
|
| 57 |
+
vad_mode: WebRTC VAD aggressiveness (0–3). 1–2 recommended.
|
| 58 |
+
vad_frame_ms: Frame length in ms for VAD (10, 20, or 30).
|
| 59 |
+
vad_onset_frames: Minimum consecutive voiced frames to detect
|
| 60 |
+
speech onset (hysteresis).
|
| 61 |
+
vad_offset_frames: Minimum consecutive unvoiced frames to detect
|
| 62 |
+
speech offset (hysteresis).
|
| 63 |
+
energy_trim_threshold: Fraction of peak absolute amplitude used
|
| 64 |
+
as threshold for energy-based refinement after VAD (0.0–1.0).
|
| 65 |
+
Frames with amplitude below this fraction of the local peak
|
| 66 |
+
are trimmed from the head and tail.
|
| 67 |
+
"""
|
| 68 |
+
self.target_sr = target_sr
|
| 69 |
+
self.use_vad = use_vad
|
| 70 |
+
self.vad_mode = vad_mode
|
| 71 |
+
self.vad_frame_ms = vad_frame_ms
|
| 72 |
+
self.vad_onset_frames = vad_onset_frames
|
| 73 |
+
self.vad_offset_frames = vad_offset_frames
|
| 74 |
+
self.energy_trim_threshold = energy_trim_threshold
|
| 75 |
+
|
| 76 |
+
@staticmethod
|
| 77 |
+
def _load_with_soundfile(audio_path: str) -> Tuple[torch.Tensor, int]:
|
| 78 |
+
"""Fallback loader using soundfile (libsndfile).
|
| 79 |
+
|
| 80 |
+
Used when torchaudio's default backend (torchcodec) is unavailable,
|
| 81 |
+
e.g. on systems without FFmpeg DLLs. Produces identical float32
|
| 82 |
+
waveform tensors for WAV files.
|
| 83 |
+
"""
|
| 84 |
+
if not _SOUNDFILE_AVAILABLE:
|
| 85 |
+
raise ImportError(
|
| 86 |
+
"soundfile is not installed. Install it with: pip install soundfile"
|
| 87 |
+
)
|
| 88 |
+
data, sample_rate = sf.read(audio_path, dtype="float32", always_2d=True)
|
| 89 |
+
# soundfile returns (num_samples, channels) → transpose to (channels, num_samples)
|
| 90 |
+
waveform = torch.from_numpy(data.T)
|
| 91 |
+
return waveform, sample_rate
|
| 92 |
+
|
| 93 |
+
def load_audio(self, audio_path: str) -> Tuple[torch.Tensor, int]:
|
| 94 |
+
"""
|
| 95 |
+
Load audio file and resample if necessary.
|
| 96 |
+
|
| 97 |
+
Uses soundfile (libsndfile) as the primary loader because
|
| 98 |
+
torchaudio ≥2.11 requires torchcodec + FFmpeg DLLs which are
|
| 99 |
+
often unavailable on Windows. soundfile handles WAV natively
|
| 100 |
+
and produces identical float32 results.
|
| 101 |
+
|
| 102 |
+
Falls back to torchaudio.load() if soundfile is unavailable.
|
| 103 |
+
|
| 104 |
+
Args:
|
| 105 |
+
audio_path: Path to WAV audio file
|
| 106 |
+
|
| 107 |
+
Returns:
|
| 108 |
+
Tuple of (waveform tensor, sample_rate)
|
| 109 |
+
waveform shape: (1, num_samples) for mono
|
| 110 |
+
|
| 111 |
+
Raises:
|
| 112 |
+
RuntimeError: If both soundfile and torchaudio fail to read the audio file.
|
| 113 |
+
"""
|
| 114 |
+
# Primary: soundfile (no FFmpeg dependency, handles WAV natively)
|
| 115 |
+
try:
|
| 116 |
+
waveform, sample_rate = self._load_with_soundfile(audio_path)
|
| 117 |
+
except Exception as sf_err:
|
| 118 |
+
# Fallback: torchaudio (needs torchcodec/FFmpeg on ≥2.11)
|
| 119 |
+
try:
|
| 120 |
+
waveform, sample_rate = torchaudio.load(audio_path)
|
| 121 |
+
except Exception as torchaudio_err:
|
| 122 |
+
raise RuntimeError(
|
| 123 |
+
f"Failed to read audio file '{audio_path}'. "
|
| 124 |
+
f"soundfile error: {sf_err} | "
|
| 125 |
+
f"torchaudio error: {torchaudio_err}"
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
# Convert to mono if stereo
|
| 129 |
+
if waveform.shape[0] > 1:
|
| 130 |
+
waveform = torch.mean(waveform, dim=0, keepdim=True)
|
| 131 |
+
|
| 132 |
+
# Resample if necessary
|
| 133 |
+
if sample_rate != self.target_sr:
|
| 134 |
+
resampler = torchaudio.transforms.Resample(
|
| 135 |
+
orig_freq=sample_rate,
|
| 136 |
+
new_freq=self.target_sr
|
| 137 |
+
)
|
| 138 |
+
waveform = resampler(waveform)
|
| 139 |
+
sample_rate = self.target_sr
|
| 140 |
+
|
| 141 |
+
return waveform, sample_rate
|
| 142 |
+
|
| 143 |
+
# ------------------------------------------------------------------
|
| 144 |
+
# VAD-based endpoint trimming
|
| 145 |
+
# ------------------------------------------------------------------
|
| 146 |
+
|
| 147 |
+
def vad_trim_endpoints(
|
| 148 |
+
self, waveform: torch.Tensor, sample_rate: int
|
| 149 |
+
) -> torch.Tensor:
|
| 150 |
+
"""Trim leading and trailing silence using WebRTC VAD.
|
| 151 |
+
|
| 152 |
+
Only the outermost silent segments are removed; internal pauses
|
| 153 |
+
(e.g. between words or ayat) are preserved intact.
|
| 154 |
+
|
| 155 |
+
The algorithm uses a hysteresis mechanism:
|
| 156 |
+
* **Onset**: the first sample of the first window in a run of at
|
| 157 |
+
least ``vad_onset_frames`` consecutive *voiced* frames is taken
|
| 158 |
+
as the speech start.
|
| 159 |
+
* **Offset**: the last sample of the last *voiced* frame before a
|
| 160 |
+
run of at least ``vad_offset_frames`` consecutive *unvoiced*
|
| 161 |
+
frames that extends to the end of the signal is taken as the
|
| 162 |
+
speech end.
|
| 163 |
+
|
| 164 |
+
Args:
|
| 165 |
+
waveform: Mono waveform tensor of shape ``(1, num_samples)``
|
| 166 |
+
in float32 (values in roughly [-1, 1]).
|
| 167 |
+
sample_rate: Must be 8000, 16000, 32000, or 48000.
|
| 168 |
+
|
| 169 |
+
Returns:
|
| 170 |
+
Trimmed waveform tensor ``(1, trimmed_samples)``, float32.
|
| 171 |
+
"""
|
| 172 |
+
if not _WEBRTCVAD_AVAILABLE:
|
| 173 |
+
raise ImportError(
|
| 174 |
+
"webrtcvad is not installed but VAD trimming was requested. "
|
| 175 |
+
"Please install it with: pip install webrtcvad"
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
# --- prepare int16 PCM bytes for webrtcvad -----------------------
|
| 179 |
+
audio_np: np.ndarray = waveform.squeeze(0).numpy() # (num_samples,)
|
| 180 |
+
# Clip and convert float32 -> int16
|
| 181 |
+
pcm_int16 = np.clip(audio_np * 32767, -32768, 32767).astype(np.int16)
|
| 182 |
+
|
| 183 |
+
frame_len = int(sample_rate * self.vad_frame_ms / 1000) # samples per frame
|
| 184 |
+
num_frames = len(pcm_int16) // frame_len
|
| 185 |
+
|
| 186 |
+
if num_frames == 0:
|
| 187 |
+
return waveform # too short to analyse
|
| 188 |
+
|
| 189 |
+
vad = webrtcvad.Vad(self.vad_mode)
|
| 190 |
+
|
| 191 |
+
# --- per-frame voiced/unvoiced labels ----------------------------
|
| 192 |
+
is_voiced: list[bool] = []
|
| 193 |
+
for i in range(num_frames):
|
| 194 |
+
start = i * frame_len
|
| 195 |
+
end = start + frame_len
|
| 196 |
+
frame_bytes = struct.pack(f"<{frame_len}h", *pcm_int16[start:end])
|
| 197 |
+
is_voiced.append(vad.is_speech(frame_bytes, sample_rate))
|
| 198 |
+
|
| 199 |
+
# --- onset detection (left-to-right) -----------------------------
|
| 200 |
+
speech_start_frame: int = 0
|
| 201 |
+
consecutive_voiced = 0
|
| 202 |
+
for idx, voiced in enumerate(is_voiced):
|
| 203 |
+
if voiced:
|
| 204 |
+
consecutive_voiced += 1
|
| 205 |
+
if consecutive_voiced >= self.vad_onset_frames:
|
| 206 |
+
speech_start_frame = idx - self.vad_onset_frames + 1
|
| 207 |
+
break
|
| 208 |
+
else:
|
| 209 |
+
consecutive_voiced = 0
|
| 210 |
+
else:
|
| 211 |
+
# No onset found – return original waveform (all silence?)
|
| 212 |
+
return waveform
|
| 213 |
+
|
| 214 |
+
# --- offset detection (right-to-left) ----------------------------
|
| 215 |
+
trailing_unvoiced = 0
|
| 216 |
+
for idx in range(num_frames - 1, -1, -1):
|
| 217 |
+
if not is_voiced[idx]:
|
| 218 |
+
trailing_unvoiced += 1
|
| 219 |
+
else:
|
| 220 |
+
break
|
| 221 |
+
|
| 222 |
+
if trailing_unvoiced >= self.vad_offset_frames:
|
| 223 |
+
speech_end_frame = num_frames - trailing_unvoiced - 1
|
| 224 |
+
else:
|
| 225 |
+
speech_end_frame = num_frames - 1 # no significant trailing silence
|
| 226 |
+
|
| 227 |
+
# Sanity: ensure start <= end
|
| 228 |
+
if speech_start_frame > speech_end_frame:
|
| 229 |
+
return waveform
|
| 230 |
+
|
| 231 |
+
# --- reconstruct trimmed waveform --------------------------------
|
| 232 |
+
start_sample = speech_start_frame * frame_len
|
| 233 |
+
end_sample = (speech_end_frame + 1) * frame_len
|
| 234 |
+
end_sample = min(end_sample, waveform.shape[1])
|
| 235 |
+
|
| 236 |
+
trimmed = waveform[:, start_sample:end_sample]
|
| 237 |
+
|
| 238 |
+
# Guard against empty result
|
| 239 |
+
if trimmed.shape[1] == 0:
|
| 240 |
+
return waveform
|
| 241 |
+
|
| 242 |
+
# --- energy-based refinement -------------------------------------
|
| 243 |
+
trimmed = self._energy_refine(trimmed)
|
| 244 |
+
|
| 245 |
+
return trimmed
|
| 246 |
+
|
| 247 |
+
def _energy_refine(self, waveform: torch.Tensor) -> torch.Tensor:
|
| 248 |
+
"""Refine VAD boundaries by trimming low-energy head/tail.
|
| 249 |
+
|
| 250 |
+
Uses a short-window RMS envelope to find the first and last
|
| 251 |
+
sample whose energy exceeds ``energy_trim_threshold`` of the
|
| 252 |
+
peak RMS value. This removes residual breath or resonance
|
| 253 |
+
that WebRTC VAD may leave behind.
|
| 254 |
+
|
| 255 |
+
Args:
|
| 256 |
+
waveform: Mono waveform ``(1, num_samples)``.
|
| 257 |
+
|
| 258 |
+
Returns:
|
| 259 |
+
Refined waveform ``(1, refined_samples)``.
|
| 260 |
+
"""
|
| 261 |
+
if self.energy_trim_threshold <= 0.0:
|
| 262 |
+
return waveform
|
| 263 |
+
|
| 264 |
+
audio = waveform.squeeze(0).numpy() # (num_samples,)
|
| 265 |
+
abs_env = np.abs(audio)
|
| 266 |
+
|
| 267 |
+
# Short-window RMS (window ≈ 10 ms)
|
| 268 |
+
win = max(int(self.target_sr * 0.01), 1)
|
| 269 |
+
# Cumulative sum trick for fast moving average of squared signal
|
| 270 |
+
sq = audio.astype(np.float64) ** 2
|
| 271 |
+
cs = np.concatenate(([0.0], np.cumsum(sq)))
|
| 272 |
+
rms = np.sqrt((cs[win:] - cs[:-win]) / win).astype(np.float32)
|
| 273 |
+
|
| 274 |
+
if len(rms) == 0:
|
| 275 |
+
return waveform
|
| 276 |
+
|
| 277 |
+
peak_rms = rms.max()
|
| 278 |
+
if peak_rms == 0:
|
| 279 |
+
return waveform
|
| 280 |
+
|
| 281 |
+
threshold = self.energy_trim_threshold * peak_rms
|
| 282 |
+
|
| 283 |
+
# Find first and last frame above threshold
|
| 284 |
+
above = np.where(rms >= threshold)[0]
|
| 285 |
+
if len(above) == 0:
|
| 286 |
+
return waveform
|
| 287 |
+
|
| 288 |
+
start = int(above[0])
|
| 289 |
+
end = int(above[-1]) + win # include the window trailing edge
|
| 290 |
+
end = min(end, len(audio))
|
| 291 |
+
|
| 292 |
+
refined = waveform[:, start:end]
|
| 293 |
+
if refined.shape[1] == 0:
|
| 294 |
+
return waveform
|
| 295 |
+
|
| 296 |
+
return refined
|
| 297 |
+
|
| 298 |
+
def normalize_audio(self, waveform: torch.Tensor) -> torch.Tensor:
|
| 299 |
+
"""
|
| 300 |
+
Normalize audio waveform to [-1, 1] range.
|
| 301 |
+
|
| 302 |
+
Args:
|
| 303 |
+
waveform: Audio waveform tensor
|
| 304 |
+
|
| 305 |
+
Returns:
|
| 306 |
+
Normalized waveform
|
| 307 |
+
"""
|
| 308 |
+
# Normalize to [-1, 1]
|
| 309 |
+
max_val = torch.max(torch.abs(waveform))
|
| 310 |
+
if max_val > 0:
|
| 311 |
+
waveform = waveform / max_val
|
| 312 |
+
return waveform
|
| 313 |
+
|
| 314 |
+
def load_and_preprocess(self, audio_path: str) -> torch.Tensor:
|
| 315 |
+
"""
|
| 316 |
+
Load and preprocess audio in one step.
|
| 317 |
+
|
| 318 |
+
Args:
|
| 319 |
+
audio_path: Path to WAV audio file
|
| 320 |
+
|
| 321 |
+
Returns:
|
| 322 |
+
Preprocessed waveform tensor (1, num_samples)
|
| 323 |
+
"""
|
| 324 |
+
waveform, sr = self.load_audio(audio_path)
|
| 325 |
+
|
| 326 |
+
# VAD endpoint trimming (after resampling, before normalisation)
|
| 327 |
+
if self.use_vad:
|
| 328 |
+
waveform = self.vad_trim_endpoints(waveform, sr)
|
| 329 |
+
|
| 330 |
+
waveform = self.normalize_audio(waveform)
|
| 331 |
+
return waveform
|
correlation_analysis.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pandas as pd
|
| 2 |
+
import scipy.stats as stats
|
| 3 |
+
import matplotlib.pyplot as plt
|
| 4 |
+
import numpy as np
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
def interpret_correlation(rho):
|
| 8 |
+
abs_rho = abs(rho)
|
| 9 |
+
if abs_rho < 0.20:
|
| 10 |
+
return 'sangat lemah'
|
| 11 |
+
elif abs_rho < 0.40:
|
| 12 |
+
return 'lemah'
|
| 13 |
+
elif abs_rho < 0.60:
|
| 14 |
+
return 'sedang'
|
| 15 |
+
elif abs_rho < 0.80:
|
| 16 |
+
return 'kuat'
|
| 17 |
+
else:
|
| 18 |
+
return 'sangat kuat'
|
| 19 |
+
|
| 20 |
+
def run_correlation_analysis(csv_path: str):
|
| 21 |
+
df = pd.read_csv(csv_path)
|
| 22 |
+
|
| 23 |
+
results = []
|
| 24 |
+
|
| 25 |
+
for layer in range(1, 13):
|
| 26 |
+
col_name = f'Score L{layer}'
|
| 27 |
+
|
| 28 |
+
# Dropna just in case
|
| 29 |
+
valid_data = df.dropna(subset=[col_name, 'rating'])
|
| 30 |
+
|
| 31 |
+
spearman_rho, spearman_p = stats.spearmanr(valid_data[col_name], valid_data['rating'])
|
| 32 |
+
pearson_r, pearson_p = stats.pearsonr(valid_data[col_name], valid_data['rating'])
|
| 33 |
+
|
| 34 |
+
interpretation = interpret_correlation(spearman_rho)
|
| 35 |
+
|
| 36 |
+
results.append({
|
| 37 |
+
'layer': col_name,
|
| 38 |
+
'spearman_rho': spearman_rho,
|
| 39 |
+
'spearman_p': spearman_p,
|
| 40 |
+
'pearson_r': pearson_r,
|
| 41 |
+
'pearson_p': pearson_p,
|
| 42 |
+
'interpretasi_spearman': interpretation
|
| 43 |
+
})
|
| 44 |
+
|
| 45 |
+
df_results = pd.DataFrame(results)
|
| 46 |
+
return df, df_results
|
| 47 |
+
|
| 48 |
+
def plot_correlation_bar(df_corr):
|
| 49 |
+
fig, ax = plt.subplots(figsize=(10, 6))
|
| 50 |
+
|
| 51 |
+
ax.bar(df_corr['layer'], df_corr['spearman_rho'])
|
| 52 |
+
ax.set_title('Korelasi Spearman (rho) per Layer vs Rating Ustadz')
|
| 53 |
+
ax.set_xlabel('Layer')
|
| 54 |
+
ax.set_ylabel('Spearman rho')
|
| 55 |
+
plt.xticks(rotation=45)
|
| 56 |
+
|
| 57 |
+
fig.tight_layout()
|
| 58 |
+
return fig
|
| 59 |
+
|
| 60 |
+
def plot_scatter_best_layer(df, best_layer):
|
| 61 |
+
fig, ax = plt.subplots(figsize=(8, 6))
|
| 62 |
+
|
| 63 |
+
valid_data = df.dropna(subset=[best_layer, 'rating'])
|
| 64 |
+
x = valid_data[best_layer]
|
| 65 |
+
y = valid_data['rating']
|
| 66 |
+
|
| 67 |
+
ax.scatter(x, y, alpha=0.5, label='Data points')
|
| 68 |
+
|
| 69 |
+
# Linear regression line
|
| 70 |
+
m, b = np.polyfit(x, y, 1)
|
| 71 |
+
ax.plot(x, m*x + b, label=f'Trend line')
|
| 72 |
+
|
| 73 |
+
ax.set_title(f'Scatter Plot: {best_layer} vs Rating Ustadz')
|
| 74 |
+
ax.set_xlabel(f'Skor Sistem ({best_layer})')
|
| 75 |
+
ax.set_ylabel('Rating Ustadz')
|
| 76 |
+
ax.legend()
|
| 77 |
+
|
| 78 |
+
fig.tight_layout()
|
| 79 |
+
return fig
|
| 80 |
+
|
| 81 |
+
def plot_heatmap(df_corr):
|
| 82 |
+
fig, ax = plt.subplots(figsize=(10, 4))
|
| 83 |
+
|
| 84 |
+
# Create a simple heatmap
|
| 85 |
+
data = df_corr[['spearman_rho', 'pearson_r']].values.T
|
| 86 |
+
cax = ax.imshow(data, aspect='auto')
|
| 87 |
+
|
| 88 |
+
# Add values
|
| 89 |
+
for i in range(data.shape[0]):
|
| 90 |
+
for j in range(data.shape[1]):
|
| 91 |
+
ax.text(j, i, f'{data[i, j]:.2f}', ha='center', va='center', color='black')
|
| 92 |
+
|
| 93 |
+
ax.set_yticks([0, 1])
|
| 94 |
+
ax.set_yticklabels(['Spearman rho', 'Pearson r'])
|
| 95 |
+
ax.set_xticks(range(len(df_corr)))
|
| 96 |
+
ax.set_xticklabels(df_corr['layer'], rotation=45)
|
| 97 |
+
ax.set_title('Heatmap Korelasi')
|
| 98 |
+
|
| 99 |
+
fig.colorbar(cax)
|
| 100 |
+
fig.tight_layout()
|
| 101 |
+
return fig
|
| 102 |
+
|
| 103 |
+
def plot_pairing_diagram(df):
|
| 104 |
+
# Get unique participants and files
|
| 105 |
+
participants = df['ID_Peserta'].unique()[:1]
|
| 106 |
+
files = df['ID_Frasa'].unique()
|
| 107 |
+
|
| 108 |
+
# Sesuaikan ukuran agar tidak terlalu bertumpuk jika datanya banyak
|
| 109 |
+
height = max(5, max(len(participants), len(files)) * 0.4)
|
| 110 |
+
fig, ax = plt.subplots(figsize=(12, height))
|
| 111 |
+
|
| 112 |
+
# Positions
|
| 113 |
+
x_peserta = 1
|
| 114 |
+
x_frasa = 2
|
| 115 |
+
x_ref = 3
|
| 116 |
+
|
| 117 |
+
# Draw nodes
|
| 118 |
+
y_peserta = np.linspace(len(files), 1, len(files))
|
| 119 |
+
y_frasa = np.linspace(len(files), 1, len(files))
|
| 120 |
+
y_ref = np.linspace(len(files), 1, len(files))
|
| 121 |
+
|
| 122 |
+
# Peserta nodes
|
| 123 |
+
ax.scatter([x_peserta]*len(files), y_peserta, s=200, zorder=2)
|
| 124 |
+
if len(participants) > 0:
|
| 125 |
+
p_name = participants[0]
|
| 126 |
+
for i, f in enumerate(files):
|
| 127 |
+
ax.annotate(f"Peserta {p_name} (Rekaman {i+1})", (x_peserta - 0.1, y_peserta[i]), ha='right', va='center', fontsize=10)
|
| 128 |
+
|
| 129 |
+
# Frasa nodes
|
| 130 |
+
ax.scatter([x_frasa]*len(files), y_frasa, s=200, zorder=2)
|
| 131 |
+
for i, f in enumerate(files):
|
| 132 |
+
# f is filename, format slightly for display e.g. "01.wav" -> "Frasa 1"
|
| 133 |
+
frasa_label = f"Frasa {i+1}"
|
| 134 |
+
ax.annotate(frasa_label, (x_frasa, y_frasa[i] + 0.15), ha='center', va='bottom', fontsize=10)
|
| 135 |
+
|
| 136 |
+
# Referensi nodes
|
| 137 |
+
ax.scatter([x_ref]*len(files), y_ref, s=200, zorder=2)
|
| 138 |
+
for i, f in enumerate(files):
|
| 139 |
+
ax.annotate(f"Referensi {i+1}", (x_ref + 0.1, y_ref[i]), ha='left', va='center', fontsize=10)
|
| 140 |
+
|
| 141 |
+
# Draw lines
|
| 142 |
+
for j, _ in enumerate(files):
|
| 143 |
+
# Peserta to Frasa
|
| 144 |
+
ax.plot([x_peserta, x_frasa], [y_peserta[j], y_frasa[j]], zorder=1, alpha=0.5)
|
| 145 |
+
|
| 146 |
+
for j, _ in enumerate(files):
|
| 147 |
+
# Frasa to Referensi
|
| 148 |
+
ax.plot([x_frasa, x_ref], [y_frasa[j], y_ref[j]], zorder=1, alpha=0.5)
|
| 149 |
+
|
| 150 |
+
peserta_name = participants[0] if len(participants) > 0 else "Peserta"
|
| 151 |
+
ax.set_title(f"Ilustrasi Struktur Dataset Pasangan Frasa", fontsize=14)
|
| 152 |
+
ax.set_xlim(0.5, 3.5)
|
| 153 |
+
ax.set_ylim(0, len(files) + 1)
|
| 154 |
+
ax.axis('off')
|
| 155 |
+
|
| 156 |
+
fig.tight_layout()
|
| 157 |
+
return fig
|
create_dataset.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pandas as pd
|
| 2 |
+
import re
|
| 3 |
+
|
| 4 |
+
# Load data
|
| 5 |
+
scores = pd.read_csv('hasil_batch_multi_layer.csv')
|
| 6 |
+
ratings = pd.read_csv('ratings_rows Yunan.csv')
|
| 7 |
+
|
| 8 |
+
# Preprocess scores: extract numeric ID from 'Peserta'
|
| 9 |
+
def get_peserta_id(p):
|
| 10 |
+
match = re.search(r'\d+', p)
|
| 11 |
+
return int(match.group()) if match else None
|
| 12 |
+
|
| 13 |
+
scores['ID_Peserta'] = scores['Peserta'].apply(get_peserta_id)
|
| 14 |
+
scores['ID_Frasa'] = scores['File']
|
| 15 |
+
|
| 16 |
+
# Preprocess ratings
|
| 17 |
+
ratings['ID_Peserta'] = ratings['peserta_id']
|
| 18 |
+
ratings['ID_Frasa'] = ratings['audio_filename']
|
| 19 |
+
|
| 20 |
+
# Select relevant columns from scores
|
| 21 |
+
score_cols = [f'Score L{i}' for i in range(1, 13)]
|
| 22 |
+
scores = scores[['ID_Peserta', 'ID_Frasa'] + score_cols]
|
| 23 |
+
|
| 24 |
+
# Select relevant columns from ratings
|
| 25 |
+
ratings = ratings[['ID_Peserta', 'ID_Frasa', 'rating']]
|
| 26 |
+
|
| 27 |
+
# Merge
|
| 28 |
+
final = pd.merge(scores, ratings, on=['ID_Peserta', 'ID_Frasa'], how='inner')
|
| 29 |
+
|
| 30 |
+
# Create ID_Pasangan
|
| 31 |
+
final['ID_Pasangan'] = final['ID_Peserta'].astype(str) + '_' + final['ID_Frasa']
|
| 32 |
+
|
| 33 |
+
# Rearrange columns
|
| 34 |
+
cols = ['ID_Pasangan', 'ID_Peserta', 'ID_Frasa'] + score_cols + ['rating']
|
| 35 |
+
final = final[cols]
|
| 36 |
+
|
| 37 |
+
# Drop duplicates if any and non-matching rows are already dropped by inner join
|
| 38 |
+
final = final.dropna().sort_values(['ID_Peserta', 'ID_Frasa'])
|
| 39 |
+
|
| 40 |
+
# Save
|
| 41 |
+
final.to_csv('dataset_final.csv', index=False)
|
| 42 |
+
print('Shape dataset final:', final.shape)
|
dataset_final.csv
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
ID_Pasangan,ID_Peserta,ID_Frasa,Score L1,Score L2,Score L3,Score L4,Score L5,Score L6,Score L7,Score L8,Score L9,Score L10,Score L11,Score L12,rating
|
| 2 |
+
1_Dirosa Pertemuan 1.wav,1,Dirosa Pertemuan 1.wav,13.6,20.17,24.53,26.52,32.37,39.27,47.88,53.81,58.2,56.47,65.76,61.99,3
|
| 3 |
+
1_Dirosa Pertemuan 10.wav,1,Dirosa Pertemuan 10.wav,17.34,26.43,30.16,33.24,40.28,47.93,56.92,61.51,63.94,63.14,69.73,66.99,4
|
| 4 |
+
1_Dirosa Pertemuan 11.wav,1,Dirosa Pertemuan 11.wav,14.07,24.27,27.84,32.44,39.95,46.9,59.25,61.78,64.99,64.77,71.67,65.42,3
|
| 5 |
+
1_Dirosa Pertemuan 12.wav,1,Dirosa Pertemuan 12.wav,12.63,17.47,19.0,22.53,29.65,37.42,44.16,49.75,56.08,55.35,64.22,59.29,3
|
| 6 |
+
1_Dirosa Pertemuan 13.wav,1,Dirosa Pertemuan 13.wav,8.63,15.6,18.58,21.73,30.35,39.15,50.85,56.37,62.1,64.32,69.91,66.51,2
|
| 7 |
+
1_Dirosa Pertemuan 14.wav,1,Dirosa Pertemuan 14.wav,12.04,22.77,26.99,29.98,38.25,49.92,63.03,70.49,74.67,76.39,79.34,79.7,4
|
| 8 |
+
1_Dirosa Pertemuan 15.wav,1,Dirosa Pertemuan 15.wav,12.01,20.28,23.0,28.08,35.62,43.93,54.63,61.31,66.31,67.74,72.92,70.79,3
|
| 9 |
+
1_Dirosa Pertemuan 16.wav,1,Dirosa Pertemuan 16.wav,6.76,12.12,13.74,14.85,21.03,28.84,40.29,48.96,56.82,60.38,65.39,65.14,3
|
| 10 |
+
1_Dirosa Pertemuan 17.wav,1,Dirosa Pertemuan 17.wav,9.24,14.61,17.27,19.42,26.74,34.83,44.96,50.64,57.8,59.55,65.92,65.83,3
|
| 11 |
+
1_Dirosa Pertemuan 18.wav,1,Dirosa Pertemuan 18.wav,10.58,20.2,26.37,29.61,36.87,43.18,51.53,55.18,60.31,62.85,66.78,63.58,2
|
| 12 |
+
1_Dirosa Pertemuan 19.wav,1,Dirosa Pertemuan 19.wav,8.23,19.22,23.5,24.63,31.71,36.86,43.47,49.24,53.12,49.6,56.23,41.71,2
|
| 13 |
+
1_Dirosa Pertemuan 2.wav,1,Dirosa Pertemuan 2.wav,13.3,19.62,22.99,25.19,32.36,40.23,48.74,53.94,59.5,58.87,66.68,62.57,2
|
| 14 |
+
1_Dirosa Pertemuan 20.wav,1,Dirosa Pertemuan 20.wav,19.18,37.95,45.48,50.08,54.58,61.63,70.07,74.09,77.73,76.7,80.52,76.76,3
|
| 15 |
+
1_Dirosa Pertemuan 3.wav,1,Dirosa Pertemuan 3.wav,8.31,14.56,17.39,21.17,28.35,34.5,42.47,48.12,50.83,48.44,60.32,52.11,2
|
| 16 |
+
1_Dirosa Pertemuan 4.wav,1,Dirosa Pertemuan 4.wav,19.58,28.28,30.31,35.78,44.94,53.27,61.89,67.04,69.55,69.05,75.38,71.11,4
|
| 17 |
+
1_Dirosa Pertemuan 5.wav,1,Dirosa Pertemuan 5.wav,12.92,21.33,23.35,26.32,33.49,42.35,51.21,54.85,59.45,60.72,66.01,65.19,3
|
| 18 |
+
1_Dirosa Pertemuan 6.wav,1,Dirosa Pertemuan 6.wav,14.6,22.29,26.08,28.7,36.26,45.44,54.97,60.74,65.86,66.6,72.3,71.01,3
|
| 19 |
+
1_Dirosa Pertemuan 7.wav,1,Dirosa Pertemuan 7.wav,8.74,13.66,15.32,16.97,22.7,29.73,39.65,45.88,52.22,54.26,62.38,60.79,2
|
| 20 |
+
1_Dirosa Pertemuan 8.wav,1,Dirosa Pertemuan 8.wav,13.51,25.21,28.52,31.44,40.68,49.95,59.99,66.02,69.66,70.9,74.66,74.14,4
|
| 21 |
+
1_Dirosa Pertemuan 9.wav,1,Dirosa Pertemuan 9.wav,6.82,10.36,12.68,13.24,20.47,30.12,42.0,51.56,59.14,61.76,67.8,68.99,4
|
| 22 |
+
2_Dirosa Pertemuan 1.wav,2,Dirosa Pertemuan 1.wav,6.78,11.7,14.5,17.7,21.21,24.17,28.36,35.18,40.52,41.2,45.39,44.95,1
|
| 23 |
+
2_Dirosa Pertemuan 10.wav,2,Dirosa Pertemuan 10.wav,12.58,20.55,23.85,28.25,34.22,37.34,44.55,46.24,49.0,46.16,56.52,49.19,1
|
| 24 |
+
2_Dirosa Pertemuan 11.wav,2,Dirosa Pertemuan 11.wav,8.3,15.52,17.48,19.38,21.3,23.09,29.72,29.62,32.11,31.75,39.61,33.71,1
|
| 25 |
+
2_Dirosa Pertemuan 12.wav,2,Dirosa Pertemuan 12.wav,4.07,4.93,5.16,5.11,6.32,7.1,7.91,9.14,12.55,12.26,21.03,16.06,1
|
| 26 |
+
2_Dirosa Pertemuan 13.wav,2,Dirosa Pertemuan 13.wav,4.24,7.52,8.91,9.55,11.61,15.08,20.38,25.66,32.07,31.37,40.82,34.78,1
|
| 27 |
+
2_Dirosa Pertemuan 14.wav,2,Dirosa Pertemuan 14.wav,7.18,12.8,14.38,14.12,19.26,24.77,33.13,38.32,44.38,47.06,56.23,53.9,2
|
| 28 |
+
2_Dirosa Pertemuan 15.wav,2,Dirosa Pertemuan 15.wav,4.56,6.91,8.65,10.19,11.83,14.54,19.05,23.19,27.43,27.58,37.44,31.89,1
|
| 29 |
+
2_Dirosa Pertemuan 16.wav,2,Dirosa Pertemuan 16.wav,3.85,5.57,6.22,6.64,9.7,14.27,19.91,24.15,30.64,33.65,42.3,38.45,1
|
| 30 |
+
2_Dirosa Pertemuan 17.wav,2,Dirosa Pertemuan 17.wav,7.27,9.34,10.98,11.98,17.54,23.62,29.81,34.14,42.74,44.64,49.81,52.59,1
|
| 31 |
+
2_Dirosa Pertemuan 18.wav,2,Dirosa Pertemuan 18.wav,6.91,13.71,15.91,17.08,20.35,22.73,27.42,29.97,33.81,35.8,45.71,38.25,1
|
| 32 |
+
2_Dirosa Pertemuan 19.wav,2,Dirosa Pertemuan 19.wav,7.72,15.18,17.31,16.28,18.87,20.41,25.05,28.32,37.05,36.54,43.07,28.68,1
|
| 33 |
+
2_Dirosa Pertemuan 2.wav,2,Dirosa Pertemuan 2.wav,6.91,11.15,13.18,15.0,18.37,20.67,24.16,28.06,33.0,33.58,35.08,34.03,1
|
| 34 |
+
2_Dirosa Pertemuan 20.wav,2,Dirosa Pertemuan 20.wav,18.58,34.0,38.24,40.68,45.13,48.68,55.71,57.44,61.47,59.89,68.17,60.68,1
|
| 35 |
+
2_Dirosa Pertemuan 3.wav,2,Dirosa Pertemuan 3.wav,5.5,7.99,8.24,8.53,11.74,14.05,18.67,25.15,29.64,30.83,43.83,34.59,1
|
| 36 |
+
2_Dirosa Pertemuan 4.wav,2,Dirosa Pertemuan 4.wav,12.33,22.23,24.28,28.17,33.54,36.03,39.83,42.63,46.51,47.1,48.9,50.72,2
|
| 37 |
+
2_Dirosa Pertemuan 5.wav,2,Dirosa Pertemuan 5.wav,6.46,11.59,12.91,13.4,15.67,17.68,19.7,21.91,25.71,27.04,28.71,31.21,1
|
| 38 |
+
2_Dirosa Pertemuan 6.wav,2,Dirosa Pertemuan 6.wav,7.03,9.96,10.61,11.44,14.21,17.64,23.44,27.6,32.97,34.94,44.54,39.75,1
|
| 39 |
+
2_Dirosa Pertemuan 7.wav,2,Dirosa Pertemuan 7.wav,3.57,5.21,5.86,6.04,8.09,9.42,12.24,15.18,21.46,23.38,31.26,27.68,1
|
| 40 |
+
2_Dirosa Pertemuan 8.wav,2,Dirosa Pertemuan 8.wav,7.24,11.98,13.58,15.25,18.72,22.6,29.15,36.38,43.63,45.32,54.88,51.11,2
|
| 41 |
+
2_Dirosa Pertemuan 9.wav,2,Dirosa Pertemuan 9.wav,5.53,8.49,8.95,9.11,12.49,17.4,22.47,28.61,34.22,35.84,42.72,42.15,1
|
| 42 |
+
3_Dirosa Pertemuan 1.wav,3,Dirosa Pertemuan 1.wav,12.72,18.38,21.48,23.81,29.23,36.19,47.04,55.7,63.21,64.75,73.59,71.21,3
|
| 43 |
+
3_Dirosa Pertemuan 10.wav,3,Dirosa Pertemuan 10.wav,14.85,21.1,25.74,27.92,32.29,39.04,44.67,49.6,53.09,55.77,65.94,62.44,1
|
| 44 |
+
3_Dirosa Pertemuan 11.wav,3,Dirosa Pertemuan 11.wav,16.06,25.19,27.41,30.97,35.93,42.27,52.77,57.3,61.78,63.21,70.63,65.62,3
|
| 45 |
+
3_Dirosa Pertemuan 12.wav,3,Dirosa Pertemuan 12.wav,10.9,15.2,17.0,19.71,23.3,25.22,26.97,34.33,42.21,44.63,57.5,51.49,1
|
| 46 |
+
3_Dirosa Pertemuan 13.wav,3,Dirosa Pertemuan 13.wav,6.48,10.69,12.09,14.46,18.88,27.3,36.61,45.82,52.99,54.97,61.41,57.36,2
|
| 47 |
+
3_Dirosa Pertemuan 14.wav,3,Dirosa Pertemuan 14.wav,7.75,13.54,15.94,17.38,23.58,32.52,44.31,53.79,61.13,63.99,69.28,67.19,3
|
| 48 |
+
3_Dirosa Pertemuan 15.wav,3,Dirosa Pertemuan 15.wav,6.62,10.98,13.05,15.51,19.44,24.38,31.32,38.62,46.76,48.06,56.08,51.99,3
|
| 49 |
+
3_Dirosa Pertemuan 16.wav,3,Dirosa Pertemuan 16.wav,6.64,9.6,10.28,10.35,13.58,18.98,25.99,34.44,43.43,48.15,54.38,54.21,3
|
| 50 |
+
3_Dirosa Pertemuan 17.wav,3,Dirosa Pertemuan 17.wav,7.51,11.32,12.89,13.92,19.26,26.52,36.04,44.96,54.12,55.92,63.72,63.39,2
|
| 51 |
+
3_Dirosa Pertemuan 18.wav,3,Dirosa Pertemuan 18.wav,13.76,22.43,24.38,25.92,29.31,31.68,36.5,40.85,46.38,51.24,58.98,53.43,2
|
| 52 |
+
3_Dirosa Pertemuan 19.wav,3,Dirosa Pertemuan 19.wav,6.42,11.28,11.37,10.92,12.15,11.63,14.56,20.51,23.8,25.04,34.3,22.2,1
|
| 53 |
+
3_Dirosa Pertemuan 2.wav,3,Dirosa Pertemuan 2.wav,9.5,12.29,14.1,15.66,20.48,25.39,33.6,42.53,48.74,49.9,60.26,56.71,2
|
| 54 |
+
3_Dirosa Pertemuan 20.wav,3,Dirosa Pertemuan 20.wav,18.95,35.73,40.83,43.55,46.64,51.21,60.13,65.12,70.88,70.19,76.14,72.14,2
|
| 55 |
+
3_Dirosa Pertemuan 3.wav,3,Dirosa Pertemuan 3.wav,7.18,9.69,9.08,9.08,12.48,14.31,18.19,22.32,28.92,29.05,41.3,31.33,2
|
| 56 |
+
3_Dirosa Pertemuan 4.wav,3,Dirosa Pertemuan 4.wav,9.05,12.87,15.73,18.91,23.16,28.47,36.29,42.92,48.23,50.43,60.99,57.68,3
|
| 57 |
+
3_Dirosa Pertemuan 5.wav,3,Dirosa Pertemuan 5.wav,7.92,10.57,10.83,11.37,14.91,19.69,25.25,30.03,36.17,38.16,48.29,45.1,2
|
| 58 |
+
3_Dirosa Pertemuan 6.wav,3,Dirosa Pertemuan 6.wav,8.23,12.28,13.99,15.02,18.46,24.25,33.14,39.28,46.46,49.25,58.9,55.04,2
|
| 59 |
+
3_Dirosa Pertemuan 7.wav,3,Dirosa Pertemuan 7.wav,6.34,8.97,10.02,10.65,13.92,17.86,23.89,29.83,37.82,42.63,52.51,47.81,2
|
| 60 |
+
3_Dirosa Pertemuan 8.wav,3,Dirosa Pertemuan 8.wav,8.82,12.85,14.15,17.0,21.47,28.1,36.2,42.23,48.83,51.25,61.76,60.55,3
|
| 61 |
+
3_Dirosa Pertemuan 9.wav,3,Dirosa Pertemuan 9.wav,4.33,7.12,8.24,8.51,12.32,19.6,29.1,38.09,47.55,50.29,59.1,56.03,2
|
| 62 |
+
4_Dirosa Pertemuan 1.wav,4,Dirosa Pertemuan 1.wav,6.65,11.64,13.52,16.12,19.01,22.88,29.66,38.34,43.77,44.45,53.45,50.8,2
|
| 63 |
+
4_Dirosa Pertemuan 10.wav,4,Dirosa Pertemuan 10.wav,6.22,13.33,15.91,18.8,20.56,23.82,28.62,33.5,36.47,37.09,43.45,40.45,1
|
| 64 |
+
4_Dirosa Pertemuan 11.wav,4,Dirosa Pertemuan 11.wav,4.52,10.06,11.75,14.22,15.18,18.27,24.91,26.13,30.63,32.06,44.53,37.92,1
|
| 65 |
+
4_Dirosa Pertemuan 12.wav,4,Dirosa Pertemuan 12.wav,2.34,3.63,4.04,4.45,5.24,6.47,8.01,9.56,13.37,13.02,20.02,16.36,1
|
| 66 |
+
4_Dirosa Pertemuan 13.wav,4,Dirosa Pertemuan 13.wav,5.14,10.05,13.14,15.53,20.55,28.84,39.24,46.97,54.4,56.08,57.29,58.89,1
|
| 67 |
+
4_Dirosa Pertemuan 14.wav,4,Dirosa Pertemuan 14.wav,9.16,15.85,18.21,17.93,23.42,33.43,42.18,47.9,55.19,57.14,61.75,63.03,1
|
| 68 |
+
4_Dirosa Pertemuan 15.wav,4,Dirosa Pertemuan 15.wav,4.81,9.87,12.7,14.85,17.67,21.68,28.88,36.44,42.75,43.24,50.08,46.68,1
|
| 69 |
+
4_Dirosa Pertemuan 16.wav,4,Dirosa Pertemuan 16.wav,3.96,6.52,7.42,7.63,10.08,13.63,18.52,25.58,30.6,34.11,39.11,39.49,1
|
| 70 |
+
4_Dirosa Pertemuan 17.wav,4,Dirosa Pertemuan 17.wav,4.35,5.47,6.79,8.1,10.73,13.08,15.35,19.95,25.92,29.01,35.24,33.98,1
|
| 71 |
+
4_Dirosa Pertemuan 18.wav,4,Dirosa Pertemuan 18.wav,3.1,6.91,11.11,12.15,13.53,14.63,18.74,22.01,26.09,30.64,37.35,37.56,1
|
| 72 |
+
4_Dirosa Pertemuan 19.wav,4,Dirosa Pertemuan 19.wav,4.91,8.89,9.17,8.93,10.27,9.6,13.25,17.0,20.17,21.4,30.03,21.74,1
|
| 73 |
+
4_Dirosa Pertemuan 2.wav,4,Dirosa Pertemuan 2.wav,4.47,8.36,10.04,12.57,15.41,17.92,23.51,26.1,30.96,31.06,38.03,32.87,1
|
| 74 |
+
4_Dirosa Pertemuan 20.wav,4,Dirosa Pertemuan 20.wav,9.19,21.09,23.65,21.89,24.92,27.25,30.91,36.04,37.74,40.86,56.73,51.21,1
|
| 75 |
+
4_Dirosa Pertemuan 3.wav,4,Dirosa Pertemuan 3.wav,5.72,10.25,11.08,11.78,15.22,18.82,24.8,31.06,34.62,34.93,42.47,35.01,1
|
| 76 |
+
4_Dirosa Pertemuan 4.wav,4,Dirosa Pertemuan 4.wav,8.45,15.7,19.21,23.4,28.81,34.16,40.76,45.3,50.56,53.01,60.93,55.71,2
|
| 77 |
+
4_Dirosa Pertemuan 5.wav,4,Dirosa Pertemuan 5.wav,6.01,10.97,13.26,15.12,20.11,26.89,35.49,39.08,44.19,46.59,53.5,51.15,2
|
| 78 |
+
4_Dirosa Pertemuan 6.wav,4,Dirosa Pertemuan 6.wav,2.97,5.16,6.43,7.6,9.29,11.79,17.19,20.72,27.66,28.62,39.17,33.27,1
|
| 79 |
+
4_Dirosa Pertemuan 7.wav,4,Dirosa Pertemuan 7.wav,2.91,5.34,6.24,7.02,8.41,10.13,14.27,19.36,26.79,28.85,36.68,31.09,1
|
| 80 |
+
4_Dirosa Pertemuan 8.wav,4,Dirosa Pertemuan 8.wav,5.62,11.61,14.5,17.29,21.36,27.71,35.63,41.56,47.96,49.41,59.67,57.1,2
|
| 81 |
+
4_Dirosa Pertemuan 9.wav,4,Dirosa Pertemuan 9.wav,4.97,6.78,7.99,9.44,13.88,20.26,28.12,37.23,45.12,47.95,54.6,56.13,1
|
| 82 |
+
5_Dirosa Pertemuan 1.wav,5,Dirosa Pertemuan 1.wav,7.37,11.09,11.76,13.19,16.11,20.29,26.22,33.61,38.96,38.89,50.79,44.22,1
|
| 83 |
+
5_Dirosa Pertemuan 10.wav,5,Dirosa Pertemuan 10.wav,5.01,6.23,7.8,10.38,14.9,17.39,20.53,24.4,27.57,33.82,47.79,45.61,1
|
| 84 |
+
5_Dirosa Pertemuan 11.wav,5,Dirosa Pertemuan 11.wav,9.43,17.29,19.75,22.14,25.4,28.04,34.52,39.48,44.08,46.82,58.94,53.18,1
|
| 85 |
+
5_Dirosa Pertemuan 12.wav,5,Dirosa Pertemuan 12.wav,8.14,10.18,12.12,13.1,15.14,19.05,24.74,30.85,39.55,39.84,50.16,45.71,1
|
| 86 |
+
5_Dirosa Pertemuan 13.wav,5,Dirosa Pertemuan 13.wav,3.85,5.8,7.17,7.66,9.07,11.35,14.65,19.92,24.06,27.29,39.51,31.41,1
|
| 87 |
+
5_Dirosa Pertemuan 14.wav,5,Dirosa Pertemuan 14.wav,6.55,11.8,14.14,14.36,18.74,24.66,34.61,44.15,52.89,55.57,63.42,58.86,2
|
| 88 |
+
5_Dirosa Pertemuan 15.wav,5,Dirosa Pertemuan 15.wav,3.6,5.97,6.44,6.87,7.65,8.01,9.92,13.76,16.97,19.06,29.84,22.74,1
|
| 89 |
+
5_Dirosa Pertemuan 16.wav,5,Dirosa Pertemuan 16.wav,4.7,7.44,8.79,10.05,12.54,16.97,24.8,32.54,41.58,44.62,51.28,46.4,1
|
| 90 |
+
5_Dirosa Pertemuan 17.wav,5,Dirosa Pertemuan 17.wav,4.46,6.86,8.3,9.38,11.91,16.03,22.74,29.51,38.23,38.87,47.75,44.15,1
|
| 91 |
+
5_Dirosa Pertemuan 18.wav,5,Dirosa Pertemuan 18.wav,7.78,16.21,19.09,20.09,22.84,25.47,32.97,38.21,42.85,47.18,56.51,50.3,1
|
| 92 |
+
5_Dirosa Pertemuan 19.wav,5,Dirosa Pertemuan 19.wav,5.31,11.2,12.14,11.95,12.49,12.22,16.3,20.66,23.92,24.42,32.88,19.83,1
|
| 93 |
+
5_Dirosa Pertemuan 2.wav,5,Dirosa Pertemuan 2.wav,6.55,8.05,9.62,10.25,12.61,15.75,18.24,21.05,25.29,26.79,37.24,30.04,1
|
| 94 |
+
5_Dirosa Pertemuan 20.wav,5,Dirosa Pertemuan 20.wav,11.05,25.81,31.86,32.8,34.3,38.43,46.64,52.21,57.33,55.8,65.82,56.74,1
|
| 95 |
+
5_Dirosa Pertemuan 3.wav,5,Dirosa Pertemuan 3.wav,5.53,8.37,7.74,8.19,10.79,11.9,15.97,21.36,25.17,25.74,35.63,25.3,1
|
| 96 |
+
5_Dirosa Pertemuan 4.wav,5,Dirosa Pertemuan 4.wav,7.69,12.55,15.23,18.32,22.04,25.99,33.1,39.91,46.49,47.19,59.95,51.13,2
|
| 97 |
+
5_Dirosa Pertemuan 5.wav,5,Dirosa Pertemuan 5.wav,9.31,14.24,15.52,18.12,21.15,25.92,31.8,37.69,43.0,45.78,53.41,48.79,1
|
| 98 |
+
5_Dirosa Pertemuan 6.wav,5,Dirosa Pertemuan 6.wav,7.86,11.77,14.14,14.93,16.83,19.33,25.14,31.53,35.86,38.77,48.87,43.13,1
|
| 99 |
+
5_Dirosa Pertemuan 7.wav,5,Dirosa Pertemuan 7.wav,4.09,5.61,6.31,6.77,8.13,10.2,13.64,18.28,24.61,27.02,37.87,31.63,1
|
| 100 |
+
5_Dirosa Pertemuan 8.wav,5,Dirosa Pertemuan 8.wav,6.48,10.83,13.08,16.14,20.2,26.34,34.77,42.04,49.3,53.1,63.42,61.57,2
|
| 101 |
+
5_Dirosa Pertemuan 9.wav,5,Dirosa Pertemuan 9.wav,4.02,6.49,7.71,8.51,12.31,16.84,23.16,30.14,37.21,40.39,50.89,46.1,1
|
dtw_similarity.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DTW Similarity Module
|
| 3 |
+
Computes Dynamic Time Warping with cosine distance between frame sequences.
|
| 4 |
+
Supports Sakoe-Chiba band constraint and path-length normalisation.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import logging
|
| 8 |
+
import math
|
| 9 |
+
|
| 10 |
+
import torch
|
| 11 |
+
import numpy as np
|
| 12 |
+
from typing import Tuple, Optional
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class DTWSimilarity:
|
| 18 |
+
"""Computes DTW-based similarity between feature sequences."""
|
| 19 |
+
|
| 20 |
+
def __init__(self, distance_metric: str = "cosine", sakoe_chiba_ratio: float = 0.1):
|
| 21 |
+
"""
|
| 22 |
+
Initialize DTW similarity calculator.
|
| 23 |
+
|
| 24 |
+
Args:
|
| 25 |
+
distance_metric: Distance metric to use ("cosine" or "euclidean")
|
| 26 |
+
sakoe_chiba_ratio: Sakoe-Chiba band radius as a fraction of the
|
| 27 |
+
longer sequence length (0.0 disables the constraint).
|
| 28 |
+
Recommended range: 0.10 – 0.2.
|
| 29 |
+
"""
|
| 30 |
+
self.distance_metric = distance_metric
|
| 31 |
+
self.sakoe_chiba_ratio = sakoe_chiba_ratio
|
| 32 |
+
|
| 33 |
+
# ------------------------------------------------------------------
|
| 34 |
+
# Pairwise distance helpers
|
| 35 |
+
# ------------------------------------------------------------------
|
| 36 |
+
|
| 37 |
+
def cosine_distance(self, x: np.ndarray, y: np.ndarray) -> float:
|
| 38 |
+
"""
|
| 39 |
+
Compute cosine distance between two vectors.
|
| 40 |
+
|
| 41 |
+
Args:
|
| 42 |
+
x: First vector
|
| 43 |
+
y: Second vector
|
| 44 |
+
|
| 45 |
+
Returns:
|
| 46 |
+
Cosine distance (1 - cosine similarity), clamped to [0, 2].
|
| 47 |
+
"""
|
| 48 |
+
x_norm = x / (np.linalg.norm(x) + 1e-8)
|
| 49 |
+
y_norm = y / (np.linalg.norm(y) + 1e-8)
|
| 50 |
+
similarity = float(np.clip(np.dot(x_norm, y_norm), -1.0, 1.0))
|
| 51 |
+
return 1.0 - similarity
|
| 52 |
+
|
| 53 |
+
@staticmethod
|
| 54 |
+
def _cosine_distance_matrix(seq1: np.ndarray, seq2: np.ndarray) -> np.ndarray:
|
| 55 |
+
"""
|
| 56 |
+
Vectorised cosine distance matrix between two frame sequences.
|
| 57 |
+
|
| 58 |
+
Args:
|
| 59 |
+
seq1: (N, D) array
|
| 60 |
+
seq2: (M, D) array
|
| 61 |
+
|
| 62 |
+
Returns:
|
| 63 |
+
(N, M) cosine-distance matrix with values in [0, 2].
|
| 64 |
+
"""
|
| 65 |
+
norms1 = np.linalg.norm(seq1, axis=1, keepdims=True) + 1e-8
|
| 66 |
+
norms2 = np.linalg.norm(seq2, axis=1, keepdims=True) + 1e-8
|
| 67 |
+
sim = (seq1 / norms1) @ (seq2 / norms2).T
|
| 68 |
+
np.clip(sim, -1.0, 1.0, out=sim)
|
| 69 |
+
return 1.0 - sim
|
| 70 |
+
|
| 71 |
+
def euclidean_distance(self, x: np.ndarray, y: np.ndarray) -> float:
|
| 72 |
+
"""
|
| 73 |
+
Compute Euclidean distance between two vectors.
|
| 74 |
+
|
| 75 |
+
Args:
|
| 76 |
+
x: First vector
|
| 77 |
+
y: Second vector
|
| 78 |
+
|
| 79 |
+
Returns:
|
| 80 |
+
Euclidean distance
|
| 81 |
+
"""
|
| 82 |
+
return np.linalg.norm(x - y)
|
| 83 |
+
|
| 84 |
+
def compute_cost_matrix(
|
| 85 |
+
self,
|
| 86 |
+
seq1: np.ndarray,
|
| 87 |
+
seq2: np.ndarray
|
| 88 |
+
) -> np.ndarray:
|
| 89 |
+
"""
|
| 90 |
+
Compute pairwise distance cost matrix (vectorised).
|
| 91 |
+
|
| 92 |
+
Args:
|
| 93 |
+
seq1: First sequence (n_frames1, feature_dim)
|
| 94 |
+
seq2: Second sequence (n_frames2, feature_dim)
|
| 95 |
+
|
| 96 |
+
Returns:
|
| 97 |
+
Cost matrix (n_frames1, n_frames2)
|
| 98 |
+
"""
|
| 99 |
+
if self.distance_metric == "cosine":
|
| 100 |
+
return self._cosine_distance_matrix(seq1, seq2)
|
| 101 |
+
else:
|
| 102 |
+
# Vectorised Euclidean distance
|
| 103 |
+
diff = seq1[:, np.newaxis, :] - seq2[np.newaxis, :, :]
|
| 104 |
+
return np.linalg.norm(diff, axis=2)
|
| 105 |
+
|
| 106 |
+
def dtw(
|
| 107 |
+
self,
|
| 108 |
+
seq1: np.ndarray,
|
| 109 |
+
seq2: np.ndarray
|
| 110 |
+
) -> Tuple[float, np.ndarray]:
|
| 111 |
+
"""
|
| 112 |
+
Compute Dynamic Time Warping distance with Sakoe-Chiba band.
|
| 113 |
+
|
| 114 |
+
Args:
|
| 115 |
+
seq1: First feature sequence (n_frames1, feature_dim)
|
| 116 |
+
seq2: Second feature sequence (n_frames2, feature_dim)
|
| 117 |
+
|
| 118 |
+
Returns:
|
| 119 |
+
Tuple of (dtw_distance, accumulated_cost_matrix)
|
| 120 |
+
"""
|
| 121 |
+
# Compute cost matrix
|
| 122 |
+
cost_matrix = self.compute_cost_matrix(seq1, seq2)
|
| 123 |
+
|
| 124 |
+
n1, n2 = cost_matrix.shape
|
| 125 |
+
|
| 126 |
+
# --- Sakoe-Chiba band ---
|
| 127 |
+
if self.sakoe_chiba_ratio > 0:
|
| 128 |
+
band_radius = max(1, int(round(self.sakoe_chiba_ratio * max(n1, n2))))
|
| 129 |
+
# Guarantee the band covers the endpoint when lengths differ
|
| 130 |
+
band_radius = max(band_radius, abs(n1 - n2))
|
| 131 |
+
else:
|
| 132 |
+
band_radius = max(n1, n2) # no constraint
|
| 133 |
+
|
| 134 |
+
# Initialize accumulated cost matrix
|
| 135 |
+
dtw_matrix = np.full((n1 + 1, n2 + 1), np.inf)
|
| 136 |
+
dtw_matrix[0, 0] = 0
|
| 137 |
+
|
| 138 |
+
# Fill DTW matrix within Sakoe-Chiba band
|
| 139 |
+
for i in range(1, n1 + 1):
|
| 140 |
+
# Expected diagonal column for row i
|
| 141 |
+
j_centre = int(round(i * n2 / n1)) if n1 > 0 else i
|
| 142 |
+
j_start = max(1, j_centre - band_radius)
|
| 143 |
+
j_end = min(n2, j_centre + band_radius)
|
| 144 |
+
for j in range(j_start, j_end + 1):
|
| 145 |
+
cost = cost_matrix[i - 1, j - 1]
|
| 146 |
+
dtw_matrix[i, j] = cost + min(
|
| 147 |
+
dtw_matrix[i - 1, j], # insertion
|
| 148 |
+
dtw_matrix[i, j - 1], # deletion
|
| 149 |
+
dtw_matrix[i - 1, j - 1] # match
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
# DTW distance is the value at bottom-right corner
|
| 153 |
+
dtw_distance = dtw_matrix[n1, n2]
|
| 154 |
+
|
| 155 |
+
logger.info(
|
| 156 |
+
"DTW raw | total_cost=%.6f | seq1_len=%d | seq2_len=%d | "
|
| 157 |
+
"band_radius=%d (ratio=%.2f)",
|
| 158 |
+
dtw_distance, n1, n2, band_radius, self.sakoe_chiba_ratio,
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
return dtw_distance, dtw_matrix
|
| 162 |
+
|
| 163 |
+
def compute_similarity(
|
| 164 |
+
self,
|
| 165 |
+
features1: torch.Tensor,
|
| 166 |
+
features2: torch.Tensor,
|
| 167 |
+
normalize: bool = True
|
| 168 |
+
) -> float:
|
| 169 |
+
"""
|
| 170 |
+
Compute DTW-based similarity score between two feature sequences.
|
| 171 |
+
|
| 172 |
+
Args:
|
| 173 |
+
features1: First feature sequence (n_frames1, feature_dim)
|
| 174 |
+
features2: Second feature sequence (n_frames2, feature_dim)
|
| 175 |
+
normalize: Whether to normalize by actual warping path length
|
| 176 |
+
|
| 177 |
+
Returns:
|
| 178 |
+
Similarity score (higher is more similar)
|
| 179 |
+
"""
|
| 180 |
+
# Convert to numpy
|
| 181 |
+
seq1 = features1.cpu().numpy() if torch.is_tensor(features1) else features1
|
| 182 |
+
seq2 = features2.cpu().numpy() if torch.is_tensor(features2) else features2
|
| 183 |
+
|
| 184 |
+
# Compute DTW distance
|
| 185 |
+
dtw_distance, dtw_matrix = self.dtw(seq1, seq2)
|
| 186 |
+
|
| 187 |
+
# Normalize by actual warping-path length
|
| 188 |
+
if normalize:
|
| 189 |
+
warping_path = self.compute_path(dtw_matrix)
|
| 190 |
+
path_length = len(warping_path)
|
| 191 |
+
normalized_distance = (
|
| 192 |
+
dtw_distance / path_length if path_length > 0 else dtw_distance
|
| 193 |
+
)
|
| 194 |
+
else:
|
| 195 |
+
path_length = None
|
| 196 |
+
normalized_distance = dtw_distance
|
| 197 |
+
|
| 198 |
+
logger.info(
|
| 199 |
+
"DTW result | raw_distance=%.6f | path_length=%s | "
|
| 200 |
+
"normalized_distance=%.6f",
|
| 201 |
+
dtw_distance, path_length, normalized_distance,
|
| 202 |
+
)
|
| 203 |
+
|
| 204 |
+
return normalized_distance
|
| 205 |
+
|
| 206 |
+
def compute_path(
|
| 207 |
+
self,
|
| 208 |
+
dtw_matrix: np.ndarray
|
| 209 |
+
) -> list:
|
| 210 |
+
"""
|
| 211 |
+
Backtrack to find optimal alignment path.
|
| 212 |
+
|
| 213 |
+
Args:
|
| 214 |
+
dtw_matrix: Accumulated cost matrix from DTW
|
| 215 |
+
|
| 216 |
+
Returns:
|
| 217 |
+
List of (i, j) tuples representing the alignment path
|
| 218 |
+
"""
|
| 219 |
+
n1, n2 = dtw_matrix.shape
|
| 220 |
+
i, j = n1 - 1, n2 - 1
|
| 221 |
+
path = [(i - 1, j - 1)]
|
| 222 |
+
|
| 223 |
+
while i > 1 or j > 1:
|
| 224 |
+
if i == 1:
|
| 225 |
+
j -= 1
|
| 226 |
+
elif j == 1:
|
| 227 |
+
i -= 1
|
| 228 |
+
else:
|
| 229 |
+
# Choose minimum of three neighbors
|
| 230 |
+
candidates = [
|
| 231 |
+
dtw_matrix[i - 1, j - 1], # diagonal
|
| 232 |
+
dtw_matrix[i - 1, j], # up
|
| 233 |
+
dtw_matrix[i, j - 1] # left
|
| 234 |
+
]
|
| 235 |
+
min_idx = np.argmin(candidates)
|
| 236 |
+
|
| 237 |
+
if min_idx == 0:
|
| 238 |
+
i -= 1
|
| 239 |
+
j -= 1
|
| 240 |
+
elif min_idx == 1:
|
| 241 |
+
i -= 1
|
| 242 |
+
else:
|
| 243 |
+
j -= 1
|
| 244 |
+
|
| 245 |
+
path.append((i - 1, j - 1))
|
| 246 |
+
|
| 247 |
+
return path[::-1]
|
| 248 |
+
|
| 249 |
+
# ------------------------------------------------------------------
|
| 250 |
+
# Score conversion
|
| 251 |
+
# ------------------------------------------------------------------
|
| 252 |
+
|
| 253 |
+
@staticmethod
|
| 254 |
+
def distance_to_score(
|
| 255 |
+
normalized_distance: float,
|
| 256 |
+
midpoint: float = 0.3,
|
| 257 |
+
steepness: float = 10.0,
|
| 258 |
+
) -> float:
|
| 259 |
+
"""
|
| 260 |
+
Convert a path-normalised DTW cosine distance to a 0-100 score
|
| 261 |
+
using a logistic (sigmoid) mapping.
|
| 262 |
+
|
| 263 |
+
Calibration (defaults `midpoint=0.3, steepness=10.0`):
|
| 264 |
+
* identical pairs (d ≈ 0.05) → score ~ 92
|
| 265 |
+
* very similar (d ≈ 0.15) → score ~ 82
|
| 266 |
+
* borderline (d ≈ 0.30) → score = 50
|
| 267 |
+
* different pairs (d ≈ 0.35) → score ~ 38
|
| 268 |
+
* noise/non-speech (d > 0.50) → score < 12
|
| 269 |
+
|
| 270 |
+
Args:
|
| 271 |
+
normalized_distance: Path-normalised DTW distance.
|
| 272 |
+
midpoint: Distance value that maps to score 50.
|
| 273 |
+
steepness: Logistic steepness (higher = sharper transition).
|
| 274 |
+
|
| 275 |
+
Returns:
|
| 276 |
+
Similarity score in [0, 100].
|
| 277 |
+
"""
|
| 278 |
+
exponent = steepness * (normalized_distance - midpoint)
|
| 279 |
+
# Guard against overflow
|
| 280 |
+
exponent = max(-50.0, min(50.0, exponent))
|
| 281 |
+
score = 100.0 / (1.0 + math.exp(exponent))
|
| 282 |
+
return max(0.0, min(100.0, score))
|
requirements.txt
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Requirements for Qur'an Recitation Similarity Pipeline
|
| 2 |
+
# Python 3.9+
|
| 3 |
+
|
| 4 |
+
# Core
|
| 5 |
+
torch==2.12.0
|
| 6 |
+
torchaudio==2.11.0
|
| 7 |
+
transformers==4.57.3
|
| 8 |
+
|
| 9 |
+
# Numeric Data & ML
|
| 10 |
+
numpy==2.3.5
|
| 11 |
+
scipy==1.16.3
|
| 12 |
+
|
| 13 |
+
# Data Manipulation & I/O
|
| 14 |
+
pandas==2.3.3
|
| 15 |
+
soundfile==0.13.1
|
| 16 |
+
|
| 17 |
+
# Audio Processing
|
| 18 |
+
webrtcvad-wheels==2.0.14
|
| 19 |
+
|
| 20 |
+
# Web App & Visualizations
|
| 21 |
+
streamlit==1.52.2
|
| 22 |
+
matplotlib==3.10.8
|
run_similarity.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Run Similarity Script.
|
| 2 |
+
|
| 3 |
+
Supports:
|
| 4 |
+
- single mode: compare one participant file vs one reference file
|
| 5 |
+
- batch mode: compare all participant files in a folder against matching
|
| 6 |
+
reference files, then export tabular results to CSV/XLSX
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import argparse
|
| 12 |
+
import csv
|
| 13 |
+
import json
|
| 14 |
+
from datetime import datetime
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
from typing import Dict, List
|
| 17 |
+
|
| 18 |
+
from scoring import SimilarityScorer
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _find_wav_files(folder: Path, recursive: bool) -> List[Path]:
|
| 22 |
+
if recursive:
|
| 23 |
+
return sorted(folder.rglob("*.wav"))
|
| 24 |
+
return sorted(folder.glob("*.wav"))
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _write_batch_results(rows: List[Dict], output_path: Path) -> None:
|
| 28 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 29 |
+
|
| 30 |
+
if output_path.suffix.lower() == ".csv":
|
| 31 |
+
if not rows:
|
| 32 |
+
rows = [{"status": "no_data"}]
|
| 33 |
+
|
| 34 |
+
headers = sorted({k for row in rows for k in row.keys()})
|
| 35 |
+
with output_path.open("w", newline="", encoding="utf-8") as f:
|
| 36 |
+
writer = csv.DictWriter(f, fieldnames=headers)
|
| 37 |
+
writer.writeheader()
|
| 38 |
+
writer.writerows(rows)
|
| 39 |
+
return
|
| 40 |
+
|
| 41 |
+
if output_path.suffix.lower() == ".xlsx":
|
| 42 |
+
try:
|
| 43 |
+
import pandas as pd # type: ignore
|
| 44 |
+
except ImportError as exc:
|
| 45 |
+
raise RuntimeError(
|
| 46 |
+
"Output .xlsx membutuhkan pandas + openpyxl. "
|
| 47 |
+
"Install dulu: pip install pandas openpyxl"
|
| 48 |
+
) from exc
|
| 49 |
+
|
| 50 |
+
df = pd.DataFrame(rows)
|
| 51 |
+
df.to_excel(output_path, index=False)
|
| 52 |
+
return
|
| 53 |
+
|
| 54 |
+
raise ValueError("Format output tidak didukung. Pakai .csv atau .xlsx")
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _build_parser() -> argparse.ArgumentParser:
|
| 58 |
+
parser = argparse.ArgumentParser(
|
| 59 |
+
description="Compare Qur'an recitation audio in single or batch mode"
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
parser.add_argument(
|
| 63 |
+
"audio1",
|
| 64 |
+
nargs="?",
|
| 65 |
+
type=str,
|
| 66 |
+
help="Path audio peserta (single mode)",
|
| 67 |
+
)
|
| 68 |
+
parser.add_argument(
|
| 69 |
+
"audio2",
|
| 70 |
+
nargs="?",
|
| 71 |
+
type=str,
|
| 72 |
+
help="Path audio referensi (single mode)",
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
parser.add_argument(
|
| 76 |
+
"--participant-dir",
|
| 77 |
+
type=str,
|
| 78 |
+
default=None,
|
| 79 |
+
help="Folder audio peserta untuk batch mode",
|
| 80 |
+
)
|
| 81 |
+
parser.add_argument(
|
| 82 |
+
"--reference-dir",
|
| 83 |
+
type=str,
|
| 84 |
+
default=None,
|
| 85 |
+
help="Folder audio referensi untuk batch mode",
|
| 86 |
+
)
|
| 87 |
+
parser.add_argument(
|
| 88 |
+
"--recursive",
|
| 89 |
+
action="store_true",
|
| 90 |
+
help="Cari file .wav secara rekursif di subfolder (batch mode)",
|
| 91 |
+
)
|
| 92 |
+
parser.add_argument(
|
| 93 |
+
"--output",
|
| 94 |
+
type=str,
|
| 95 |
+
default="similarity_results.csv",
|
| 96 |
+
help="Path output hasil batch (.csv/.xlsx)",
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
parser.add_argument(
|
| 100 |
+
"--model",
|
| 101 |
+
type=str,
|
| 102 |
+
default="./wavlm-base-plus",
|
| 103 |
+
help="WavLM model name (default: ./wavlm-base-plus)",
|
| 104 |
+
)
|
| 105 |
+
parser.add_argument(
|
| 106 |
+
"--device",
|
| 107 |
+
type=str,
|
| 108 |
+
default=None,
|
| 109 |
+
help="Device to use (cuda/cpu, default: auto-detect)",
|
| 110 |
+
)
|
| 111 |
+
parser.add_argument(
|
| 112 |
+
"--distance",
|
| 113 |
+
type=str,
|
| 114 |
+
default="cosine",
|
| 115 |
+
choices=["cosine", "euclidean"],
|
| 116 |
+
help="Distance metric for DTW (default: cosine)",
|
| 117 |
+
)
|
| 118 |
+
parser.add_argument(
|
| 119 |
+
"--no-normalize",
|
| 120 |
+
action="store_true",
|
| 121 |
+
help="Disable DTW distance normalization",
|
| 122 |
+
)
|
| 123 |
+
parser.add_argument(
|
| 124 |
+
"--detailed",
|
| 125 |
+
action="store_true",
|
| 126 |
+
help="Show detailed metrics in single mode",
|
| 127 |
+
)
|
| 128 |
+
parser.add_argument(
|
| 129 |
+
"--json",
|
| 130 |
+
action="store_true",
|
| 131 |
+
help="Output results in JSON format",
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
return parser
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def _run_single(args: argparse.Namespace, scorer: SimilarityScorer) -> None:
|
| 138 |
+
if not args.audio1 or not args.audio2:
|
| 139 |
+
raise ValueError(
|
| 140 |
+
"Single mode membutuhkan 2 file: audio1 audio2. "
|
| 141 |
+
"Atau pakai --participant-dir dan --reference-dir untuk batch mode."
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
print("\nComparing audio files:")
|
| 145 |
+
print(f" Audio 1 (Participant): {args.audio1}")
|
| 146 |
+
print(f" Audio 2 (Reference): {args.audio2}")
|
| 147 |
+
print("\nProcessing...")
|
| 148 |
+
|
| 149 |
+
# One compute pass only to avoid doing the same heavy pipeline twice.
|
| 150 |
+
results = scorer.compute_detailed_similarity(args.audio1, args.audio2)
|
| 151 |
+
|
| 152 |
+
if not args.detailed:
|
| 153 |
+
results = {
|
| 154 |
+
"dtw_distance": results["dtw_distance"],
|
| 155 |
+
"similarity_score": results["similarity_score"],
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
if args.json:
|
| 159 |
+
print(json.dumps(results, indent=2))
|
| 160 |
+
return
|
| 161 |
+
|
| 162 |
+
print("\n" + "=" * 60)
|
| 163 |
+
print("SIMILARITY RESULTS")
|
| 164 |
+
print("=" * 60)
|
| 165 |
+
print(f"DTW Distance: {results['dtw_distance']:.6f}")
|
| 166 |
+
print(f"Similarity Score: {results['similarity_score']:.2f}/100")
|
| 167 |
+
|
| 168 |
+
if args.detailed:
|
| 169 |
+
print(f"\nAudio 1 Frames: {results['num_frames_audio1']}")
|
| 170 |
+
print(f"Audio 2 Frames: {results['num_frames_audio2']}")
|
| 171 |
+
print(f"Feature Dimension: {results['feature_dimension']}")
|
| 172 |
+
print(f"Distance Metric: {results['distance_metric']}")
|
| 173 |
+
print(f"Normalized: {results['normalized']}")
|
| 174 |
+
print(f"Path Length: {results['path_length']}")
|
| 175 |
+
|
| 176 |
+
print("=" * 60)
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def _run_batch(args: argparse.Namespace, scorer: SimilarityScorer) -> None:
|
| 180 |
+
participant_dir = Path(args.participant_dir)
|
| 181 |
+
reference_dir = Path(args.reference_dir)
|
| 182 |
+
output_path = Path(args.output)
|
| 183 |
+
|
| 184 |
+
if not participant_dir.exists() or not participant_dir.is_dir():
|
| 185 |
+
raise ValueError(f"Folder peserta tidak valid: {participant_dir}")
|
| 186 |
+
if not reference_dir.exists() or not reference_dir.is_dir():
|
| 187 |
+
raise ValueError(f"Folder referensi tidak valid: {reference_dir}")
|
| 188 |
+
|
| 189 |
+
participant_files = _find_wav_files(participant_dir, args.recursive)
|
| 190 |
+
reference_files = _find_wav_files(reference_dir, args.recursive)
|
| 191 |
+
|
| 192 |
+
ref_by_name = {f.name.lower(): f for f in reference_files}
|
| 193 |
+
ref_by_stem = {f.stem.lower(): f for f in reference_files}
|
| 194 |
+
|
| 195 |
+
rows: List[Dict] = []
|
| 196 |
+
matched = 0
|
| 197 |
+
failed = 0
|
| 198 |
+
missing_ref = 0
|
| 199 |
+
|
| 200 |
+
print("\nBatch mode")
|
| 201 |
+
print(f" Participant folder: {participant_dir}")
|
| 202 |
+
print(f" Reference folder: {reference_dir}")
|
| 203 |
+
print(f" Participant files: {len(participant_files)}")
|
| 204 |
+
print(f" Reference files: {len(reference_files)}")
|
| 205 |
+
print("\nProcessing...")
|
| 206 |
+
|
| 207 |
+
for idx, participant_file in enumerate(participant_files, 1):
|
| 208 |
+
key_name = participant_file.name.lower()
|
| 209 |
+
key_stem = participant_file.stem.lower()
|
| 210 |
+
reference_file = ref_by_name.get(key_name) or ref_by_stem.get(key_stem)
|
| 211 |
+
|
| 212 |
+
if reference_file is None:
|
| 213 |
+
missing_ref += 1
|
| 214 |
+
rows.append(
|
| 215 |
+
{
|
| 216 |
+
"index": idx,
|
| 217 |
+
"participant_file": str(participant_file),
|
| 218 |
+
"reference_file": "",
|
| 219 |
+
"status": "missing_reference",
|
| 220 |
+
"dtw_distance": "",
|
| 221 |
+
"similarity_score": "",
|
| 222 |
+
"error": "No matching reference by filename/stem",
|
| 223 |
+
"processed_at": datetime.now().isoformat(timespec="seconds"),
|
| 224 |
+
}
|
| 225 |
+
)
|
| 226 |
+
continue
|
| 227 |
+
|
| 228 |
+
try:
|
| 229 |
+
result = scorer.compute_detailed_similarity(
|
| 230 |
+
str(participant_file),
|
| 231 |
+
str(reference_file),
|
| 232 |
+
)
|
| 233 |
+
matched += 1
|
| 234 |
+
rows.append(
|
| 235 |
+
{
|
| 236 |
+
"index": idx,
|
| 237 |
+
"participant_file": str(participant_file),
|
| 238 |
+
"reference_file": str(reference_file),
|
| 239 |
+
"status": "ok",
|
| 240 |
+
"dtw_distance": float(result["dtw_distance"]),
|
| 241 |
+
"similarity_score": float(result["similarity_score"]),
|
| 242 |
+
"raw_dtw_distance": float(result["raw_dtw_distance"]),
|
| 243 |
+
"path_length": int(result["path_length"]),
|
| 244 |
+
"num_frames_audio1": int(result["num_frames_audio1"]),
|
| 245 |
+
"num_frames_audio2": int(result["num_frames_audio2"]),
|
| 246 |
+
"feature_dimension": int(result["feature_dimension"]),
|
| 247 |
+
"distance_metric": result["distance_metric"],
|
| 248 |
+
"normalized": bool(result["normalized"]),
|
| 249 |
+
"sakoe_chiba_ratio": float(result["sakoe_chiba_ratio"]),
|
| 250 |
+
"processed_at": datetime.now().isoformat(timespec="seconds"),
|
| 251 |
+
}
|
| 252 |
+
)
|
| 253 |
+
except Exception as exc: # pragma: no cover - runtime safety in batch
|
| 254 |
+
failed += 1
|
| 255 |
+
rows.append(
|
| 256 |
+
{
|
| 257 |
+
"index": idx,
|
| 258 |
+
"participant_file": str(participant_file),
|
| 259 |
+
"reference_file": str(reference_file),
|
| 260 |
+
"status": "error",
|
| 261 |
+
"dtw_distance": "",
|
| 262 |
+
"similarity_score": "",
|
| 263 |
+
"error": str(exc),
|
| 264 |
+
"processed_at": datetime.now().isoformat(timespec="seconds"),
|
| 265 |
+
}
|
| 266 |
+
)
|
| 267 |
+
|
| 268 |
+
if idx % 10 == 0:
|
| 269 |
+
print(f" Processed {idx}/{len(participant_files)} files...")
|
| 270 |
+
|
| 271 |
+
_write_batch_results(rows, output_path)
|
| 272 |
+
|
| 273 |
+
summary = {
|
| 274 |
+
"mode": "batch",
|
| 275 |
+
"participant_dir": str(participant_dir),
|
| 276 |
+
"reference_dir": str(reference_dir),
|
| 277 |
+
"output_file": str(output_path),
|
| 278 |
+
"participant_total": len(participant_files),
|
| 279 |
+
"reference_total": len(reference_files),
|
| 280 |
+
"matched_ok": matched,
|
| 281 |
+
"missing_reference": missing_ref,
|
| 282 |
+
"failed": failed,
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
if args.json:
|
| 286 |
+
print(json.dumps(summary, indent=2))
|
| 287 |
+
return
|
| 288 |
+
|
| 289 |
+
print("\n" + "=" * 60)
|
| 290 |
+
print("BATCH SIMILARITY SUMMARY")
|
| 291 |
+
print("=" * 60)
|
| 292 |
+
print(f"Participant Total: {summary['participant_total']}")
|
| 293 |
+
print(f"Reference Total: {summary['reference_total']}")
|
| 294 |
+
print(f"Matched (OK): {summary['matched_ok']}")
|
| 295 |
+
print(f"Missing Reference: {summary['missing_reference']}")
|
| 296 |
+
print(f"Failed: {summary['failed']}")
|
| 297 |
+
print(f"Output File: {summary['output_file']}")
|
| 298 |
+
print("=" * 60)
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
def main() -> None:
|
| 302 |
+
parser = _build_parser()
|
| 303 |
+
args = parser.parse_args()
|
| 304 |
+
|
| 305 |
+
is_batch = args.participant_dir is not None or args.reference_dir is not None
|
| 306 |
+
|
| 307 |
+
if is_batch and (args.audio1 or args.audio2):
|
| 308 |
+
raise ValueError(
|
| 309 |
+
"Jangan campur mode single dan batch. "
|
| 310 |
+
"Single: audio1 audio2 | Batch: --participant-dir --reference-dir"
|
| 311 |
+
)
|
| 312 |
+
|
| 313 |
+
if is_batch and (not args.participant_dir or not args.reference_dir):
|
| 314 |
+
raise ValueError("Batch mode membutuhkan --participant-dir dan --reference-dir")
|
| 315 |
+
|
| 316 |
+
print("Initializing similarity scorer...")
|
| 317 |
+
scorer = SimilarityScorer(
|
| 318 |
+
model_name=args.model,
|
| 319 |
+
device=args.device,
|
| 320 |
+
distance_metric=args.distance,
|
| 321 |
+
normalize_dtw=not args.no_normalize,
|
| 322 |
+
)
|
| 323 |
+
|
| 324 |
+
if is_batch:
|
| 325 |
+
_run_batch(args, scorer)
|
| 326 |
+
else:
|
| 327 |
+
_run_single(args, scorer)
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
if __name__ == "__main__":
|
| 331 |
+
main()
|
scoring.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Scoring Module
|
| 3 |
+
End-to-end pipeline for computing audio similarity scores.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import logging
|
| 7 |
+
|
| 8 |
+
import torch
|
| 9 |
+
from typing import Optional, Dict
|
| 10 |
+
from audio_loader import AudioLoader
|
| 11 |
+
from wavlm_encoder import WavLMEncoder
|
| 12 |
+
from dtw_similarity import DTWSimilarity
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class SimilarityScorer:
|
| 18 |
+
"""End-to-end similarity scoring pipeline."""
|
| 19 |
+
|
| 20 |
+
def __init__(
|
| 21 |
+
self,
|
| 22 |
+
model_name: str = "./wavlm-base-plus",
|
| 23 |
+
device: Optional[str] = None,
|
| 24 |
+
distance_metric: str = "cosine",
|
| 25 |
+
sakoe_chiba_ratio: float = 0.1,
|
| 26 |
+
normalize_dtw: bool = True,
|
| 27 |
+
score_midpoint: float = 0.3,
|
| 28 |
+
score_steepness: float = 10.0
|
| 29 |
+
):
|
| 30 |
+
"""
|
| 31 |
+
Initialize similarity scorer.
|
| 32 |
+
|
| 33 |
+
Args:
|
| 34 |
+
model_name: WavLM model name from HuggingFace
|
| 35 |
+
device: Device to run model on
|
| 36 |
+
distance_metric: Distance metric for DTW ("cosine" or "euclidean")
|
| 37 |
+
sakoe_chiba_ratio: Band radius ratio for DTW constraint
|
| 38 |
+
normalize_dtw: Whether to normalize DTW distance by path length
|
| 39 |
+
score_midpoint: Distance value that maps to 50 for the logisitic score
|
| 40 |
+
score_steepness: Logistic steepness for score mapping
|
| 41 |
+
"""
|
| 42 |
+
self.audio_loader = AudioLoader(target_sr=16000)
|
| 43 |
+
self.encoder = WavLMEncoder(model_name=model_name, device=device)
|
| 44 |
+
self.dtw_similarity = DTWSimilarity(distance_metric=distance_metric, sakoe_chiba_ratio=sakoe_chiba_ratio)
|
| 45 |
+
self.normalize_dtw = normalize_dtw
|
| 46 |
+
self.score_midpoint = score_midpoint
|
| 47 |
+
self.score_steepness = score_steepness
|
| 48 |
+
|
| 49 |
+
def compute_similarity(
|
| 50 |
+
self,
|
| 51 |
+
audio_path1: str,
|
| 52 |
+
audio_path2: str
|
| 53 |
+
) -> float:
|
| 54 |
+
"""
|
| 55 |
+
Compute similarity score between two audio files.
|
| 56 |
+
|
| 57 |
+
Args:
|
| 58 |
+
audio_path1: Path to first audio file (participant's recitation)
|
| 59 |
+
audio_path2: Path to second audio file (reference recitation)
|
| 60 |
+
|
| 61 |
+
Returns:
|
| 62 |
+
Similarity score (DTW distance - lower means more similar)
|
| 63 |
+
"""
|
| 64 |
+
# Load and preprocess audio
|
| 65 |
+
waveform1 = self.audio_loader.load_and_preprocess(audio_path1)
|
| 66 |
+
waveform2 = self.audio_loader.load_and_preprocess(audio_path2)
|
| 67 |
+
|
| 68 |
+
# Extract features
|
| 69 |
+
features1 = self.encoder.encode(waveform1)
|
| 70 |
+
features2 = self.encoder.encode(waveform2)
|
| 71 |
+
|
| 72 |
+
# Compute DTW similarity
|
| 73 |
+
dtw_distance = self.dtw_similarity.compute_similarity(
|
| 74 |
+
features1,
|
| 75 |
+
features2,
|
| 76 |
+
normalize=self.normalize_dtw
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
logger.info(
|
| 80 |
+
"compute_similarity | audio1=%s | audio2=%s | dtw_distance=%.6f",
|
| 81 |
+
audio_path1, audio_path2, dtw_distance,
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
return dtw_distance
|
| 85 |
+
|
| 86 |
+
def compute_similarity_score_normalized(
|
| 87 |
+
self,
|
| 88 |
+
audio_path1: str,
|
| 89 |
+
audio_path2: str
|
| 90 |
+
) -> float:
|
| 91 |
+
"""
|
| 92 |
+
Compute normalized similarity score (0-100 scale, higher is better).
|
| 93 |
+
|
| 94 |
+
Uses a logistic (sigmoid) mapping calibrated so that:
|
| 95 |
+
- identical pairs (d ≈ 0.05) → score ~ 92
|
| 96 |
+
- very similar (d ≈ 0.15) → score ~ 82
|
| 97 |
+
* borderline (d ≈ 0.30) → score = 50
|
| 98 |
+
* different pairs (d ≈ 0.35) → score ~ 38
|
| 99 |
+
* noise/non-speech (d > 0.50) → score < 12
|
| 100 |
+
|
| 101 |
+
Args:
|
| 102 |
+
audio_path1: Path to first audio file
|
| 103 |
+
audio_path2: Path to second audio file
|
| 104 |
+
|
| 105 |
+
Returns:
|
| 106 |
+
Normalized similarity score (0-100, where 100 is perfect match)
|
| 107 |
+
"""
|
| 108 |
+
# Get DTW distance (already path-normalised when self.normalize_dtw)
|
| 109 |
+
dtw_distance = self.compute_similarity(audio_path1, audio_path2)
|
| 110 |
+
|
| 111 |
+
# Logistic mapping (calibrated for cosine-distance DTW)
|
| 112 |
+
similarity_score = DTWSimilarity.distance_to_score(
|
| 113 |
+
dtw_distance,
|
| 114 |
+
midpoint=self.score_midpoint,
|
| 115 |
+
steepness=self.score_steepness
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
logger.info(
|
| 119 |
+
"score_normalized | dtw_distance=%.6f | score=%.2f",
|
| 120 |
+
dtw_distance, similarity_score,
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
return similarity_score
|
| 124 |
+
|
| 125 |
+
def compute_detailed_similarity(
|
| 126 |
+
self,
|
| 127 |
+
audio_path1: str,
|
| 128 |
+
audio_path2: str,
|
| 129 |
+
use_vad: bool = True,
|
| 130 |
+
layer_indices: Optional[list] = None
|
| 131 |
+
) -> Dict:
|
| 132 |
+
"""
|
| 133 |
+
Compute detailed similarity analysis (supports multiple layers).
|
| 134 |
+
|
| 135 |
+
Args:
|
| 136 |
+
audio_path1: Path to first audio file (reference)
|
| 137 |
+
audio_path2: Path to second audio file (test)
|
| 138 |
+
use_vad: Whether to use VAD endpoint trimming
|
| 139 |
+
layer_indices: List of WavLM layers to extract (defaults to [12])
|
| 140 |
+
|
| 141 |
+
Returns:
|
| 142 |
+
Dictionary containing detailed metrics, warping paths, DTW matrices, and original waveforms.
|
| 143 |
+
"""
|
| 144 |
+
if layer_indices is None:
|
| 145 |
+
layer_indices = [12]
|
| 146 |
+
|
| 147 |
+
self.audio_loader.use_vad = use_vad
|
| 148 |
+
|
| 149 |
+
# Process and retrieve both raw and VAD waveforms
|
| 150 |
+
waveform_ref_raw, sr_ref = self.audio_loader.load_audio(audio_path1)
|
| 151 |
+
waveform_test_raw, sr_test = self.audio_loader.load_audio(audio_path2)
|
| 152 |
+
|
| 153 |
+
if use_vad:
|
| 154 |
+
waveform_ref_vad = self.audio_loader.vad_trim_endpoints(waveform_ref_raw, sr_ref)
|
| 155 |
+
waveform_test_vad = self.audio_loader.vad_trim_endpoints(waveform_test_raw, sr_test)
|
| 156 |
+
else:
|
| 157 |
+
waveform_ref_vad = waveform_ref_raw
|
| 158 |
+
waveform_test_vad = waveform_test_raw
|
| 159 |
+
|
| 160 |
+
waveform_ref = self.audio_loader.normalize_audio(waveform_ref_vad)
|
| 161 |
+
waveform_test = self.audio_loader.normalize_audio(waveform_test_vad)
|
| 162 |
+
|
| 163 |
+
# Extract features for all requested layers
|
| 164 |
+
feat_ref_dict = self.encoder.encode(waveform_ref, extract_layers=layer_indices)
|
| 165 |
+
feat_test_dict = self.encoder.encode(waveform_test, extract_layers=layer_indices)
|
| 166 |
+
|
| 167 |
+
results = {}
|
| 168 |
+
for layer in layer_indices:
|
| 169 |
+
seq1 = feat_ref_dict[layer].cpu().numpy()
|
| 170 |
+
seq2 = feat_test_dict[layer].cpu().numpy()
|
| 171 |
+
|
| 172 |
+
# Compute DTW
|
| 173 |
+
raw_distance, dtw_matrix = self.dtw_similarity.dtw(seq1, seq2)
|
| 174 |
+
warping_path = self.dtw_similarity.compute_path(dtw_matrix)
|
| 175 |
+
path_length = len(warping_path)
|
| 176 |
+
|
| 177 |
+
# Normalize
|
| 178 |
+
if self.normalize_dtw and path_length > 0:
|
| 179 |
+
normalized_distance = raw_distance / path_length
|
| 180 |
+
else:
|
| 181 |
+
normalized_distance = raw_distance
|
| 182 |
+
|
| 183 |
+
# Logistic score
|
| 184 |
+
normalized_score = DTWSimilarity.distance_to_score(
|
| 185 |
+
normalized_distance,
|
| 186 |
+
midpoint=self.score_midpoint,
|
| 187 |
+
steepness=self.score_steepness
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
num_frames_ref = seq1.shape[0]
|
| 191 |
+
num_frames_test = seq2.shape[0]
|
| 192 |
+
sr = 16000
|
| 193 |
+
ref_duration_sec = round(num_frames_ref * 320 / sr, 3)
|
| 194 |
+
test_duration_sec = round(num_frames_test * 320 / sr, 3)
|
| 195 |
+
duration_ratio = round(test_duration_sec / ref_duration_sec, 4) if ref_duration_sec > 0 else 0.0
|
| 196 |
+
|
| 197 |
+
diagnostics = {
|
| 198 |
+
"raw_dtw_distance": float(raw_distance),
|
| 199 |
+
"normalized_distance": float(normalized_distance),
|
| 200 |
+
"path_length": int(path_length),
|
| 201 |
+
"num_frames_ref": num_frames_ref,
|
| 202 |
+
"num_frames_test": num_frames_test,
|
| 203 |
+
"feature_dimension": seq1.shape[1],
|
| 204 |
+
"ref_duration_sec": ref_duration_sec,
|
| 205 |
+
"test_duration_sec": test_duration_sec,
|
| 206 |
+
"duration_ratio": duration_ratio,
|
| 207 |
+
"distance_metric": self.dtw_similarity.distance_metric,
|
| 208 |
+
"normalized": self.normalize_dtw,
|
| 209 |
+
"sakoe_chiba_ratio": self.dtw_similarity.sakoe_chiba_ratio,
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
results[layer] = {
|
| 213 |
+
"score": normalized_score,
|
| 214 |
+
"dtw_distance": float(normalized_distance),
|
| 215 |
+
"raw_dtw_distance": float(raw_distance),
|
| 216 |
+
"warping_path": warping_path,
|
| 217 |
+
"dtw_matrix": dtw_matrix,
|
| 218 |
+
"diagnostics": diagnostics
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
return {
|
| 222 |
+
"results": results,
|
| 223 |
+
"waveforms": {
|
| 224 |
+
"ref_raw": waveform_ref_raw,
|
| 225 |
+
"ref_vad": waveform_ref_vad,
|
| 226 |
+
"ref_normalized": waveform_ref,
|
| 227 |
+
"test_raw": waveform_test_raw,
|
| 228 |
+
"test_vad": waveform_test_vad,
|
| 229 |
+
"test_normalized": waveform_test,
|
| 230 |
+
}
|
| 231 |
+
}
|
wavlm-base-plus/config.json
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"activation_dropout": 0.0,
|
| 3 |
+
"adapter_kernel_size": 3,
|
| 4 |
+
"adapter_stride": 2,
|
| 5 |
+
"add_adapter": false,
|
| 6 |
+
"apply_spec_augment": true,
|
| 7 |
+
"architectures": [
|
| 8 |
+
"WavLMModel"
|
| 9 |
+
],
|
| 10 |
+
"attention_dropout": 0.1,
|
| 11 |
+
"bos_token_id": 1,
|
| 12 |
+
"classifier_proj_size": 256,
|
| 13 |
+
"codevector_dim": 256,
|
| 14 |
+
"contrastive_logits_temperature": 0.1,
|
| 15 |
+
"conv_bias": false,
|
| 16 |
+
"conv_dim": [
|
| 17 |
+
512,
|
| 18 |
+
512,
|
| 19 |
+
512,
|
| 20 |
+
512,
|
| 21 |
+
512,
|
| 22 |
+
512,
|
| 23 |
+
512
|
| 24 |
+
],
|
| 25 |
+
"conv_kernel": [
|
| 26 |
+
10,
|
| 27 |
+
3,
|
| 28 |
+
3,
|
| 29 |
+
3,
|
| 30 |
+
3,
|
| 31 |
+
2,
|
| 32 |
+
2
|
| 33 |
+
],
|
| 34 |
+
"conv_stride": [
|
| 35 |
+
5,
|
| 36 |
+
2,
|
| 37 |
+
2,
|
| 38 |
+
2,
|
| 39 |
+
2,
|
| 40 |
+
2,
|
| 41 |
+
2
|
| 42 |
+
],
|
| 43 |
+
"ctc_loss_reduction": "sum",
|
| 44 |
+
"ctc_zero_infinity": false,
|
| 45 |
+
"diversity_loss_weight": 0.1,
|
| 46 |
+
"do_stable_layer_norm": false,
|
| 47 |
+
"dtype": "float32",
|
| 48 |
+
"eos_token_id": 2,
|
| 49 |
+
"feat_extract_activation": "gelu",
|
| 50 |
+
"feat_extract_norm": "group",
|
| 51 |
+
"feat_proj_dropout": 0.1,
|
| 52 |
+
"feat_quantizer_dropout": 0.0,
|
| 53 |
+
"final_dropout": 0.0,
|
| 54 |
+
"freeze_feat_extract_train": true,
|
| 55 |
+
"hidden_act": "gelu",
|
| 56 |
+
"hidden_dropout": 0.1,
|
| 57 |
+
"hidden_size": 768,
|
| 58 |
+
"initializer_range": 0.02,
|
| 59 |
+
"intermediate_size": 3072,
|
| 60 |
+
"layer_norm_eps": 1e-05,
|
| 61 |
+
"layerdrop": 0.05,
|
| 62 |
+
"mask_channel_length": 10,
|
| 63 |
+
"mask_channel_min_space": 1,
|
| 64 |
+
"mask_channel_other": 0.0,
|
| 65 |
+
"mask_channel_prob": 0.0,
|
| 66 |
+
"mask_channel_selection": "static",
|
| 67 |
+
"mask_feature_length": 10,
|
| 68 |
+
"mask_feature_min_masks": 0,
|
| 69 |
+
"mask_feature_prob": 0.0,
|
| 70 |
+
"mask_time_length": 10,
|
| 71 |
+
"mask_time_min_masks": 2,
|
| 72 |
+
"mask_time_min_space": 1,
|
| 73 |
+
"mask_time_other": 0.0,
|
| 74 |
+
"mask_time_prob": 0.05,
|
| 75 |
+
"mask_time_selection": "static",
|
| 76 |
+
"max_bucket_distance": 800,
|
| 77 |
+
"model_type": "wavlm",
|
| 78 |
+
"no_mask_channel_overlap": false,
|
| 79 |
+
"no_mask_time_overlap": false,
|
| 80 |
+
"num_adapter_layers": 3,
|
| 81 |
+
"num_attention_heads": 12,
|
| 82 |
+
"num_buckets": 320,
|
| 83 |
+
"num_codevector_groups": 2,
|
| 84 |
+
"num_codevectors_per_group": 320,
|
| 85 |
+
"num_conv_pos_embedding_groups": 16,
|
| 86 |
+
"num_conv_pos_embeddings": 128,
|
| 87 |
+
"num_ctc_classes": 80,
|
| 88 |
+
"num_feat_extract_layers": 7,
|
| 89 |
+
"num_hidden_layers": 12,
|
| 90 |
+
"num_negatives": 100,
|
| 91 |
+
"output_hidden_size": 768,
|
| 92 |
+
"pad_token_id": 0,
|
| 93 |
+
"proj_codevector_dim": 256,
|
| 94 |
+
"replace_prob": 0.5,
|
| 95 |
+
"tdnn_dilation": [
|
| 96 |
+
1,
|
| 97 |
+
2,
|
| 98 |
+
3,
|
| 99 |
+
1,
|
| 100 |
+
1
|
| 101 |
+
],
|
| 102 |
+
"tdnn_dim": [
|
| 103 |
+
512,
|
| 104 |
+
512,
|
| 105 |
+
512,
|
| 106 |
+
512,
|
| 107 |
+
1500
|
| 108 |
+
],
|
| 109 |
+
"tdnn_kernel": [
|
| 110 |
+
5,
|
| 111 |
+
3,
|
| 112 |
+
3,
|
| 113 |
+
1,
|
| 114 |
+
1
|
| 115 |
+
],
|
| 116 |
+
"tokenizer_class": "Wav2Vec2CTCTokenizer",
|
| 117 |
+
"transformers_version": "4.57.3",
|
| 118 |
+
"use_weighted_layer_sum": false,
|
| 119 |
+
"vocab_size": 32,
|
| 120 |
+
"xvector_output_dim": 512
|
| 121 |
+
}
|
wavlm-base-plus/model.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:8f1f0adf5eb20d7c049ae512949fdc16cb846a631cb9b7a3ff7da4a147b069ff
|
| 3 |
+
size 377555928
|
wavlm-base-plus/preprocessor_config.json
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"do_normalize": false,
|
| 3 |
+
"feature_extractor_type": "Wav2Vec2FeatureExtractor",
|
| 4 |
+
"feature_size": 1,
|
| 5 |
+
"padding_side": "right",
|
| 6 |
+
"padding_value": 0.0,
|
| 7 |
+
"return_attention_mask": true,
|
| 8 |
+
"sampling_rate": 16000
|
| 9 |
+
}
|
wavlm_encoder.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""WavLM Encoder Module
|
| 2 |
+
|
| 3 |
+
Extracts frame-level features using pretrained WavLM model (frozen).
|
| 4 |
+
|
| 5 |
+
Note:
|
| 6 |
+
- Some Python environments have an incompatible TensorFlow install (commonly due
|
| 7 |
+
to NumPy 2.x ABI changes). HuggingFace `transformers` may try to import TF as
|
| 8 |
+
an optional dependency; we explicitly disable TF/Flax to keep this pipeline
|
| 9 |
+
PyTorch-only.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import os
|
| 15 |
+
from typing import Optional
|
| 16 |
+
|
| 17 |
+
import torch
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class WavLMEncoder:
|
| 21 |
+
"""Extracts features from audio using pretrained WavLM model."""
|
| 22 |
+
|
| 23 |
+
def __init__(
|
| 24 |
+
self,
|
| 25 |
+
model_name: str = "./wavlm-base-plus",
|
| 26 |
+
device: Optional[str] = None,
|
| 27 |
+
layer_idx: int = -1
|
| 28 |
+
):
|
| 29 |
+
"""
|
| 30 |
+
Initialize WavLM encoder.
|
| 31 |
+
|
| 32 |
+
Args:
|
| 33 |
+
model_name: HuggingFace model identifier
|
| 34 |
+
device: Device to run model on ('cuda', 'cpu', or None for auto)
|
| 35 |
+
layer_idx: Which transformer layer to extract features from (-1 for last layer)
|
| 36 |
+
"""
|
| 37 |
+
# Force transformers to stay PyTorch-only (avoid importing TensorFlow/Flax)
|
| 38 |
+
os.environ.setdefault("TRANSFORMERS_NO_TF", "1")
|
| 39 |
+
os.environ.setdefault("TRANSFORMERS_NO_FLAX", "1")
|
| 40 |
+
os.environ.setdefault("USE_TF", "0")
|
| 41 |
+
os.environ.setdefault("USE_FLAX", "0")
|
| 42 |
+
|
| 43 |
+
# Local import so env vars above take effect before transformers loads
|
| 44 |
+
from transformers import AutoFeatureExtractor, WavLMModel
|
| 45 |
+
|
| 46 |
+
# Set device
|
| 47 |
+
if device is None:
|
| 48 |
+
self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 49 |
+
else:
|
| 50 |
+
self.device = torch.device(device)
|
| 51 |
+
|
| 52 |
+
# Load feature extractor and model
|
| 53 |
+
self.feature_extractor = AutoFeatureExtractor.from_pretrained(model_name)
|
| 54 |
+
self.model = WavLMModel.from_pretrained(model_name)
|
| 55 |
+
|
| 56 |
+
# Freeze model parameters (no fine-tuning)
|
| 57 |
+
for param in self.model.parameters():
|
| 58 |
+
param.requires_grad = False
|
| 59 |
+
|
| 60 |
+
# Move model to device and set to eval mode
|
| 61 |
+
self.model = self.model.to(self.device)
|
| 62 |
+
self.model.eval()
|
| 63 |
+
self.layer_idx = layer_idx
|
| 64 |
+
|
| 65 |
+
print(f"WavLM model loaded on {self.device}")
|
| 66 |
+
|
| 67 |
+
def encode(self, waveform: torch.Tensor, extract_layers: Optional[list[int]] = None) -> torch.Tensor | dict[int, torch.Tensor]:
|
| 68 |
+
"""
|
| 69 |
+
Extract frame-level features from audio waveform.
|
| 70 |
+
|
| 71 |
+
Args:
|
| 72 |
+
waveform: Audio waveform tensor, shape (1, num_samples) or (num_samples,)
|
| 73 |
+
extract_layers: Optional list of layer indices to extract. If provided, returns a dict of layer representations.
|
| 74 |
+
|
| 75 |
+
Returns:
|
| 76 |
+
If extract_layers is None, returns frame-level features for self.layer_idx, shape (num_frames, hidden_size)
|
| 77 |
+
If extract_layers is a list, returns a dictionary mapping layer index to feature tensor.
|
| 78 |
+
"""
|
| 79 |
+
# Ensure correct shape and convert directly to 1D numpy array
|
| 80 |
+
if waveform.dim() == 1:
|
| 81 |
+
waveform_np = waveform.detach().cpu().numpy()
|
| 82 |
+
elif waveform.dim() == 2 and waveform.shape[0] == 1:
|
| 83 |
+
waveform_np = waveform.squeeze(0).detach().cpu().numpy()
|
| 84 |
+
else:
|
| 85 |
+
raise ValueError(f"Expected waveform shape (num_samples,) or (1, num_samples), got {waveform.shape}")
|
| 86 |
+
|
| 87 |
+
# Extract features using feature extractor
|
| 88 |
+
inputs = self.feature_extractor(
|
| 89 |
+
waveform_np,
|
| 90 |
+
sampling_rate=16000,
|
| 91 |
+
return_tensors="pt",
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
# Move inputs to device
|
| 95 |
+
input_values = inputs.input_values.to(self.device)
|
| 96 |
+
|
| 97 |
+
# Extract features (no gradient computation)
|
| 98 |
+
with torch.no_grad():
|
| 99 |
+
outputs = self.model(input_values, output_hidden_states=True)
|
| 100 |
+
|
| 101 |
+
if extract_layers is not None:
|
| 102 |
+
features_dict = {}
|
| 103 |
+
for idx in extract_layers:
|
| 104 |
+
hidden_states = outputs.hidden_states[idx]
|
| 105 |
+
features_dict[idx] = hidden_states.squeeze(0)
|
| 106 |
+
return features_dict
|
| 107 |
+
else:
|
| 108 |
+
# Get the specified hidden state layer: (batch_size, num_frames, hidden_size)
|
| 109 |
+
hidden_states = outputs.hidden_states[self.layer_idx]
|
| 110 |
+
|
| 111 |
+
# Remove batch dimension and return
|
| 112 |
+
# Shape: (num_frames, hidden_size)
|
| 113 |
+
features = hidden_states.squeeze(0)
|
| 114 |
+
|
| 115 |
+
return features
|
| 116 |
+
|
| 117 |
+
def encode_batch(self, waveforms: list) -> list:
|
| 118 |
+
"""
|
| 119 |
+
Encode multiple waveforms.
|
| 120 |
+
|
| 121 |
+
Args:
|
| 122 |
+
waveforms: List of waveform tensors
|
| 123 |
+
|
| 124 |
+
Returns:
|
| 125 |
+
List of feature tensors
|
| 126 |
+
"""
|
| 127 |
+
features_list = []
|
| 128 |
+
for waveform in waveforms:
|
| 129 |
+
features = self.encode(waveform)
|
| 130 |
+
features_list.append(features)
|
| 131 |
+
return features_list
|
| 132 |
+
|
| 133 |
+
def get_feature_dim(self) -> int:
|
| 134 |
+
"""
|
| 135 |
+
Get the dimensionality of the feature vectors.
|
| 136 |
+
|
| 137 |
+
Returns:
|
| 138 |
+
Feature dimension size
|
| 139 |
+
"""
|
| 140 |
+
return self.model.config.hidden_size
|