diff --git a/drizzle/0009_useful_human_robot.sql b/drizzle/0009_useful_human_robot.sql new file mode 100644 index 0000000..b92a446 --- /dev/null +++ b/drizzle/0009_useful_human_robot.sql @@ -0,0 +1 @@ +CREATE UNIQUE INDEX `unique_annotation` ON `annotation` (`dataset_entry_id`,`experiment_id`,`user_id`); \ No newline at end of file diff --git a/drizzle/meta/0009_snapshot.json b/drizzle/meta/0009_snapshot.json new file mode 100644 index 0000000..1553580 --- /dev/null +++ b/drizzle/meta/0009_snapshot.json @@ -0,0 +1,758 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "73a78a6a-e342-4183-8d27-3a076e9ec87b", + "prevId": "d2076a12-9038-4cce-b78c-e3f736effe9c", + "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 + }, + "experiment_id": { + "name": "experiment_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "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())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "unique_annotation": { + "name": "unique_annotation", + "columns": [ + "dataset_entry_id", + "experiment_id", + "user_id" + ], + "isUnique": true + } + }, + "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" + }, + "annotation_experiment_id_experiment_id_fk": { + "name": "annotation_experiment_id_experiment_id_fk", + "tableFrom": "annotation", + "tableTo": "experiment", + "columnsFrom": [ + "experiment_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": {} + }, + "experiment": { + "name": "experiment", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published": { + "name": "published", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "annotation_tool": { + "name": "annotation_tool", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dataset_id": { + "name": "dataset_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "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": { + "experiment_dataset_id_dataset_id_fk": { + "name": "experiment_dataset_id_dataset_id_fk", + "tableFrom": "experiment", + "tableTo": "dataset", + "columnsFrom": [ + "dataset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "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 14f3b07..ba61a90 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1773989236199, "tag": "0008_smiling_lady_mastermind", "breakpoints": true + }, + { + "idx": 9, + "version": "6", + "when": 1773994442668, + "tag": "0009_useful_human_robot", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/actions/annotations.ts b/src/app/actions/annotations.ts index bfe89e6..b10abdf 100644 --- a/src/app/actions/annotations.ts +++ b/src/app/actions/annotations.ts @@ -66,7 +66,7 @@ export async function getAnnotationEntries(experimentId: number): Promise; @@ -55,9 +55,11 @@ export default async function AnnotatePage({ params }: Props) { // Render based on prototype type const renderAnnotationView = () => { switch (prototype) { - case 'single-choice': - return ; - // Add more prototypes here as needed + case 'quality-choice': + return ; + case 'binary': + //return ; + return
; default: return (
diff --git a/src/app/page.tsx b/src/app/page.tsx index 2a8136d..d07d5ec 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -73,7 +73,7 @@ export default async function HomePage() { {/* Progress */}
- {done} / {total} bewertet + {done} / {total} annotiert {pct}%
diff --git a/src/components/AnnotationViews/QualityChoiceEntryView.tsx b/src/components/AnnotationViews/QualityChoiceEntryView.tsx new file mode 100644 index 0000000..6006109 --- /dev/null +++ b/src/components/AnnotationViews/QualityChoiceEntryView.tsx @@ -0,0 +1,127 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import WaveformPlayer from '../WaveformPlayer'; +import { type DatasetEntryForAnnotation, DIALECT_LABELS } from '@/lib/dialects'; + +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 QualityChoiceEntryViewProps { + entry: DatasetEntryForAnnotation; + onSave: (rating: number) => Promise; + isSaving: boolean; +} + +export default function QualityChoiceEntryView({ + entry, + onSave, + isSaving, +}: QualityChoiceEntryViewProps) { + const [answer, setAnswer] = useState(null); + const [fullyPlayed, setFullyPlayed] = useState(false); + + const dialectLabel = DIALECT_LABELS[entry.dialect] ?? entry.dialect; + const fileExt = entry.fileName.substring(entry.fileName.lastIndexOf('.')); + const audioSrc = `/public/datasets/${entry.datasetId}/${entry.externalId}${fileExt}`; + + // Auto-save when answer is selected + useEffect(() => { + if (answer === null || isSaving) return; + if (answer === entry.annotation) return; // No change, don't save + + const timer = setTimeout(() => { + onSave(answer); + }, AUTO_ADVANCE_DELAY_MS); + + return () => clearTimeout(timer); + }, [answer, onSave, entry.annotation, isSaving]); + + useEffect(() => { + // Reset state when entry changes + setAnswer(entry.annotation); + setFullyPlayed(entry.annotation !== null); // If already annotated, mark as fully played to allow changing answer + }, [entry]); + + return ( +
+ {/* Sample header */} +
+ + Sample - {entry.externalId} + + {fullyPlayed && ( + + ✓ Gehört + + )} +
+ + {/* Waveform player */} + setFullyPlayed(true)} + /> + + {/* Must-listen hint */} + {!fullyPlayed && ( +

+ 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 = answer === value; + const disabled = !fullyPlayed || isSaving; + return ( + + ); + })} +
+
+ ); +} diff --git a/src/components/AnnotationViews/QualityChoiceView.tsx b/src/components/AnnotationViews/QualityChoiceView.tsx new file mode 100644 index 0000000..6eb7939 --- /dev/null +++ b/src/components/AnnotationViews/QualityChoiceView.tsx @@ -0,0 +1,126 @@ +'use client'; + +import { useState, useMemo, useTransition } from 'react'; +import Link from 'next/link'; +import { saveAnnotations } from '@/app/actions/annotations'; +import { type DatasetEntryForAnnotation } from '@/lib/dialects'; +import QualityChoiceEntryView from './QualityChoiceEntryView'; + +interface QualityChoiceViewProps { + entries: DatasetEntryForAnnotation[]; + experimentId: number; +} + +export default function QualityChoicePage({ entries, experimentId }: QualityChoiceViewProps) { + + const [isPending, startTransition] = useTransition(); + + // Find the first unannotated entry to start from + const startingIndex = useMemo(() => { + const firstUnannotatedIndex = entries.findIndex((e) => e.annotation === null); + return firstUnannotatedIndex === -1 ? 0 : firstUnannotatedIndex; + }, [entries]); + + const [currentIndex, setCurrentIndex] = useState(startingIndex); + + const currentEntry = entries[currentIndex]; + const isComplete = entries.every((e) => e.annotation !== null); + + const handlePrevious = () => { + if (currentIndex > 0) { + setCurrentIndex((i) => i - 1); + } + }; + + const handleNext = () => { + if (currentIndex + 1 < entries.length) { + setCurrentIndex((i) => i + 1); + } + }; + + const handleSaveEntry = async (rating: number) => { + startTransition(async () => { + const batch = [{ entryId: currentEntry!.id, rating, dialectLabel: currentEntry!.dialect }]; + await saveAnnotations(batch, experimentId); + entries[currentIndex].annotation = rating; // Update local state optimistically + handleNext(); + }); + }; + + // Calculate progress: count already-annotated entries + const annotatedCount = entries.filter((e) => e.annotation !== null).length; + const progressPct = Math.round(((annotatedCount) / entries.length) * 100); + + if (isComplete) { + return ( +
+
+

Fertig!

+

+ Alle Samples wurden erfolgreich bewertet. Vielen Dank! +

+ + Zurück zur Startseite + +
+ ); + } + + return ( +
+ {/* ── Top bar ─────────────────────────────────────────── */} +
+
+ + ← Startseite + +
+ + + +
+
+ {/* Progress bar */} +
+
+
+
+
{annotatedCount}/{entries.length} annotiert
+
{progressPct}% abgeschlossen
+
+
+ + {/* ── Sample entry ────────────────────────────────────── */} +
+ {currentEntry && ( + + )} +
+
+ ); +} diff --git a/src/components/AnnotationViews/SingleChoiceView.tsx b/src/components/AnnotationViews/SingleChoiceView.tsx deleted file mode 100644 index ec0c063..0000000 --- a/src/components/AnnotationViews/SingleChoiceView.tsx +++ /dev/null @@ -1,206 +0,0 @@ -'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 DatasetEntryForAnnotation, 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: DatasetEntryForAnnotation[]; - experimentId: number; -} - -export default function SingleChoiceView({ entries, experimentId }: 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, experimentId); - 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/${entry.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/EditableExperimentHeader.tsx b/src/components/EditableExperimentHeader.tsx index 3709f23..77ddd4f 100644 --- a/src/components/EditableExperimentHeader.tsx +++ b/src/components/EditableExperimentHeader.tsx @@ -9,7 +9,9 @@ interface EditableExperimentHeaderProps { } const ANNOTATION_TOOLS = [ - { value: 'single-choice', label: 'Single Choice' }, + { value: 'quality-choice', label: 'Quality Choice' }, + { value: 'binary', label: 'Binary' }, + // Future tools can be added here ]; export default function EditableExperimentHeader({ @@ -18,7 +20,7 @@ export default function EditableExperimentHeader({ const [isEditing, setIsEditing] = useState(false); const [name, setName] = useState(experiment.name); const [description, setDescription] = useState(experiment.description || ''); - const [annotationTool, setAnnotationTool] = useState(experiment.annotationTool || 'single-choice'); + const [annotationTool, setAnnotationTool] = useState(experiment.annotationTool || 'quality-choice'); const [published, setPublished] = useState(experiment.published || false); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -49,7 +51,7 @@ export default function EditableExperimentHeader({ function handleCancel() { setName(experiment.name); - setAnnotationTool(experiment.annotationTool || 'single-choice'); + setAnnotationTool(experiment.annotationTool || 'quality-choice'); setPublished(experiment.published || false); setDescription(experiment.description || ''); setError(null); diff --git a/src/components/WaveformPlayer.tsx b/src/components/WaveformPlayer.tsx index 420af86..0235cda 100644 --- a/src/components/WaveformPlayer.tsx +++ b/src/components/WaveformPlayer.tsx @@ -1,13 +1,12 @@ 'use client'; -import { useEffect, useRef, useState, useCallback } from 'react'; +import { useEffect, useRef, useState, useCallback, useMemo } from 'react'; +import { useAudio } from './AudioProvider'; interface WaveformPlayerProps { src: string; durationMs?: number | null; - /** When false the player should stop if it was playing */ - isActive: boolean; - onPlay: () => void; + onPlay?: () => void; onFullyPlayed: () => void; } @@ -16,10 +15,15 @@ const BAR_COUNT = 120; export default function WaveformPlayer({ src, durationMs, - isActive, onPlay, onFullyPlayed, }: WaveformPlayerProps) { + const { currentAudioId, setCurrentAudio } = useAudio(); + + // Generate unique ID for this player instance + const playerId = useMemo(() => Math.random().toString(36).slice(2), []); + const isActive = currentAudioId === playerId; + const audioRef = useRef(null); const canvasRef = useRef(null); const rafRef = useRef(0); @@ -39,6 +43,21 @@ export default function WaveformPlayer({ } }, [isActive, isPlaying]); + // Reset state when src changes and cancel playback + + useEffect(() => { + setPeaks([]); + setCurrentTime(0); + setDuration(durationMs ? durationMs / 1000 : 0); + fullyPlayedRef.current = false; + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current.currentTime = 0; + } + cancelAnimationFrame(rafRef.current); + setIsPlaying(false); + }, [src, durationMs]); + // Decode audio and generate waveform peaks useEffect(() => { let cancelled = false; @@ -103,7 +122,8 @@ export default function WaveformPlayer({ const handlePlay = () => { const audio = audioRef.current; if (!audio) return; - onPlay(); + setCurrentAudio(playerId); + onPlay?.(); audio.play(); setIsPlaying(true); rafRef.current = requestAnimationFrame(tick); @@ -122,7 +142,7 @@ export default function WaveformPlayer({ cancelAnimationFrame(rafRef.current); if (!fullyPlayedRef.current) { fullyPlayedRef.current = true; - onFullyPlayed(); + onFullyPlayed?.(); } }; diff --git a/src/lib/model/annotation.ts b/src/lib/model/annotation.ts index d0f500e..e06d2fc 100644 --- a/src/lib/model/annotation.ts +++ b/src/lib/model/annotation.ts @@ -20,7 +20,10 @@ export const annotation = sqliteTable( .notNull() .default(sql`(unixepoch())`) .$onUpdate(() => sql`(unixepoch())`), - } + }, + (table) => ({ + uniqueAnnotation: uniqueIndex('unique_annotation').on(table.datasetEntryId, table.experimentId, table.userId), + }) ); export const annotationRelations = relations(annotation, ({ one }) => ({