diff --git a/drizzle/0007_light_spiral.sql b/drizzle/0007_light_spiral.sql new file mode 100644 index 0000000..618e470 --- /dev/null +++ b/drizzle/0007_light_spiral.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS `annotation` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `dataset_entry_id` integer NOT NULL, + `user_id` text NOT NULL, + `rating` integer NOT NULL, + `dialect_label` text NOT NULL, + `created_at` integer DEFAULT (unixepoch()) NOT NULL, + FOREIGN KEY (`dataset_entry_id`) REFERENCES `dataset_entry`(`id`) ON UPDATE no action ON DELETE no action +); diff --git a/drizzle/meta/0007_snapshot.json b/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000..213e8be --- /dev/null +++ b/drizzle/meta/0007_snapshot.json @@ -0,0 +1,637 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "071a0744-4d6e-4657-948b-7eda495b9756", + "prevId": "4e72f679-ef9b-47d5-a6fb-a1c45d5cbe74", + "tables": { + "annotation": { + "name": "annotation", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "dataset_entry_id": { + "name": "dataset_entry_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rating": { + "name": "rating", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dialect_label": { + "name": "dialect_label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": { + "annotation_dataset_entry_id_dataset_entry_id_fk": { + "name": "annotation_dataset_entry_id_dataset_entry_id_fk", + "tableFrom": "annotation", + "tableTo": "dataset_entry", + "columnsFrom": [ + "dataset_entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "admin": { + "name": "admin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "dataset_entry": { + "name": "dataset_entry", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "speaker_id": { + "name": "speaker_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_name": { + "name": "model_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "utterance_id": { + "name": "utterance_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "utterance_text": { + "name": "utterance_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dataset_id": { + "name": "dataset_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dialect": { + "name": "dialect", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "iteration": { + "name": "iteration", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rms_value": { + "name": "rms_value", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "longest_pause": { + "name": "longest_pause", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "utmos_score": { + "name": "utmos_score", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "wer_score": { + "name": "wer_score", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": { + "dataset_entry_dataset_id_dataset_id_fk": { + "name": "dataset_entry_dataset_id_dataset_id_fk", + "tableFrom": "dataset_entry", + "tableTo": "dataset", + "columnsFrom": [ + "dataset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "dataset": { + "name": "dataset", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 0455de6..a85e5e6 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1773406006276, "tag": "0006_special_junta", "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1773410584481, + "tag": "0007_light_spiral", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/actions/annotations.ts b/src/app/actions/annotations.ts new file mode 100644 index 0000000..e1c9e36 --- /dev/null +++ b/src/app/actions/annotations.ts @@ -0,0 +1,106 @@ +'use server'; + +import db from '@/lib/db'; +import { dataset_entry } from '@/lib/model/dataset_entry'; +import { dataset } from '@/lib/model/dataset'; +import { annotation } from '@/lib/model/annotation'; +import { eq, and } from 'drizzle-orm'; +import { auth } from '@/lib/auth'; +import { headers } from 'next/headers'; +import type { AnnotationEntry } from '@/lib/dialects'; + +/** + * Returns unannotated entries for the given dataset and authenticated user. + */ +export async function getAnnotationEntries(datasetId: number): Promise { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) throw new Error('Nicht angemeldet'); + + const allEntries = await db + .select() + .from(dataset_entry) + .where(eq(dataset_entry.datasetId, datasetId)); + + if (allEntries.length === 0) return []; + + // Find entries already annotated by this user in this dataset (via join) + const annotated = await db + .select({ entryId: annotation.datasetEntryId }) + .from(annotation) + .innerJoin(dataset_entry, eq(annotation.datasetEntryId, dataset_entry.id)) + .where( + and( + eq(annotation.userId, session.user.id), + eq(dataset_entry.datasetId, datasetId) + ) + ); + + const annotatedIds = new Set(annotated.map((a) => a.entryId)); + const remaining = allEntries.filter((e) => !annotatedIds.has(e.id)); + + const mapped: AnnotationEntry[] = remaining.map((e) => ({ + id: e.id, + externalId: e.externalId, + fileName: e.fileName, + dialect: e.dialect, + durationMs: e.durationMs, + datasetId: e.datasetId, + })); + + return mapped; +} + +/** + * Persists a batch of ratings for the authenticated user. + * Silently ignores duplicates (onConflictDoNothing). + */ +export async function saveAnnotations( + ratings: { entryId: number; rating: number; dialectLabel: string }[] +): Promise { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) throw new Error('Nicht angemeldet'); + + if (ratings.length === 0) return; + + for (const { entryId, rating, dialectLabel } of ratings) { + await db + .insert(annotation) + .values({ datasetEntryId: entryId, userId: session.user.id, rating, dialectLabel }) + .onConflictDoNothing(); + } +} + +/** Returns all datasets (for the home page). */ +export async function getAllDatasets() { + return db.select().from(dataset).orderBy(dataset.name); +} + +/** Returns annotation progress for the current user in a dataset. */ +export async function getAnnotationProgress( + datasetId: number +): Promise<{ total: number; done: number }> { + const session = await auth.api.getSession({ headers: await headers() }); + + const allEntries = await db + .select({ id: dataset_entry.id }) + .from(dataset_entry) + .where(eq(dataset_entry.datasetId, datasetId)); + + if (!session || allEntries.length === 0) { + return { total: allEntries.length, done: 0 }; + } + + const annotated = await db + .select({ entryId: annotation.datasetEntryId }) + .from(annotation) + .innerJoin(dataset_entry, eq(annotation.datasetEntryId, dataset_entry.id)) + .where( + and( + eq(annotation.userId, session.user.id), + eq(dataset_entry.datasetId, datasetId) + ) + ); + + return { total: allEntries.length, done: annotated.length }; +} + diff --git a/src/app/annotate/[datasetId]/[prototype]/page.tsx b/src/app/annotate/[datasetId]/[prototype]/page.tsx new file mode 100644 index 0000000..7ffc3b8 --- /dev/null +++ b/src/app/annotate/[datasetId]/[prototype]/page.tsx @@ -0,0 +1,57 @@ +import { redirect, notFound } from 'next/navigation'; +import { headers } from 'next/headers'; +import Link from 'next/link'; +import { auth } from '@/lib/auth'; +import { getAnnotationEntries } from '@/app/actions/annotations'; +import SingleChoiceView from '@/components/AnnotationViews/SingleChoiceView'; + +interface Props { + params: Promise<{ datasetId: string; prototype: string }>; +} + +export default async function AnnotatePage({ params }: Props) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) redirect('/user/sign-in'); + + const { datasetId: datasetIdStr, prototype } = await params; + const datasetId = parseInt(datasetIdStr, 10); + + if (isNaN(datasetId)) { + return ( +
+

Ungültige Dataset-ID.

+ + ← Startseite + +
+ ); + } + + if (prototype !== 'single-choice') { + notFound(); + } + + const entries = await getAnnotationEntries(datasetId); + + if (entries.length === 0) { + return ( +
+
+

+ Alle Samples bewertet! +

+

+ Sie haben alle Samples in diesem Dataset bereits bewertet. Danke für Ihre Mitarbeit! +

+ + Zurück zur Startseite + +
+ ); + } + + return ; +} diff --git a/src/app/annotate/[datasetId]/page.tsx b/src/app/annotate/[datasetId]/page.tsx new file mode 100644 index 0000000..ff66baf --- /dev/null +++ b/src/app/annotate/[datasetId]/page.tsx @@ -0,0 +1,53 @@ +import { redirect } from 'next/navigation'; +import { headers } from 'next/headers'; +import Link from 'next/link'; +import { auth } from '@/lib/auth'; + +const PROTOTYPES = [ + { + id: 'single-choice', + name: 'Single Choice', + description: 'Bewerten Sie jedes Sample auf einer Skala von 1–5.', + }, +]; + +interface Props { + params: Promise<{ datasetId: string }>; +} + +export default async function PrototypeSelectionPage({ params }: Props) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) redirect('/user/sign-in'); + + const { datasetId } = await params; + + return ( +
+ + ← Zurück zur Startseite + +

Prototyp auswählen

+

+ Wählen Sie einen Annotationsprototyp aus, um mit der Bewertung zu beginnen. +

+ +
+ {PROTOTYPES.map((proto) => ( + +
+
+

{proto.name}

+

{proto.description}

+
+ +
+ + ))} +
+
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index baacf2a..e86ee7e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,5 +1,109 @@ -"use client"; +import Link from 'next/link'; +import { headers } from 'next/headers'; +import { auth } from '@/lib/auth'; +import { getAllDatasets, getAnnotationProgress } from '@/app/actions/annotations'; -export default function Page() { - return

Hello Next.js!

-} \ No newline at end of file +export default async function HomePage() { + const session = await auth.api.getSession({ headers: await headers() }); + + if (!session) { + return ( +
+

+ Willkommen zur Dialektannotation +

+

+ Bitte melden Sie sich an, um mit der Annotation zu beginnen. +

+ + Anmelden + +
+ ); + } + + const datasets = await getAllDatasets(); + + if (datasets.length === 0) { + return ( +
+

+ Keine Datasets verfügbar +

+

+ Es wurden noch keine Datasets angelegt. Wenden Sie sich an einen Administrator. +

+
+ ); + } + + // Fetch progress for all datasets in parallel + const progressData = await Promise.all( + datasets.map((ds) => getAnnotationProgress(ds.id)) + ); + + return ( +
+

Datasets

+

+ Wählen Sie ein Dataset aus, um mit der Annotation zu beginnen oder fortzufahren. +

+ +
+ {datasets.map((ds, i) => { + const { total, done } = progressData[i]; + const pct = total > 0 ? Math.round((done / total) * 100) : 0; + const isFinished = total > 0 && done >= total; + + return ( +
+
+
+

{ds.name}

+ {ds.description && ( +

{ds.description}

+ )} + + {/* Progress */} +
+
+ {done} / {total} bewertet + {pct}% +
+
+
+
+
+
+ + 0 + ? 'bg-blue-600 hover:bg-blue-700 text-white' + : 'bg-blue-600 hover:bg-blue-700 text-white' + }`} + > + {isFinished ? '✓ Fertig' : done > 0 ? 'Fortsetzen' : 'Starten'} + +
+
+ ); + })} +
+
+ ); +} diff --git a/src/components/AnnotationViews/SingleChoiceView.tsx b/src/components/AnnotationViews/SingleChoiceView.tsx new file mode 100644 index 0000000..05564c7 --- /dev/null +++ b/src/components/AnnotationViews/SingleChoiceView.tsx @@ -0,0 +1,206 @@ +'use client'; + +import { useState, useEffect, useTransition } from 'react'; +import Link from 'next/link'; +import WaveformPlayer from '../WaveformPlayer'; +import { saveAnnotations } from '@/app/actions/annotations'; +import { type AnnotationEntry, DIALECT_LABELS } from '@/lib/dialects'; + +const PAGE_SIZE = 1; +const AUTO_ADVANCE_DELAY_MS = 600; + +const RATING_OPTIONS = (dialectLabel: string) => [ + { value: 1, label: `Klingt überhaupt nicht nach ${dialectLabel}` }, + { value: 2, label: `Klingt eher nicht nach ${dialectLabel}` }, + { value: 3, label: 'Schwer zu beurteilen' }, + { value: 4, label: `Klingt eher nach ${dialectLabel}` }, + { value: 5, label: `Klingt eindeutig nach ${dialectLabel}` }, +]; + +interface SingleChoiceViewProps { + entries: AnnotationEntry[]; + datasetId: number; +} + +export default function SingleChoiceView({ entries, datasetId }: SingleChoiceViewProps) { + + const [isPending, startTransition] = useTransition(); + + const [currentPage, setCurrentPage] = useState(0); + const [answers, setAnswers] = useState>({}); + const [fullyPlayed, setFullyPlayed] = useState>(new Set()); + const [activePlayerId, setActivePlayerId] = useState(null); + const [isComplete, setIsComplete] = useState(false); + + const totalPages = Math.ceil(entries.length / PAGE_SIZE); + const pageEntries = entries.slice(currentPage * PAGE_SIZE, (currentPage + 1) * PAGE_SIZE); + const progressPct = Math.round((currentPage / totalPages) * 100); + + const allCurrentDone = + pageEntries.length > 0 && + pageEntries.every((e) => fullyPlayed.has(e.id) && answers[e.id] !== undefined); + + useEffect(() => { + if (!allCurrentDone || isPending) return; + + const timer = setTimeout(() => { + const batch = pageEntries.map((e) => ({ entryId: e.id, rating: answers[e.id], dialectLabel: e.dialect })); + startTransition(async () => { + await saveAnnotations(batch); + if (currentPage + 1 >= totalPages) { + setIsComplete(true); + } else { + setCurrentPage((p) => p + 1); + setActivePlayerId(null); + } + }); + }, AUTO_ADVANCE_DELAY_MS); + + return () => clearTimeout(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [allCurrentDone]); + + if (isComplete) { + return ( +
+
+

Fertig!

+

+ Alle Samples wurden erfolgreich bewertet. Vielen Dank! +

+ + Zurück zur Startseite + +
+ ); + } + + return ( +
+ {/* ── Top bar ─────────────────────────────────────────── */} +
+
+ + Sample {currentPage + 1} von {totalPages} + + + ← Zurück zur Startseite + +
+ {/* Progress bar */} +
+
+
+
{progressPct}% abgeschlossen
+
+ + {/* ── Sample cards ────────────────────────────────────── */} +
+ {pageEntries.map((entry, idx) => { + const dialectLabel = DIALECT_LABELS[entry.dialect] ?? entry.dialect; + const fileExt = entry.fileName.substring(entry.fileName.lastIndexOf('.')); + const audioSrc = `/public/datasets/${datasetId}/${entry.externalId}${fileExt}`; + const isListened = fullyPlayed.has(entry.id); + const currentRating = answers[entry.id] ?? null; + + return ( +
+ {/* Sample header */} +
+ + Sample {currentPage * PAGE_SIZE + idx + 1} + + {isListened && ( + + ✓ Gehört + + )} +
+ + {/* Waveform player */} + setActivePlayerId(entry.id)} + onFullyPlayed={() => + setFullyPlayed((prev) => { + const next = new Set(prev); + next.add(entry.id); + return next; + }) + } + /> + + {/* Must-listen hint */} + {!isListened && ( +

+ Bitte das Sample vollständig anhören, bevor Sie eine Bewertung abgeben. +

+ )} + + {/* Dialect + question */} +
+

+ Wie authentisch klingt dieses Sample nach dem Dialekt der Region{' '} + {dialectLabel}? +

+
+ + {/* Rating buttons */} +
+ {RATING_OPTIONS(dialectLabel).map(({ value, label }) => { + const selected = currentRating === value; + const disabled = !isListened || isPending; + return ( + + ); + })} +
+
+ ); + })} +
+
+ ); +} diff --git a/src/components/WaveformPlayer.tsx b/src/components/WaveformPlayer.tsx new file mode 100644 index 0000000..420af86 --- /dev/null +++ b/src/components/WaveformPlayer.tsx @@ -0,0 +1,187 @@ +'use client'; + +import { useEffect, useRef, useState, useCallback } from 'react'; + +interface WaveformPlayerProps { + src: string; + durationMs?: number | null; + /** When false the player should stop if it was playing */ + isActive: boolean; + onPlay: () => void; + onFullyPlayed: () => void; +} + +const BAR_COUNT = 120; + +export default function WaveformPlayer({ + src, + durationMs, + isActive, + onPlay, + onFullyPlayed, +}: WaveformPlayerProps) { + const audioRef = useRef(null); + const canvasRef = useRef(null); + const rafRef = useRef(0); + + const [isPlaying, setIsPlaying] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(durationMs ? durationMs / 1000 : 0); + const [peaks, setPeaks] = useState([]); + const fullyPlayedRef = useRef(false); + + // Stop if another player became active + useEffect(() => { + if (!isActive && isPlaying) { + audioRef.current?.pause(); + cancelAnimationFrame(rafRef.current); + setIsPlaying(false); + } + }, [isActive, isPlaying]); + + // Decode audio and generate waveform peaks + useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await fetch(src); + const buf = await res.arrayBuffer(); + const actx = new AudioContext(); + const decoded = await actx.decodeAudioData(buf); + if (cancelled) return; + + const data = decoded.getChannelData(0); + const blockSize = Math.floor(data.length / BAR_COUNT); + const raw: number[] = []; + for (let i = 0; i < BAR_COUNT; i++) { + let sum = 0; + for (let j = 0; j < blockSize; j++) { + sum += Math.abs(data[i * blockSize + j]); + } + raw.push(sum / blockSize); + } + const max = Math.max(...raw, 0.001); + if (!cancelled) setPeaks(raw.map((v) => v / max)); + await actx.close(); + } catch { + if (!cancelled) setPeaks(Array(BAR_COUNT).fill(0.5)); + } + })(); + return () => { cancelled = true; }; + }, [src]); + + // Draw waveform on canvas whenever peaks or playback position change + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || peaks.length === 0) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + const { width, height } = canvas; + ctx.clearRect(0, 0, width, height); + const progress = duration > 0 ? Math.min(currentTime / duration, 1) : 0; + const barW = Math.max(1, width / BAR_COUNT - 1); + + peaks.forEach((peak, i) => { + const x = (i / BAR_COUNT) * width; + const barH = Math.max(2, peak * height * 0.85); + const y = (height - barH) / 2; + const played = i / BAR_COUNT < progress; + ctx.fillStyle = played ? '#3b82f6' : '#d1d5db'; + ctx.beginPath(); + ctx.roundRect(x, y, barW, barH, 2); + ctx.fill(); + }); + }, [peaks, currentTime, duration]); + + const tick = useCallback(() => { + const audio = audioRef.current; + if (!audio) return; + setCurrentTime(audio.currentTime); + rafRef.current = requestAnimationFrame(tick); + }, []); + + const handlePlay = () => { + const audio = audioRef.current; + if (!audio) return; + onPlay(); + audio.play(); + setIsPlaying(true); + rafRef.current = requestAnimationFrame(tick); + }; + + const handlePause = () => { + const audio = audioRef.current; + if (!audio) return; + audio.pause(); + setIsPlaying(false); + cancelAnimationFrame(rafRef.current); + }; + + const handleEnded = () => { + setIsPlaying(false); + cancelAnimationFrame(rafRef.current); + if (!fullyPlayedRef.current) { + fullyPlayedRef.current = true; + onFullyPlayed(); + } + }; + + const handleLoadedMetadata = () => { + if (audioRef.current) setDuration(audioRef.current.duration); + }; + + const fmt = (s: number) => { + const m = Math.floor(s / 60); + const sec = Math.floor(s % 60); + return `${m}:${sec.toString().padStart(2, '0')}`; + }; + + return ( +
+
+ ); +} diff --git a/src/lib/dialects.ts b/src/lib/dialects.ts new file mode 100644 index 0000000..faa3d66 --- /dev/null +++ b/src/lib/dialects.ts @@ -0,0 +1,19 @@ +export const DIALECT_LABELS: Record = { + ch_be: 'Bern', + ch_bs: 'Basel', + ch_gr: 'Graubünden', + ch_in: 'Innerschweiz', + ch_os: 'Ostschweiz', + ch_vs: 'Wallis', + ch_zh: 'Zürich', + de: 'Deutsch', +}; + +export type AnnotationEntry = { + id: number; + externalId: string; + fileName: string; + dialect: string; + durationMs: number | null; + datasetId: number; +}; diff --git a/src/lib/model/annotation.ts b/src/lib/model/annotation.ts new file mode 100644 index 0000000..47e0057 --- /dev/null +++ b/src/lib/model/annotation.ts @@ -0,0 +1,29 @@ +import { sqliteTable, text, integer, uniqueIndex } from 'drizzle-orm/sqlite-core'; +import { relations, sql } from 'drizzle-orm'; +import { dataset_entry } from './dataset_entry'; + +export const annotation = sqliteTable( + 'annotation', + { + id: integer('id').primaryKey({ autoIncrement: true }), + datasetEntryId: integer('dataset_entry_id') + .notNull() + .references(() => dataset_entry.id), + userId: text('user_id').notNull(), + rating: integer('rating').notNull(), // 1–5 + dialectLabel: text('dialect_label').notNull(), // e.g. 'ch_be', 'ch_zh', … + createdAt: integer('created_at', { mode: 'timestamp' }) + .notNull() + .default(sql`(unixepoch())`), + } +); + +export const annotationRelations = relations(annotation, ({ one }) => ({ + datasetEntry: one(dataset_entry, { + fields: [annotation.datasetEntryId], + references: [dataset_entry.id], + }), +})); + +export type Annotation = typeof annotation.$inferSelect; +export type NewAnnotation = typeof annotation.$inferInsert;