AutoReXz commited on
Commit
1a0e6e8
·
verified ·
1 Parent(s): f77e2f9

Upload project with bundled WavLM model

Browse files
.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 &mdash; 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