From a823e968a2f172508692f6f6eb4c7a16dc76ca35 Mon Sep 17 00:00:00 2001 From: smaubio <118258478+smaubio@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:49:46 +0100 Subject: [PATCH] feat: add audio clip annotation view for dialect rating --- drizzle/0006_polite_moondragon.sql | 10 + drizzle/0007_flawless_wonder_man.sql | 3 + drizzle/meta/0006_snapshot.json | 611 +++++++++++++++++++++++++ drizzle/meta/0007_snapshot.json | 619 ++++++++++++++++++++++++++ drizzle/meta/_journal.json | 14 + src/app/actions/annotations.ts | 135 ++++++ src/app/annotate/[datasetId]/page.tsx | 53 +++ src/app/page.tsx | 112 ++++- src/components/AnnotationView.tsx | 227 ++++++++++ src/components/WaveformPlayer.tsx | 187 ++++++++ src/lib/dialects.ts | 19 + src/lib/model/annotation.ts | 36 ++ 12 files changed, 2022 insertions(+), 4 deletions(-) create mode 100644 drizzle/0006_polite_moondragon.sql create mode 100644 drizzle/0007_flawless_wonder_man.sql create mode 100644 drizzle/meta/0006_snapshot.json create mode 100644 drizzle/meta/0007_snapshot.json create mode 100644 src/app/actions/annotations.ts create mode 100644 src/app/annotate/[datasetId]/page.tsx create mode 100644 src/components/AnnotationView.tsx create mode 100644 src/components/WaveformPlayer.tsx create mode 100644 src/lib/dialects.ts create mode 100644 src/lib/model/annotation.ts diff --git a/drizzle/0006_polite_moondragon.sql b/drizzle/0006_polite_moondragon.sql new file mode 100644 index 0000000..fc74410 --- /dev/null +++ b/drizzle/0006_polite_moondragon.sql @@ -0,0 +1,10 @@ +CREATE TABLE `annotation` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `dataset_entry_id` integer NOT NULL, + `user_id` text NOT NULL, + `rating` integer 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 +); +--> statement-breakpoint +CREATE UNIQUE INDEX `annotation_user_entry_idx` ON `annotation` (`user_id`,`dataset_entry_id`); \ No newline at end of file diff --git a/drizzle/0007_flawless_wonder_man.sql b/drizzle/0007_flawless_wonder_man.sql new file mode 100644 index 0000000..740c563 --- /dev/null +++ b/drizzle/0007_flawless_wonder_man.sql @@ -0,0 +1,3 @@ +DROP INDEX `annotation_user_entry_idx`;--> statement-breakpoint +ALTER TABLE `annotation` ADD `dialect_label` text NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX `annotation_user_entry_dialect_idx` ON `annotation` (`user_id`,`dataset_entry_id`,`dialect_label`); \ No newline at end of file diff --git a/drizzle/meta/0006_snapshot.json b/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..9ccf987 --- /dev/null +++ b/drizzle/meta/0006_snapshot.json @@ -0,0 +1,611 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "59929a2a-9ed3-4f73-8ef7-7af2b8d436b6", + "prevId": "e6165973-fcea-4b1b-8004-60db966b8106", + "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 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "annotation_user_entry_idx": { + "name": "annotation_user_entry_idx", + "columns": [ + "user_id", + "dataset_entry_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" + } + }, + "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 + }, + "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/0007_snapshot.json b/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000..eb3ae75 --- /dev/null +++ b/drizzle/meta/0007_snapshot.json @@ -0,0 +1,619 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "49f84327-9f41-4e67-bf1b-09621233e349", + "prevId": "59929a2a-9ed3-4f73-8ef7-7af2b8d436b6", + "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": { + "annotation_user_entry_dialect_idx": { + "name": "annotation_user_entry_dialect_idx", + "columns": [ + "user_id", + "dataset_entry_id", + "dialect_label" + ], + "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" + } + }, + "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 + }, + "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 97bf443..99cd494 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -43,6 +43,20 @@ "when": 1773395728082, "tag": "0005_flashy_piledriver", "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1773406795226, + "tag": "0006_polite_moondragon", + "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1773407494321, + "tag": "0007_flawless_wonder_man", + "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..c356461 --- /dev/null +++ b/src/app/actions/annotations.ts @@ -0,0 +1,135 @@ +'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 shuffled unannotated entries for the given dataset and authenticated user. + * Consecutive entries will never share the same externalId. + */ +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 shuffleWithConstraint(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 }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Fisher-Yates shuffle with a greedy fix for consecutive same externalId. */ +function shuffleWithConstraint(entries: AnnotationEntry[]): AnnotationEntry[] { + if (entries.length <= 1) return entries; + + const arr = [...entries]; + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } + + // Single-pass greedy fix + for (let i = 1; i < arr.length; i++) { + if (arr[i].externalId === arr[i - 1].externalId) { + for (let j = i + 1; j < arr.length; j++) { + if (arr[j].externalId !== arr[i - 1].externalId) { + [arr[i], arr[j]] = [arr[j], arr[i]]; + break; + } + } + } + } + + return arr; +} diff --git a/src/app/annotate/[datasetId]/page.tsx b/src/app/annotate/[datasetId]/page.tsx new file mode 100644 index 0000000..48b020f --- /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'; +import { getAnnotationEntries } from '@/app/actions/annotations'; +import AnnotationView from '@/components/AnnotationView'; + +interface Props { + params: Promise<{ datasetId: 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 } = await params; + const datasetId = parseInt(datasetIdStr, 10); + + if (isNaN(datasetId)) { + return ( +
+

Ungültige Dataset-ID.

+ + ← Startseite + +
+ ); + } + + 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/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/AnnotationView.tsx b/src/components/AnnotationView.tsx new file mode 100644 index 0000000..3aae875 --- /dev/null +++ b/src/components/AnnotationView.tsx @@ -0,0 +1,227 @@ +'use client'; + +import { useState, 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 = 5; + +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 AnnotationViewProps { + entries: AnnotationEntry[]; + datasetId: number; +} + +export default function AnnotationView({ entries, datasetId }: AnnotationViewProps) { + + 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 [showHint, setShowHint] = useState(false); + 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); + + const handleWeiter = () => { + if (!allCurrentDone) { + setShowHint(true); + return; + } + setShowHint(false); + + 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); + } + }); + }; + + if (isComplete) { + return ( +
+
+

Fertig!

+

+ Alle Samples wurden erfolgreich bewertet. Vielen Dank! +

+ + Zurück zur Startseite + +
+ ); + } + + return ( +
+ {/* ── Top bar ─────────────────────────────────────────── */} +
+
+ + Seite {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; + return ( + + ); + })} +
+
+ ); + })} +
+ + {/* ── Navigation ──────────────────────────────────────── */} +
+ {showHint && !allCurrentDone && ( +

+ Bitte alle{' '} + {pageEntries.length < PAGE_SIZE ? pageEntries.length : PAGE_SIZE}{' '} + Samples vollständig anhören und bewerten, bevor Sie fortfahren. +

+ )} + +
+
+ ); +} 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..26f393b --- /dev/null +++ b/src/lib/model/annotation.ts @@ -0,0 +1,36 @@ +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())`), + }, + (table) => [ + uniqueIndex('annotation_user_entry_dialect_idx').on( + table.userId, + table.datasetEntryId, + table.dialectLabel + ), + ] +); + +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;