Idrees0's picture
Update static annotator client with improved audio metadata loading
a6e2653 verified
Raw History Blame Contribute Delete
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);