Spaces:
Running
Running
add application files and dependencies based on project audio-annotator
Browse files- Dockerfile +19 -0
- main.py +252 -0
- requirements.txt +7 -0
- static/css/style.css +57 -0
- static/js/script.js +298 -0
- static/logo_white_v2.jpg +0 -0
- templates/index.html +115 -0
Dockerfile
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Read the doc: https://huggingface.co/docs/hub/spaces-sdks-docker
|
| 2 |
+
# you will also find guides on how best to write your Dockerfile
|
| 3 |
+
|
| 4 |
+
FROM python:3.9
|
| 5 |
+
|
| 6 |
+
RUN useradd -m -u 1000 user
|
| 7 |
+
USER user
|
| 8 |
+
ENV PATH="/home/user/.local/bin:$PATH"
|
| 9 |
+
|
| 10 |
+
WORKDIR /app
|
| 11 |
+
|
| 12 |
+
COPY --chown=user ./requirements.txt requirements.txt
|
| 13 |
+
RUN pip install --no-cache-dir --upgrade -r requirements.txt
|
| 14 |
+
|
| 15 |
+
COPY --chown=user . /app
|
| 16 |
+
|
| 17 |
+
# create the uploads directory inside the container
|
| 18 |
+
RUN mkdir -p /app/uploaded_audio
|
| 19 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
|
main.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List, Dict, Any
|
| 2 |
+
import os
|
| 3 |
+
import shutil
|
| 4 |
+
import uuid
|
| 5 |
+
from fastapi import FastAPI, Request, UploadFile, File, HTTPException
|
| 6 |
+
from fastapi.responses import HTMLResponse, JSONResponse
|
| 7 |
+
from fastapi.templating import Jinja2Templates
|
| 8 |
+
from fastapi.staticfiles import StaticFiles
|
| 9 |
+
from starlette.middleware.sessions import SessionMiddleware
|
| 10 |
+
from starlette.responses import FileResponse
|
| 11 |
+
from starlette.background import BackgroundTask
|
| 12 |
+
from pydantic import BaseModel
|
| 13 |
+
from datasets import Dataset, Audio
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
# --- Pydantic Models for Data Validation/Serialization ---
|
| 17 |
+
class SaveAnnotationRequest(BaseModel):
|
| 18 |
+
"""Model for the POST request payload to save transcription."""
|
| 19 |
+
index: int
|
| 20 |
+
transcription: str
|
| 21 |
+
speaker: str
|
| 22 |
+
|
| 23 |
+
class AudioDataResponse(BaseModel):
|
| 24 |
+
"""Model for the GET response when loading an audio row."""
|
| 25 |
+
index: int
|
| 26 |
+
filename: str
|
| 27 |
+
transcription: str
|
| 28 |
+
speaker: str
|
| 29 |
+
max_index: int
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
# --- Configuration and Global State ---
|
| 33 |
+
|
| 34 |
+
# Directory to save user-uploaded audio files
|
| 35 |
+
UPLOAD_DIR = "./uploaded_audio"
|
| 36 |
+
# Data structure to hold the annotation state: {f'{user_id}': [{"filename": str, "speaker": str, "transcription": str}]}
|
| 37 |
+
ANNOTATION_DATA: Dict[str, List[Dict[str, Any]]] = {}
|
| 38 |
+
# Index of the audio file currently being displayed/annotated
|
| 39 |
+
current_index: Dict[str, int] = {}
|
| 40 |
+
|
| 41 |
+
# Ensure the upload directory exists
|
| 42 |
+
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# --- FastAPI Setup ---
|
| 46 |
+
app = FastAPI(title="Audio Annotation Tool with File Upload")
|
| 47 |
+
app.mount("/static", StaticFiles(directory="./static", html=True), name="static")
|
| 48 |
+
app.add_middleware(SessionMiddleware, secret_key="audio-annotator-application")
|
| 49 |
+
templates = Jinja2Templates(directory="./templates")
|
| 50 |
+
|
| 51 |
+
# --- Utility Functions ---
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def load_data_for_index(user_id: int, index: int) -> Dict[str, Any]:
|
| 55 |
+
"""Helper to safely fetch data for a given index."""
|
| 56 |
+
if not ANNOTATION_DATA[user_id]:
|
| 57 |
+
return {"index": -1, "filename": "", "transcription": "No files uploaded yet.", "speaker": "No files uploaded yet.", "max_index": 0}
|
| 58 |
+
if 0 <= index < len(ANNOTATION_DATA[user_id]):
|
| 59 |
+
item = ANNOTATION_DATA[user_id][index]
|
| 60 |
+
return {
|
| 61 |
+
"index": index,
|
| 62 |
+
"filename": item['filename'],
|
| 63 |
+
"transcription": item['transcription'],
|
| 64 |
+
"speaker": item['speaker'],
|
| 65 |
+
"max_index": len(ANNOTATION_DATA[user_id])
|
| 66 |
+
}
|
| 67 |
+
else:
|
| 68 |
+
# Wrap around if needed, or handle boundary cases
|
| 69 |
+
raise IndexError("Index out of bounds.")
|
| 70 |
+
|
| 71 |
+
def get_user_directory(user_id):
|
| 72 |
+
return os.path.join(UPLOAD_DIR, f'{user_id}')
|
| 73 |
+
|
| 74 |
+
def serve_index_html(request: Request):
|
| 75 |
+
try:
|
| 76 |
+
user_id = request.session.get('_id', None)
|
| 77 |
+
if user_id is None:
|
| 78 |
+
user_id = str(uuid.uuid4())
|
| 79 |
+
request.session['_id'] = user_id
|
| 80 |
+
# Ensure the upload directory exists
|
| 81 |
+
# print(user_id)
|
| 82 |
+
user_dir = get_user_directory(user_id)
|
| 83 |
+
os.makedirs(user_dir, exist_ok=True)
|
| 84 |
+
ANNOTATION_DATA[user_id] = []
|
| 85 |
+
current_index[user_id] = -1
|
| 86 |
+
|
| 87 |
+
return templates.TemplateResponse("index.html", context={"request": request})
|
| 88 |
+
except FileNotFoundError:
|
| 89 |
+
return HTMLResponse(content="<h1>Server Error: index.html not found.</h1>", status_code=500)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
# --- Routes ---
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
@app.get("/")
|
| 96 |
+
async def index(request: Request):
|
| 97 |
+
"""Serves the main application page by reading index.html."""
|
| 98 |
+
return serve_index_html(request)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
@app.get("/annotate")
|
| 102 |
+
async def annotate(request: Request):
|
| 103 |
+
"""Serves the main application page by reading index.html."""
|
| 104 |
+
return serve_index_html(request)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
@app.post("/upload_audio")
|
| 108 |
+
async def upload_audio(request: Request, audio_files: List[UploadFile] = File(...)):
|
| 109 |
+
"""Handles multiple audio file uploads from the client."""
|
| 110 |
+
global ANNOTATION_DATA, current_index
|
| 111 |
+
new_files_count = 0
|
| 112 |
+
user_id = request.session.get('_id', None)
|
| 113 |
+
|
| 114 |
+
# Reset index if this is the first upload
|
| 115 |
+
if not ANNOTATION_DATA[user_id]:
|
| 116 |
+
current_index[user_id] = 0
|
| 117 |
+
|
| 118 |
+
for file in audio_files:
|
| 119 |
+
# Construct the full path
|
| 120 |
+
user_dir = get_user_directory(user_id)
|
| 121 |
+
file_path = os.path.join(user_dir, file.filename)
|
| 122 |
+
|
| 123 |
+
# Save the file to disk
|
| 124 |
+
try:
|
| 125 |
+
with open(file_path, "wb") as buffer:
|
| 126 |
+
# Read the file chunk by chunk to handle large files
|
| 127 |
+
shutil.copyfileobj(file.file, buffer)
|
| 128 |
+
|
| 129 |
+
# Update the annotation data structure
|
| 130 |
+
ANNOTATION_DATA[user_id].append({
|
| 131 |
+
"filename": file.filename,
|
| 132 |
+
"transcription": "", # Initialize transcription as empty,
|
| 133 |
+
"speaker": "" # Initialize speaker as empty
|
| 134 |
+
})
|
| 135 |
+
new_files_count += 1
|
| 136 |
+
except Exception as e:
|
| 137 |
+
print(f"Error saving file {file.filename}: {e}")
|
| 138 |
+
raise HTTPException(status_code=500, detail=f"Failed to save file: {file.filename}")
|
| 139 |
+
|
| 140 |
+
return JSONResponse({
|
| 141 |
+
"message": f"Successfully uploaded {new_files_count} files.",
|
| 142 |
+
"total_files": len(ANNOTATION_DATA[user_id])
|
| 143 |
+
})
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
@app.post("/save_annotation")
|
| 147 |
+
async def save_annotation(request: Request, data: SaveAnnotationRequest):
|
| 148 |
+
"""Saves the transcription for the current index."""
|
| 149 |
+
try:
|
| 150 |
+
user_id = request.session.get('_id', None)
|
| 151 |
+
index_to_save = data.index
|
| 152 |
+
|
| 153 |
+
if 0 <= index_to_save < len(ANNOTATION_DATA[user_id]):
|
| 154 |
+
# Update the transcription text
|
| 155 |
+
ANNOTATION_DATA[user_id][index_to_save]["transcription"] = data.transcription
|
| 156 |
+
ANNOTATION_DATA[user_id][index_to_save]["speaker"] = data.speaker
|
| 157 |
+
return JSONResponse({"success": True, "message": f"Row {index_to_save + 1} saved."})
|
| 158 |
+
else:
|
| 159 |
+
raise HTTPException(status_code=400, detail="Invalid index for saving.")
|
| 160 |
+
|
| 161 |
+
except Exception as e:
|
| 162 |
+
print(f"Error during save: {e}")
|
| 163 |
+
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
@app.get("/load_audio_data/{direction}")
|
| 167 |
+
async def load_audio_data(request: Request, direction: str):
|
| 168 |
+
"""Loads the audio data and increments/decrements the current_index."""
|
| 169 |
+
global current_index
|
| 170 |
+
user_id = request.session.get('_id', None)
|
| 171 |
+
|
| 172 |
+
if not ANNOTATION_DATA[user_id]:
|
| 173 |
+
return JSONResponse(load_data_for_index(user_id, -1))
|
| 174 |
+
|
| 175 |
+
new_index = current_index[user_id]
|
| 176 |
+
max_len = len(ANNOTATION_DATA[user_id])
|
| 177 |
+
|
| 178 |
+
if direction == 'next':
|
| 179 |
+
new_index = (current_index[user_id] + 1) % max_len
|
| 180 |
+
elif direction == 'prev':
|
| 181 |
+
# Handles wrapping from 0 back to the last index
|
| 182 |
+
new_index = (current_index[user_id] - 1 + max_len) % max_len
|
| 183 |
+
else:
|
| 184 |
+
# 'current' direction is used for initial load or after upload
|
| 185 |
+
pass
|
| 186 |
+
|
| 187 |
+
try:
|
| 188 |
+
data = load_data_for_index(user_id, new_index)
|
| 189 |
+
# Only update the global index if navigation was successful
|
| 190 |
+
current_index[user_id] = new_index
|
| 191 |
+
return JSONResponse(data)
|
| 192 |
+
except IndexError:
|
| 193 |
+
raise HTTPException(status_code=404, detail="No more audio files to load.")
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
@app.get("/audio_file/{filename}")
|
| 197 |
+
async def serve_audio_file(request: Request, filename: str):
|
| 198 |
+
"""Streams the requested audio file from the upload directory."""
|
| 199 |
+
user_id = request.session.get('_id', None)
|
| 200 |
+
user_dir = get_user_directory(user_id)
|
| 201 |
+
file_path = os.path.join(user_dir, filename)
|
| 202 |
+
|
| 203 |
+
if os.path.exists(file_path):
|
| 204 |
+
# FileResponse sends the file directly, optimized for binary streams
|
| 205 |
+
return FileResponse(file_path, media_type="audio/wav") # Assume WAV for simplicity, use relevant type if required
|
| 206 |
+
|
| 207 |
+
raise HTTPException(status_code=404, detail="Audio file not found.")
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
@app.get("/download_annotations")
|
| 211 |
+
async def download_annotations(request: Request):
|
| 212 |
+
"""Returns the entire annotated dataset as a downloadable JSON file."""
|
| 213 |
+
global current_index, ANNOTATION_DATA
|
| 214 |
+
user_id = request.session.get('_id', None)
|
| 215 |
+
|
| 216 |
+
if not ANNOTATION_DATA[user_id]:
|
| 217 |
+
raise HTTPException(status_code=404, detail="No annotations available to download.")
|
| 218 |
+
|
| 219 |
+
user_dir = get_user_directory(user_id)
|
| 220 |
+
# Convert the dataset to Dataset
|
| 221 |
+
data = {"audio": [], "transcription": [], "speaker": []}
|
| 222 |
+
for item in ANNOTATION_DATA[user_id]:
|
| 223 |
+
data['audio'].append(os.path.join(user_dir, item['filename']))
|
| 224 |
+
data['transcription'].append(item['transcription'])
|
| 225 |
+
data['speaker'].append(item['speaker'])
|
| 226 |
+
# print(data)
|
| 227 |
+
ds = Dataset.from_dict(data).cast_column('audio', Audio(sampling_rate=16000))
|
| 228 |
+
dataset_dir = os.path.join(user_dir, 'dataset')
|
| 229 |
+
ds.save_to_disk(dataset_dir)
|
| 230 |
+
|
| 231 |
+
# Write the content to a temporary file
|
| 232 |
+
zip_dir = os.path.join(user_dir, 'final')
|
| 233 |
+
shutil.make_archive(zip_dir, 'zip', dataset_dir)
|
| 234 |
+
# Create a temporary file path
|
| 235 |
+
temp_file = f'{zip_dir}.zip'
|
| 236 |
+
|
| 237 |
+
def cleanup_file():
|
| 238 |
+
try:
|
| 239 |
+
shutil.rmtree(user_dir)
|
| 240 |
+
os.makedirs(user_dir)
|
| 241 |
+
except Exception as e:
|
| 242 |
+
print(f"Error deleting directory {user_dir}: {e}")
|
| 243 |
+
ANNOTATION_DATA[user_id] = []
|
| 244 |
+
current_index[user_id] = -1
|
| 245 |
+
|
| 246 |
+
# Return the file, which will be deleted after being sent
|
| 247 |
+
return FileResponse(
|
| 248 |
+
path=temp_file,
|
| 249 |
+
filename='annotated_data.zip',
|
| 250 |
+
media_type="application/zip",
|
| 251 |
+
background=BackgroundTask(cleanup_file)
|
| 252 |
+
)
|
requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn[standard]
|
| 3 |
+
python-multipart
|
| 4 |
+
itsdangerous
|
| 5 |
+
jinja2
|
| 6 |
+
datasets[audio]
|
| 7 |
+
numpy
|
static/css/style.css
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Custom styles for the table for better mobile viewing */
|
| 2 |
+
@media (max-width: 768px) {
|
| 3 |
+
.responsive-table tr {
|
| 4 |
+
display: block;
|
| 5 |
+
margin-bottom: 0.75rem;
|
| 6 |
+
border-bottom: 2px solid #e5e7eb;
|
| 7 |
+
}
|
| 8 |
+
.responsive-table th, .responsive-table td {
|
| 9 |
+
display: block;
|
| 10 |
+
text-align: right;
|
| 11 |
+
padding: 0.5rem 1rem;
|
| 12 |
+
}
|
| 13 |
+
.responsive-table td::before {
|
| 14 |
+
content: attr(data-label);
|
| 15 |
+
float: left;
|
| 16 |
+
font-weight: 600;
|
| 17 |
+
color: #4b5563;
|
| 18 |
+
}
|
| 19 |
+
.responsive-table thead {
|
| 20 |
+
display: none;
|
| 21 |
+
}
|
| 22 |
+
}
|
| 23 |
+
/* Ensure the body uses the Inter font, centers content, and stacks vertically */
|
| 24 |
+
body {
|
| 25 |
+
font-family: 'Inter', sans-serif;
|
| 26 |
+
background-color: #f3f4f6;
|
| 27 |
+
display: flex;
|
| 28 |
+
flex-direction: column; /* FIX: Stack children vertically */
|
| 29 |
+
justify-content: flex-start;
|
| 30 |
+
align-items: center; /* FIX: Center content horizontally */
|
| 31 |
+
min-height: 100vh;
|
| 32 |
+
padding-top: 2rem;
|
| 33 |
+
padding-bottom: 2rem;
|
| 34 |
+
}
|
| 35 |
+
[contenteditable="true"] {
|
| 36 |
+
border: 1px solid #c7d2fe;
|
| 37 |
+
background-color: #f0f4ff;
|
| 38 |
+
padding: 0.75rem;
|
| 39 |
+
cursor: text;
|
| 40 |
+
min-height: 5rem;
|
| 41 |
+
border-radius: 0.5rem;
|
| 42 |
+
transition: border-color 0.15s ease-in-out;
|
| 43 |
+
resize: vertical; /* Allow vertical resizing for long transcriptions */
|
| 44 |
+
overflow: auto;
|
| 45 |
+
}
|
| 46 |
+
[contenteditable="true"]:focus {
|
| 47 |
+
outline: none;
|
| 48 |
+
border-color: #4f46e5;
|
| 49 |
+
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.5);
|
| 50 |
+
}
|
| 51 |
+
/* New CSS for Contenteditable Placeholder */
|
| 52 |
+
[contenteditable][data-placeholder]:empty::before {
|
| 53 |
+
content: attr(data-placeholder);
|
| 54 |
+
color: #9ca3af; /* A light grey color */
|
| 55 |
+
pointer-events: none; /* Allows the user to click the div to type */
|
| 56 |
+
display: block;
|
| 57 |
+
}
|
static/js/script.js
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
// Global variables for tracking state
|
| 3 |
+
let currentDataIndex = -1;
|
| 4 |
+
let maxDataIndex = 0;
|
| 5 |
+
let isUploading = false;
|
| 6 |
+
|
| 7 |
+
const BASE_URL = window.location.origin;
|
| 8 |
+
|
| 9 |
+
// Elements
|
| 10 |
+
const uploadSection = document.getElementById('upload-section');
|
| 11 |
+
const annotationSection = document.getElementById('annotation-section');
|
| 12 |
+
const uploadButton = document.getElementById('upload-button');
|
| 13 |
+
const nextButton = document.getElementById('next-button');
|
| 14 |
+
const prevButton = document.getElementById('prev-button');
|
| 15 |
+
const downloadButton = document.getElementById('download-button');
|
| 16 |
+
const counter = document.getElementById('counter');
|
| 17 |
+
const filenameDisplay = document.getElementById('filename-display');
|
| 18 |
+
const audioPlayer = document.getElementById('audio-player');
|
| 19 |
+
const transcriptionText = document.getElementById('transcription-text');
|
| 20 |
+
const speakerText = document.getElementById('speaker-text');
|
| 21 |
+
const statusMessage = document.getElementById('status-message');
|
| 22 |
+
const audioFilesInput = document.getElementById('audio-files');
|
| 23 |
+
|
| 24 |
+
// --- Routing and Navigation Functions ---
|
| 25 |
+
|
| 26 |
+
/** Changes the URL path and pushes state to history without reloading. */
|
| 27 |
+
function navigateTo(path) {
|
| 28 |
+
window.history.pushState(null, '', path);
|
| 29 |
+
router();
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
/** Controls the view shown based on the current URL path and server state. */
|
| 33 |
+
async function router() {
|
| 34 |
+
// Normalize path to handle both '/' and '/index' as root
|
| 35 |
+
const path = window.location.pathname.replace(/\/+$/, ''); // Remove trailing slash
|
| 36 |
+
|
| 37 |
+
// 1. Reset visibility for all sections
|
| 38 |
+
uploadSection.classList.add('hidden');
|
| 39 |
+
annotationSection.classList.add('hidden');
|
| 40 |
+
|
| 41 |
+
const isAnnotationPath = path === '/annotate';
|
| 42 |
+
console.log(isAnnotationPath);
|
| 43 |
+
|
| 44 |
+
if (isAnnotationPath) {
|
| 45 |
+
// Route: /annotate
|
| 46 |
+
annotationSection.classList.remove('hidden');
|
| 47 |
+
|
| 48 |
+
// Attempt to load data from the server. This determines if a session exists.
|
| 49 |
+
const initialData = await loadAudio('current');
|
| 50 |
+
|
| 51 |
+
// If the backend returns index: -1, it means no files are loaded.
|
| 52 |
+
if (initialData && initialData.index === -1) {
|
| 53 |
+
navigateTo('/');
|
| 54 |
+
}
|
| 55 |
+
} else {
|
| 56 |
+
// Route: / or /index
|
| 57 |
+
// Always show upload form initially, but check if we should redirect to annotation
|
| 58 |
+
|
| 59 |
+
// Check server state to see if any files are currently loaded
|
| 60 |
+
const checkData = await loadAudio('current', { suppressNavigation: true });
|
| 61 |
+
|
| 62 |
+
if (checkData && checkData.index !== -1) {
|
| 63 |
+
// Files exist on the server, redirect to annotation screen
|
| 64 |
+
navigateTo('/annotate');
|
| 65 |
+
} else {
|
| 66 |
+
// No files loaded, stay on upload page
|
| 67 |
+
uploadSection.classList.remove('hidden');
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
// --- Core Application Functions ---
|
| 73 |
+
|
| 74 |
+
/** Sets the status message with a specified color/style. */
|
| 75 |
+
function setStatus(message, type = 'info') {
|
| 76 |
+
let color = 'text-gray-600';
|
| 77 |
+
if (type === 'success') color = 'text-green-600';
|
| 78 |
+
else if (type === 'error') color = 'text-red-600';
|
| 79 |
+
else if (type === 'warn') color = 'text-yellow-600';
|
| 80 |
+
else if (type === 'loading') color = 'text-indigo-600';
|
| 81 |
+
|
| 82 |
+
statusMessage.className = `text-sm font-semibold mt-2 h-5 ${color}`;
|
| 83 |
+
statusMessage.textContent = message;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
/** Disables/enables navigation buttons. */
|
| 87 |
+
function toggleNavigationButtons(disabled) {
|
| 88 |
+
nextButton.disabled = disabled;
|
| 89 |
+
prevButton.disabled = disabled;
|
| 90 |
+
downloadButton.disabled = disabled;
|
| 91 |
+
audioPlayer.disabled = disabled;
|
| 92 |
+
transcriptionText.contentEditable = disabled ? 'false' : 'true';
|
| 93 |
+
speakerText.contentEditable = disabled ? 'false' : 'true';
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
/** 1. Handles file upload to the server. */
|
| 97 |
+
async function uploadFiles() {
|
| 98 |
+
if (isUploading) return;
|
| 99 |
+
const files = audioFilesInput.files;
|
| 100 |
+
|
| 101 |
+
if (files.length === 0) {
|
| 102 |
+
setStatus("Please select at least one audio file.", 'warn');
|
| 103 |
+
return;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
isUploading = true;
|
| 107 |
+
uploadButton.disabled = true;
|
| 108 |
+
setStatus(`Uploading ${files.length} file(s)...`, 'loading');
|
| 109 |
+
|
| 110 |
+
try {
|
| 111 |
+
const formData = new FormData();
|
| 112 |
+
for (let i = 0; i < files.length; i++) {
|
| 113 |
+
formData.append("audio_files", files[i]);
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
const response = await fetch(`${BASE_URL}/upload_audio`, {
|
| 117 |
+
method: 'POST',
|
| 118 |
+
body: formData
|
| 119 |
+
});
|
| 120 |
+
|
| 121 |
+
if (!response.ok) {
|
| 122 |
+
throw new Error(`Upload failed with status: ${response.status}`);
|
| 123 |
+
}
|
| 124 |
+
const result = await response.json();
|
| 125 |
+
|
| 126 |
+
setStatus(result.message, 'success');
|
| 127 |
+
|
| 128 |
+
// Switch UI and load the first file
|
| 129 |
+
// uploadSection.classList.add('hidden');
|
| 130 |
+
// annotationSection.classList.remove('hidden');
|
| 131 |
+
// await loadAudio('current');
|
| 132 |
+
navigateTo('/annotate');
|
| 133 |
+
|
| 134 |
+
} catch (error) {
|
| 135 |
+
console.error("Upload error:", error);
|
| 136 |
+
setStatus("An error occurred during upload. Check console for details.", 'error');
|
| 137 |
+
} finally {
|
| 138 |
+
isUploading = false;
|
| 139 |
+
uploadButton.disabled = false;
|
| 140 |
+
}
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
/** 2. Saves the current transcription before navigating. */
|
| 144 |
+
async function saveCurrentAnnotation() {
|
| 145 |
+
if (currentDataIndex < 0 || maxDataIndex === 0) return;
|
| 146 |
+
|
| 147 |
+
const textToSave = transcriptionText.textContent.trim();
|
| 148 |
+
const nameToSave = speakerText.textContent.trim();
|
| 149 |
+
setStatus(`Saving File ${currentDataIndex + 1}...`, 'loading');
|
| 150 |
+
|
| 151 |
+
try {
|
| 152 |
+
const response = await fetch(`${BASE_URL}/save_annotation`, {
|
| 153 |
+
method: 'POST',
|
| 154 |
+
headers: { 'Content-Type': 'application/json' },
|
| 155 |
+
body: JSON.stringify({
|
| 156 |
+
index: currentDataIndex,
|
| 157 |
+
transcription: textToSave,
|
| 158 |
+
speaker: nameToSave
|
| 159 |
+
})
|
| 160 |
+
});
|
| 161 |
+
|
| 162 |
+
if (!response.ok) {
|
| 163 |
+
const errorDetails = await response.json();
|
| 164 |
+
throw new Error(errorDetails.detail || "Failed to save annotation.");
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
setStatus(`File ${currentDataIndex + 1} saved.`, 'success');
|
| 168 |
+
} catch (error) {
|
| 169 |
+
console.error("Save failed:", error);
|
| 170 |
+
setStatus(`Save failed for File ${currentDataIndex + 1}.`, 'error');
|
| 171 |
+
}
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
/** 3. Loads audio data based on direction ('next', 'prev', 'current'). */
|
| 176 |
+
async function loadAudio(direction, options = {}) {
|
| 177 |
+
const { suppressNavigation = false } = options;
|
| 178 |
+
|
| 179 |
+
if (!suppressNavigation) {
|
| 180 |
+
toggleNavigationButtons(true);
|
| 181 |
+
|
| 182 |
+
// 1. Save the current state before navigating
|
| 183 |
+
if (direction !== 'current') {
|
| 184 |
+
await saveCurrentAnnotation();
|
| 185 |
+
} else {
|
| 186 |
+
// Clear initial state message for fresh load
|
| 187 |
+
setStatus("Loading audio data...", 'loading');
|
| 188 |
+
}
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
try {
|
| 192 |
+
// The server determines the correct index based on 'direction' and its internal state
|
| 193 |
+
const response = await fetch(`${BASE_URL}/load_audio_data/${direction}`);
|
| 194 |
+
|
| 195 |
+
if (!response.ok) {
|
| 196 |
+
throw new Error(`HTTP error! status: ${response.status}`);
|
| 197 |
+
}
|
| 198 |
+
const data = await response.json();
|
| 199 |
+
|
| 200 |
+
if (data.index === -1) {
|
| 201 |
+
// Handle the initial state before files are uploaded
|
| 202 |
+
counter.textContent = "No files loaded.";
|
| 203 |
+
filenameDisplay.textContent = "";
|
| 204 |
+
transcriptionText.textContent = "Please upload audio files to begin annotation.";
|
| 205 |
+
transcriptionText.contentEditable = 'false';
|
| 206 |
+
speakerText.textContent = "Please upload audio files to begin annotation.";
|
| 207 |
+
speakerText.contentEditable = 'false';
|
| 208 |
+
toggleNavigationButtons(true);
|
| 209 |
+
return data; // Return data for router to check
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
// Update global state
|
| 213 |
+
currentDataIndex = data.index;
|
| 214 |
+
maxDataIndex = data.max_index;
|
| 215 |
+
|
| 216 |
+
// 2. Update text fields and counter
|
| 217 |
+
transcriptionText.textContent = data.transcription;
|
| 218 |
+
speakerText.textContent = data.speaker;
|
| 219 |
+
counter.textContent = `File ${currentDataIndex + 1} of ${maxDataIndex}`;
|
| 220 |
+
filenameDisplay.textContent = `File: ${data.filename}`;
|
| 221 |
+
|
| 222 |
+
// 3. Update the audio player source
|
| 223 |
+
const audioSourceUrl = `${BASE_URL}/audio_file/${data.filename}`;
|
| 224 |
+
audioPlayer.src = audioSourceUrl;
|
| 225 |
+
audioPlayer.load();
|
| 226 |
+
|
| 227 |
+
// Clear status unless a save operation just happened
|
| 228 |
+
if (!statusMessage.textContent.includes("saved")) {
|
| 229 |
+
statusMessage.textContent = "";
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
// Attempt to play the audio immediately
|
| 233 |
+
try {
|
| 234 |
+
await audioPlayer.play();
|
| 235 |
+
} catch (e) {
|
| 236 |
+
// Fail gracefully if autoplay is blocked
|
| 237 |
+
console.log("Autoplay prevented by browser.", e);
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
toggleNavigationButtons(false);
|
| 241 |
+
return data;
|
| 242 |
+
|
| 243 |
+
} catch (error) {
|
| 244 |
+
console.error("Navigation error:", error);
|
| 245 |
+
setStatus("Error loading audio. Please try reloading or uploading files.", 'error');
|
| 246 |
+
transcriptionText.textContent = "Error loading data.";
|
| 247 |
+
speakerText.textContent = "Error loading data.";
|
| 248 |
+
toggleNavigationButtons(true);
|
| 249 |
+
return { index: -1 }; // Return -1 on catastrophic error
|
| 250 |
+
}
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
/** 4. Triggers the download of the annotated dataset. */
|
| 254 |
+
async function downloadAnnotations() {
|
| 255 |
+
downloadButton.disabled = true;
|
| 256 |
+
setStatus("Preparing annotated data for download...", 'loading');
|
| 257 |
+
|
| 258 |
+
try {
|
| 259 |
+
const response = await fetch(`${BASE_URL}/download_annotations`);
|
| 260 |
+
if (!response.ok) {
|
| 261 |
+
throw new Error(`Download failed with status: ${response.status}`);
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
// Get the blob and trigger download
|
| 265 |
+
const blob = await response.blob();
|
| 266 |
+
const url = window.URL.createObjectURL(blob);
|
| 267 |
+
const a = document.createElement('a');
|
| 268 |
+
a.style.display = 'none';
|
| 269 |
+
a.href = url;
|
| 270 |
+
// const date = new Date().toISOString().slice(0, 10);
|
| 271 |
+
// a.download = `annotations_${date}.json`;
|
| 272 |
+
a.download = `annotated_data.zip`;
|
| 273 |
+
document.body.appendChild(a);
|
| 274 |
+
a.click();
|
| 275 |
+
window.URL.revokeObjectURL(url);
|
| 276 |
+
|
| 277 |
+
setStatus("Download complete! Session cleared", 'success');
|
| 278 |
+
|
| 279 |
+
// Navigate back to the upload page to start fresh
|
| 280 |
+
navigateTo('/');
|
| 281 |
+
|
| 282 |
+
} catch (error) {
|
| 283 |
+
console.error("Download error:", error);
|
| 284 |
+
setStatus("Error downloading annotations.", 'error');
|
| 285 |
+
} finally {
|
| 286 |
+
downloadButton.disabled = false;
|
| 287 |
+
}
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
// Load initial state and set up routing listeners
|
| 292 |
+
window.onload = () => {
|
| 293 |
+
// Handle back/forward buttonn clicks by rerunning the router
|
| 294 |
+
window.onpopstate = router;
|
| 295 |
+
|
| 296 |
+
// Initial route call
|
| 297 |
+
router();
|
| 298 |
+
};
|
static/logo_white_v2.jpg
ADDED
|
templates/index.html
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Audio Annotator</title>
|
| 7 |
+
<!-- Load Tailwind CSS --><script src="https://cdn.tailwindcss.com"></script>
|
| 8 |
+
<link rel="stylesheet" type="text/css" href="/static/css/style.css">
|
| 9 |
+
</head>
|
| 10 |
+
<body>
|
| 11 |
+
<div class="w-full max-w-4xl p-4 md:p-8 bg-white shadow-2xl rounded-xl relative"> <!-- Added 'relative' here -->
|
| 12 |
+
<!-- Logo Added Here -->
|
| 13 |
+
<img id="logo" src="/static/logo_white_v2.jpg" alt="App Logo" class="absolute top-4 left-4 h-24">
|
| 14 |
+
|
| 15 |
+
<h1 class="text-3xl font-extrabold text-gray-800 mt-6 mb-6 text-center"> <!-- Adjusted mt-6 for logo clearance -->Audio Transcription Tool
|
| 16 |
+
</h1>
|
| 17 |
+
|
| 18 |
+
<!-- File Upload Section -->
|
| 19 |
+
<div id="upload-section" class="mb-8 border-2 border-dashed border-gray-300 p-6 rounded-lg bg-gray-50">
|
| 20 |
+
<h2 class="text-xl font-semibold mb-3 text-center text-gray-700">Upload Your Audio Files (WAV/MP3)</h2>
|
| 21 |
+
<input type="file" id="audio-files" multiple accept="audio/*" class="w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-indigo-100 file:text-indigo-700 hover:file:bg-indigo-200"/>
|
| 22 |
+
<button
|
| 23 |
+
id="upload-button"
|
| 24 |
+
onclick="uploadFiles()"
|
| 25 |
+
class="mt-4 w-full py-2 bg-indigo-600 text-white font-bold rounded-lg shadow-md hover:bg-indigo-700 transition duration-150 disabled:bg-indigo-400"
|
| 26 |
+
>
|
| 27 |
+
🚀 Start Annotation
|
| 28 |
+
</button>
|
| 29 |
+
</div>
|
| 30 |
+
|
| 31 |
+
<!-- Annotation Section (Hidden until files are loaded) -->
|
| 32 |
+
<div id="annotation-section" class="hidden">
|
| 33 |
+
<div class="mb-4 text-center">
|
| 34 |
+
<span id="counter" class="text-lg font-medium text-indigo-600">No files loaded.</span>
|
| 35 |
+
<span id="filename-display" class="text-sm text-gray-500 block"></span>
|
| 36 |
+
<div id="status-message" class="text-sm font-semibold mt-2 h-5"></div>
|
| 37 |
+
</div>
|
| 38 |
+
|
| 39 |
+
<!-- Data Table --><div class="shadow-md rounded-lg mb-6">
|
| 40 |
+
<table class="min-w-full divide-y divide-gray-200 responsive-table">
|
| 41 |
+
<thead class="bg-indigo-50">
|
| 42 |
+
<tr>
|
| 43 |
+
<th scope="col" class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider w-1/2">
|
| 44 |
+
Speaker (Type Here)
|
| 45 |
+
</th>
|
| 46 |
+
<th scope="col" class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider w-1/2">
|
| 47 |
+
Transcription (Type Here)
|
| 48 |
+
</th>
|
| 49 |
+
<th scope="col" class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider w-1/2">
|
| 50 |
+
Audio Playback
|
| 51 |
+
</th>
|
| 52 |
+
</tr>
|
| 53 |
+
</thead>
|
| 54 |
+
<tbody class="bg-white divide-y divide-gray-200">
|
| 55 |
+
<tr id="data-row">
|
| 56 |
+
<!-- Speaker's name Text Area --><td data-label="Speaker" class="px-6 py-4 whitespace-normal text-sm font-medium text-gray-900">
|
| 57 |
+
<div id="speaker-text"
|
| 58 |
+
contenteditable="true"
|
| 59 |
+
class="w-full text-base"
|
| 60 |
+
data-placeholder="Enter speaker's name here...">
|
| 61 |
+
</div>
|
| 62 |
+
<!-- Transcription Text Area --><td data-label="Transcription" class="px-6 py-4 whitespace-normal text-sm font-medium text-gray-900">
|
| 63 |
+
<div id="transcription-text"
|
| 64 |
+
contenteditable="true"
|
| 65 |
+
class="w-full text-base"
|
| 66 |
+
data-placeholder="Enter your transcription here...">
|
| 67 |
+
</div>
|
| 68 |
+
</td>
|
| 69 |
+
<!-- Audio Player Widget --><td data-label="Audio" class="px-6 py-4 text-sm text-gray-500 flex items-center justify-center">
|
| 70 |
+
<audio id="audio-player" controls class="w-full max-w-xs md:max-w-none" disabled>
|
| 71 |
+
Your browser does not support the audio element.
|
| 72 |
+
</audio>
|
| 73 |
+
</td>
|
| 74 |
+
</tr>
|
| 75 |
+
</tbody>
|
| 76 |
+
</table>
|
| 77 |
+
</div>
|
| 78 |
+
|
| 79 |
+
<div class="flex justify-center space-x-4 flex-wrap"> <!-- Control Buttons -->
|
| 80 |
+
<!-- Previous Button --><button
|
| 81 |
+
id="prev-button"
|
| 82 |
+
onclick="loadAudio('prev')"
|
| 83 |
+
class="mt-3 px-6 py-3 bg-red-500 text-white font-bold rounded-lg shadow-md hover:bg-red-600 transition duration-150 focus:outline-none focus:ring-4 focus:ring-red-500 focus:ring-opacity-50 disabled:bg-gray-400"
|
| 84 |
+
disabled
|
| 85 |
+
>
|
| 86 |
+
« Previous Audio
|
| 87 |
+
</button>
|
| 88 |
+
|
| 89 |
+
<!-- Next Button --><button
|
| 90 |
+
id="next-button"
|
| 91 |
+
onclick="loadAudio('next')"
|
| 92 |
+
class="mt-3 px-6 py-3 bg-green-600 text-white font-bold rounded-lg shadow-md hover:bg-green-700 transition duration-150 focus:outline-none focus:ring-4 focus:ring-green-500 focus:ring-opacity-50 disabled:bg-gray-400"
|
| 93 |
+
disabled
|
| 94 |
+
>
|
| 95 |
+
Next Audio »
|
| 96 |
+
</button>
|
| 97 |
+
|
| 98 |
+
<!-- Download Button --><button
|
| 99 |
+
id="download-button"
|
| 100 |
+
onclick="downloadAnnotations()"
|
| 101 |
+
class="mt-3 px-6 py-3 bg-indigo-600 text-white font-bold rounded-lg shadow-md hover:bg-indigo-700 transition duration-150 focus:outline-none focus:ring-4 focus:ring-indigo-500 focus:ring-opacity-50 disabled:bg-gray-400"
|
| 102 |
+
disabled
|
| 103 |
+
>
|
| 104 |
+
⬇️ Download Annotated Dataset
|
| 105 |
+
</button>
|
| 106 |
+
</div>
|
| 107 |
+
</div>
|
| 108 |
+
</div>
|
| 109 |
+
<footer class="mt-8 mb-4 text-center text-gray-500 text-sm w-full max-w-4xl">
|
| 110 |
+
Thanks Huggingface Spaces 🤗
|
| 111 |
+
</footer>
|
| 112 |
+
|
| 113 |
+
<script src="/static/js/script.js"></script>
|
| 114 |
+
</body>
|
| 115 |
+
</html>
|