Spaces:
Running
Running
Download app.js from Idrees0/urdu-speech-annotator: direct link, hf CLI and curl.
- Browser
- Download file 19 kB
-
https://huggingface.co/spaces/Idrees0/urdu-speech-annotator/resolve/main/app.js
- Command line
-
hf download hf://spaces/Idrees0/urdu-speech-annotator/app.js
-
curl -L -o app.js https://huggingface.co/spaces/Idrees0/urdu-speech-annotator/resolve/main/app.js
19 kB
| /** | |
| * Urdu Speech Annotator — 24/7 Cloud Client | |
| * Runs 100% in the browser on Hugging Face Static Spaces. | |
| * Persists annotations directly to the Hugging Face Dataset repo. | |
| */ | |
| // ================================================================ | |
| // Configuration | |
| // ================================================================ | |
| // Base64-encoded token for cloud persistence | |
| const _T = "aGZfelVFdU1kSWtnc2RyYWtHSEVmeFlmdmFVa0VkQmdac2VuUA=="; | |
| function getHfToken() { | |
| return localStorage.getItem("urdu_hf_token") || atob(_T); | |
| } | |
| const CONFIG = { | |
| ANNOTATIONS_REPO: "Idrees0/urdu-gold-annotations", | |
| AUDIO_REPO: "Idrees0/urdu-gold-audio", | |
| RAW_TRANSCRIPTS_URL: "https://huggingface.co/datasets/Idrees0/urdu-gold-annotations/raw/main/transcribe.jsonl", | |
| RAW_ANNOTATIONS_URL: "https://huggingface.co/datasets/Idrees0/urdu-gold-annotations/raw/main/annotations.jsonl", | |
| AUDIO_BASE_URL: "https://huggingface.co/datasets/Idrees0/urdu-gold-audio/resolve/main/", | |
| }; | |
| // ================================================================ | |
| // State | |
| // ================================================================ | |
| const state = { | |
| samples: [], | |
| annotations: {}, // sample_id -> annotation object | |
| currentIndex: 0, | |
| isPlaying: false, | |
| isSaving: false, | |
| }; | |
| // ================================================================ | |
| // DOM References | |
| // ================================================================ | |
| const dom = { | |
| audio: document.getElementById("audio-element"), | |
| playBtn: document.getElementById("play-btn"), | |
| playIcon: document.getElementById("play-icon"), | |
| pauseIcon: document.getElementById("pause-icon"), | |
| playerTrack: document.getElementById("player-track"), | |
| playerProgress: document.getElementById("player-progress"), | |
| playerTime: document.getElementById("player-time"), | |
| sampleId: document.getElementById("sample-id"), | |
| sampleCounter: document.getElementById("sample-counter"), | |
| annotationBadge: document.getElementById("annotation-badge"), | |
| transcriptText: document.getElementById("transcript-text"), | |
| progressFill: document.getElementById("progress-fill"), | |
| progressText: document.getElementById("progress-text"), | |
| btnPrev: document.getElementById("btn-prev"), | |
| btnNext: document.getElementById("btn-next"), | |
| btnJump: document.getElementById("btn-jump"), | |
| jumpInput: document.getElementById("jump-input"), | |
| form: document.getElementById("annotation-form"), | |
| submitBtn: document.getElementById("submit-btn"), | |
| skipBtn: document.getElementById("skip-btn"), | |
| annotatorId: document.getElementById("annotator-id"), | |
| notes: document.getElementById("notes"), | |
| exportBtn: document.getElementById("export-dropdown-btn"), | |
| exportMenu: document.getElementById("export-menu"), | |
| exportCsvBtn: document.getElementById("export-csv-btn"), | |
| exportJsonlBtn: document.getElementById("export-jsonl-btn"), | |
| exportJsonBtn: document.getElementById("export-json-btn"), | |
| toast: document.getElementById("toast"), | |
| toastMessage: document.getElementById("toast-message"), | |
| }; | |
| // ================================================================ | |
| // Export Dropdown | |
| // ================================================================ | |
| if (dom.exportBtn && dom.exportMenu) { | |
| dom.exportBtn.addEventListener("click", (e) => { | |
| e.stopPropagation(); | |
| dom.exportMenu.classList.toggle("hidden"); | |
| }); | |
| document.addEventListener("click", () => { | |
| dom.exportMenu.classList.add("hidden"); | |
| }); | |
| } | |
| // ================================================================ | |
| // Audio Player | |
| // ================================================================ | |
| function togglePlay() { | |
| if (dom.audio.paused) { | |
| dom.audio.play().catch(e => console.warn("Playback error:", e)); | |
| } else { | |
| dom.audio.pause(); | |
| } | |
| } | |
| dom.playBtn.addEventListener("click", togglePlay); | |
| dom.audio.addEventListener("loadedmetadata", () => { | |
| if (dom.audio.duration && !isNaN(dom.audio.duration)) { | |
| dom.playerTime.textContent = | |
| "0:00 / " + formatTime(dom.audio.duration); | |
| } | |
| }); | |
| dom.audio.addEventListener("error", (e) => { | |
| console.warn("Audio load error:", e); | |
| dom.playerTime.textContent = "Audio unavailable"; | |
| }); | |
| dom.audio.addEventListener("play", () => { | |
| state.isPlaying = true; | |
| dom.playIcon.classList.add("hidden"); | |
| dom.pauseIcon.classList.remove("hidden"); | |
| }); | |
| dom.audio.addEventListener("pause", () => { | |
| state.isPlaying = false; | |
| dom.playIcon.classList.remove("hidden"); | |
| dom.pauseIcon.classList.add("hidden"); | |
| }); | |
| dom.audio.addEventListener("timeupdate", () => { | |
| if (dom.audio.duration) { | |
| const pct = (dom.audio.currentTime / dom.audio.duration) * 100; | |
| dom.playerProgress.style.width = pct + "%"; | |
| dom.playerTime.textContent = | |
| formatTime(dom.audio.currentTime) + " / " + formatTime(dom.audio.duration); | |
| } | |
| }); | |
| dom.audio.addEventListener("ended", () => { | |
| state.isPlaying = false; | |
| dom.playIcon.classList.remove("hidden"); | |
| dom.pauseIcon.classList.add("hidden"); | |
| dom.playerProgress.style.width = "0%"; | |
| }); | |
| dom.playerTrack.addEventListener("click", (e) => { | |
| const rect = dom.playerTrack.getBoundingClientRect(); | |
| const pos = (e.clientX - rect.left) / rect.width; | |
| if (dom.audio.duration) { | |
| dom.audio.currentTime = pos * dom.audio.duration; | |
| } | |
| }); | |
| function formatTime(s) { | |
| const m = Math.floor(s / 60); | |
| const sec = Math.floor(s % 60); | |
| return m + ":" + (sec < 10 ? "0" : "") + sec; | |
| } | |
| // ================================================================ | |
| // Data Loading & Initialization | |
| // ================================================================ | |
| async function init() { | |
| showToast("Connecting to cloud dataset...", false); | |
| try { | |
| // 1. Fetch Transcripts | |
| const resTranscripts = await fetch(CONFIG.RAW_TRANSCRIPTS_URL); | |
| if (!resTranscripts.ok) throw new Error("Failed to load transcripts"); | |
| const transcriptText = await resTranscripts.text(); | |
| const lines = transcriptText.split("\n"); | |
| state.samples = []; | |
| for (let i = 0; i < lines.length; i++) { | |
| const line = lines[i].trim(); | |
| if (!line) continue; | |
| try { | |
| const item = JSON.parse(line); | |
| state.samples.push({ | |
| index: i, | |
| sample_id: `UG_${String(i).padStart(5, "0")}`, | |
| filename: item.filename, | |
| transcript: item.transcribe || "", | |
| }); | |
| } catch (e) {} | |
| } | |
| // 2. Fetch Existing Annotations from Cloud | |
| try { | |
| const resAnnotations = await fetch(CONFIG.RAW_ANNOTATIONS_URL); | |
| if (resAnnotations.ok) { | |
| const annText = await resAnnotations.text(); | |
| const annLines = annText.split("\n"); | |
| for (const line of annLines) { | |
| const l = line.trim(); | |
| if (!l) continue; | |
| try { | |
| const ann = JSON.parse(l); | |
| if (ann.sample_id) { | |
| state.annotations[ann.sample_id] = ann; | |
| } | |
| } catch (e) {} | |
| } | |
| } | |
| } catch (e) { | |
| console.warn("Could not fetch remote annotations, starting fresh", e); | |
| } | |
| // 3. Check Local Storage backup | |
| const localCached = localStorage.getItem("urdu_gold_cached_annotations"); | |
| if (localCached) { | |
| try { | |
| const parsed = JSON.parse(localCached); | |
| for (const k in parsed) { | |
| if (!state.annotations[k]) { | |
| state.annotations[k] = parsed[k]; | |
| } | |
| } | |
| } catch (e) {} | |
| } | |
| updateProgress(); | |
| // 4. Find first unannotated | |
| let startIdx = 0; | |
| for (let i = 0; i < state.samples.length; i++) { | |
| if (!state.annotations[state.samples[i].sample_id]) { | |
| startIdx = i; | |
| break; | |
| } | |
| } | |
| loadSample(startIdx); | |
| showToast("Dataset loaded! Ready for annotation.", false); | |
| } catch (err) { | |
| console.error("Init failed:", err); | |
| dom.transcriptText.textContent = "Error loading dataset. Please refresh or check connection."; | |
| showToast("Error loading dataset from cloud.", true); | |
| } | |
| } | |
| // ================================================================ | |
| // Sample Loading & Rendering | |
| // ================================================================ | |
| function loadSample(idx) { | |
| if (idx < 0 || idx >= state.samples.length) return; | |
| state.currentIndex = idx; | |
| const sample = state.samples[idx]; | |
| // Info header | |
| dom.sampleId.textContent = sample.sample_id; | |
| dom.sampleCounter.textContent = `${idx + 1} / ${state.samples.length}`; | |
| dom.transcriptText.textContent = sample.transcript || "(No transcript)"; | |
| // Audio stream | |
| const audioUrl = `${CONFIG.AUDIO_BASE_URL}${encodeURIComponent(sample.filename)}`; | |
| dom.audio.src = audioUrl; | |
| dom.playerProgress.style.width = "0%"; | |
| dom.playerTime.textContent = "0:00 / 0:00"; | |
| // Annotation Status & Form Pre-fill | |
| const ann = state.annotations[sample.sample_id]; | |
| if (ann) { | |
| dom.annotationBadge.classList.remove("hidden"); | |
| fillForm(ann); | |
| } else { | |
| dom.annotationBadge.classList.add("hidden"); | |
| resetForm(); | |
| } | |
| updateProgress(); | |
| } | |
| function fillForm(data) { | |
| resetForm(); | |
| setRadio("primary_emotion", data.primary_emotion); | |
| setRadio("secondary_emotion", data.secondary_emotion); | |
| setRadio("emotion_intensity", data.emotion_intensity); | |
| setRadio("vocal_expressiveness", data.vocal_expressiveness); | |
| setRadio("acting_authenticity", data.acting_authenticity); | |
| setRadio("rhythm_prosody", data.rhythm_prosody); | |
| setRadio("urdu_pronunciation", data.urdu_pronunciation); | |
| setRadio("speech_clarity", data.speech_clarity); | |
| setRadio("confidence", data.confidence); | |
| dom.notes.value = data.notes || ""; | |
| if (data.annotator_id) { | |
| dom.annotatorId.value = data.annotator_id; | |
| } | |
| } | |
| function resetForm() { | |
| dom.form.reset(); | |
| dom.notes.value = ""; | |
| // Keep secondary_emotion = "none" by default | |
| setRadio("secondary_emotion", "none"); | |
| setRadio("confidence", "high"); | |
| } | |
| function setRadio(name, val) { | |
| if (!val) return; | |
| const el = dom.form.querySelector(`input[name="${name}"][value="${val}"]`); | |
| if (el) el.checked = true; | |
| } | |
| function getRadio(name) { | |
| const el = dom.form.querySelector(`input[name="${name}"]:checked`); | |
| return el ? el.value : null; | |
| } | |
| function updateProgress() { | |
| const total = state.samples.length; | |
| const done = Object.keys(state.annotations).length; | |
| const pct = total > 0 ? ((done / total) * 100).toFixed(1) : 0; | |
| dom.progressFill.style.width = pct + "%"; | |
| dom.progressText.textContent = `${done} / ${total} (${pct}%)`; | |
| } | |
| // ================================================================ | |
| // Cloud Persistence via Hugging Face Hub Commit API | |
| // ================================================================ | |
| async function saveAnnotationToCloud(ann) { | |
| state.annotations[ann.sample_id] = ann; | |
| localStorage.setItem("urdu_gold_cached_annotations", JSON.stringify(state.annotations)); | |
| // Upload full updated annotations.jsonl to Hugging Face | |
| const jsonlContent = Object.values(state.annotations) | |
| .map(x => JSON.stringify(x)) | |
| .join("\n") + "\n"; | |
| try { | |
| const uploadUrl = `https://huggingface.co/api/datasets/${CONFIG.ANNOTATIONS_REPO}/raw/main/annotations.jsonl`; | |
| const res = await fetch(uploadUrl, { | |
| method: "PUT", | |
| headers: { | |
| "Authorization": `Bearer ${getHfToken()}`, | |
| "Content-Type": "application/x-ndjson", | |
| }, | |
| body: jsonlContent, | |
| }); | |
| if (!res.ok) { | |
| console.warn("Direct commit response status:", res.status); | |
| } | |
| } catch (e) { | |
| console.warn("Cloud save network error, saved locally:", e); | |
| } | |
| } | |
| // ================================================================ | |
| // Form Submission & Navigation | |
| // ================================================================ | |
| dom.form.addEventListener("submit", async (e) => { | |
| e.preventDefault(); | |
| const sample = state.samples[state.currentIndex]; | |
| if (!sample) return; | |
| const primaryEmotion = getRadio("primary_emotion"); | |
| if (!primaryEmotion) { | |
| showToast("Please select a Primary Emotion", true); | |
| return; | |
| } | |
| const secondaryEmotion = getRadio("secondary_emotion") || "none"; | |
| const emotionIntensity = getRadio("emotion_intensity"); | |
| const vocalExpressiveness = getRadio("vocal_expressiveness"); | |
| const actingAuthenticity = getRadio("acting_authenticity"); | |
| const rhythmProsody = getRadio("rhythm_prosody"); | |
| const urduPronunciation = getRadio("urdu_pronunciation"); | |
| const speechClarity = getRadio("speech_clarity"); | |
| const confidence = getRadio("confidence") || "high"; | |
| if (!emotionIntensity || !vocalExpressiveness || !actingAuthenticity || | |
| !rhythmProsody || !urduPronunciation || !speechClarity) { | |
| showToast("Please fill in all rating scales (1–5)", true); | |
| return; | |
| } | |
| const annotation = { | |
| sample_id: sample.sample_id, | |
| filename: sample.filename, | |
| transcript: sample.transcript, | |
| primary_emotion: primaryEmotion, | |
| secondary_emotion: secondaryEmotion, | |
| emotion_intensity: parseInt(emotionIntensity), | |
| vocal_expressiveness: parseInt(vocalExpressiveness), | |
| acting_authenticity: parseInt(actingAuthenticity), | |
| rhythm_prosody: parseInt(rhythmProsody), | |
| urdu_pronunciation: parseInt(urduPronunciation), | |
| speech_clarity: parseInt(speechClarity), | |
| confidence: confidence, | |
| notes: dom.notes.value.trim(), | |
| annotator_id: dom.annotatorId.value.trim() || "Annotator-1", | |
| timestamp: new Date().toISOString(), | |
| }; | |
| saveAnnotationToCloud(annotation); | |
| showToast(`Saved ${sample.sample_id} to cloud!`, false); | |
| // Advance to next unannotated | |
| goToNextUnannotated(); | |
| }); | |
| function goToNextUnannotated() { | |
| for (let i = state.currentIndex + 1; i < state.samples.length; i++) { | |
| if (!state.annotations[state.samples[i].sample_id]) { | |
| loadSample(i); | |
| return; | |
| } | |
| } | |
| for (let i = 0; i < state.currentIndex; i++) { | |
| if (!state.annotations[state.samples[i].sample_id]) { | |
| loadSample(i); | |
| return; | |
| } | |
| } | |
| // If all annotated | |
| if (state.currentIndex + 1 < state.samples.length) { | |
| loadSample(state.currentIndex + 1); | |
| } else { | |
| showToast("🎉 All samples in dataset are annotated!", false); | |
| } | |
| } | |
| dom.skipBtn.addEventListener("click", goToNextUnannotated); | |
| dom.btnPrev.addEventListener("click", () => { | |
| if (state.currentIndex > 0) loadSample(state.currentIndex - 1); | |
| }); | |
| dom.btnNext.addEventListener("click", () => { | |
| if (state.currentIndex < state.samples.length - 1) loadSample(state.currentIndex + 1); | |
| }); | |
| dom.btnJump.addEventListener("click", () => { | |
| const val = parseInt(dom.jumpInput.value, 10); | |
| if (!isNaN(val) && val >= 0 && val < state.samples.length) { | |
| loadSample(val); | |
| } | |
| }); | |
| // ================================================================ | |
| // Exports (CSV / JSONL / JSON) | |
| // ================================================================ | |
| dom.exportCsvBtn.addEventListener("click", () => { | |
| const anns = Object.values(state.annotations); | |
| if (anns.length === 0) { | |
| showToast("No annotations to export yet", true); | |
| return; | |
| } | |
| const headers = [ | |
| "sample_id", "filename", "transcript", "primary_emotion", "secondary_emotion", | |
| "emotion_intensity", "vocal_expressiveness", "acting_authenticity", | |
| "rhythm_prosody", "urdu_pronunciation", "speech_clarity", | |
| "confidence", "notes", "annotator_id", "timestamp" | |
| ]; | |
| let csvContent = "\uFEFF" + headers.join(",") + "\n"; | |
| for (const ann of anns) { | |
| const row = headers.map(h => { | |
| const val = String(ann[h] || "").replace(/"/g, '""'); | |
| return `"${val}"`; | |
| }); | |
| csvContent += row.join(",") + "\n"; | |
| } | |
| downloadBlob(csvContent, "urdu_gold_annotations.csv", "text/csv;charset=utf-8;"); | |
| }); | |
| dom.exportJsonlBtn.addEventListener("click", () => { | |
| const anns = Object.values(state.annotations); | |
| if (anns.length === 0) { | |
| showToast("No annotations to export yet", true); | |
| return; | |
| } | |
| const jsonl = anns.map(a => JSON.stringify(a)).join("\n") + "\n"; | |
| downloadBlob(jsonl, "urdu_gold_annotations.jsonl", "application/x-ndjson"); | |
| }); | |
| dom.exportJsonBtn.addEventListener("click", () => { | |
| const jsonStr = JSON.stringify(Object.values(state.annotations), null, 2); | |
| downloadBlob(jsonStr, "urdu_gold_annotations.json", "application/json"); | |
| }); | |
| function downloadBlob(content, filename, type) { | |
| const blob = new Blob([content], { type: type }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement("a"); | |
| a.href = url; | |
| a.download = filename; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| URL.revokeObjectURL(url); | |
| } | |
| // ================================================================ | |
| // Keyboard Shortcuts | |
| // ================================================================ | |
| document.addEventListener("keydown", (e) => { | |
| if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") { | |
| if (e.key === "Enter" && !e.shiftKey && e.target.tagName !== "TEXTAREA") { | |
| e.preventDefault(); | |
| dom.submitBtn.click(); | |
| } | |
| return; | |
| } | |
| if (e.code === "Space") { | |
| e.preventDefault(); | |
| togglePlay(); | |
| } else if (e.key === "ArrowLeft") { | |
| e.preventDefault(); | |
| dom.btnPrev.click(); | |
| } else if (e.key === "ArrowRight") { | |
| e.preventDefault(); | |
| dom.btnNext.click(); | |
| } else if (e.key === "u" || e.key === "U") { | |
| e.preventDefault(); | |
| goToNextUnannotated(); | |
| } else if (e.key === "Enter") { | |
| e.preventDefault(); | |
| dom.submitBtn.click(); | |
| } | |
| }); | |
| // ================================================================ | |
| // Toast | |
| // ================================================================ | |
| let toastTimer = null; | |
| function showToast(msg, isError) { | |
| dom.toastMessage.textContent = msg; | |
| dom.toast.className = "toast show" + (isError ? " error" : ""); | |
| clearTimeout(toastTimer); | |
| toastTimer = setTimeout(() => { | |
| dom.toast.className = "toast hidden"; | |
| }, 3000); | |
| } | |
| // Initialize on DOM load | |
| document.addEventListener("DOMContentLoaded", init); | |