diff --git a/apps/api/drizzle-sqlite-legacy/meta/0000_snapshot.json b/apps/api/drizzle-sqlite-legacy/meta/0000_snapshot.json index 54abc0aa..88f526a9 100644 --- a/apps/api/drizzle-sqlite-legacy/meta/0000_snapshot.json +++ b/apps/api/drizzle-sqlite-legacy/meta/0000_snapshot.json @@ -1,54 +1,67 @@ { - "version": "6", - "dialect": "sqlite", - "id": "c7909605-aabf-4832-8ef8-9390c0f7c99a", - "prevId": "00000000-0000-0000-0000-000000000000", + "id": "b83e1f2a-9c4d-4e7b-a1f3-8d2c6b5a4e90", + "prevId": "47a9d637-64e3-4cca-a916-cf42ea63b335", + "version": "7", + "dialect": "postgresql", "tables": { - "api_keys": { + "public.api_keys": { "name": "api_keys", + "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, - "notNull": true, - "autoincrement": false + "notNull": true }, "user_id": { "name": "user_id", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "key_hash": { "name": "key_hash", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": false }, "name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true, - "autoincrement": false, "default": "'Default API Key'" }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, "created_at": { "name": "created_at", - "type": "integer", + "type": "timestamp with time zone", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "last_used_at": { "name": "last_used_at", - "type": "integer", + "type": "timestamp with time zone", "primaryKey": false, - "notNull": false, - "autoincrement": false + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false } }, "indexes": {}, @@ -65,120 +78,423 @@ }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "checkConstraints": {} + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false }, - "jobs": { - "name": "jobs", + "public.audit_log": { + "name": "audit_log", + "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, - "notNull": true, - "autoincrement": false + "notNull": true }, - "type": { - "name": "type", + "actor_id": { + "name": "actor_id", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": false }, - "status": { - "name": "status", + "actor_username": { + "name": "actor_username", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'queued'" + "notNull": true }, - "progress": { - "name": "progress", - "type": "real", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "input_files": { - "name": "input_files", + "action": { + "name": "action", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, - "output_path": { - "name": "output_path", + "target_type": { + "name": "target_type", "type": "text", "primaryKey": false, - "notNull": false, - "autoincrement": false + "notNull": false }, - "settings": { - "name": "settings", + "target_id": { + "name": "target_id", "type": "text", "primaryKey": false, - "notNull": false, - "autoincrement": false + "notNull": false }, - "error": { - "name": "error", + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", "type": "text", "primaryKey": false, - "notNull": false, - "autoincrement": false + "notNull": false }, "created_at": { "name": "created_at", - "type": "integer", + "type": "timestamp with time zone", "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "completed_at": { - "name": "completed_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false + "notNull": true } }, "indexes": {}, - "foreignKeys": {}, + "foreignKeys": { + "audit_log_actor_id_users_id_fk": { + "name": "audit_log_actor_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "checkConstraints": {} + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false }, - "sessions": { - "name": "sessions", + "public.jobs": { + "name": "jobs", + "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, - "notNull": true, - "autoincrement": false + "notNull": true }, "user_id": { "name": "user_id", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": false }, - "expires_at": { - "name": "expires_at", + "tool_id": { + "name": "tool_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pool": { + "name": "pool", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "attempts": { + "name": "attempts", "type": "integer", "primaryKey": false, "notNull": true, - "autoincrement": false + "default": 0 + }, + "progress": { + "name": "progress", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "input_refs": { + "name": "input_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "output_refs": { + "name": "output_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "bytes_in": { + "name": "bytes_in", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "bytes_out": { + "name": "bytes_out", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false }, "created_at": { "name": "created_at", - "type": "integer", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "jobs_created_at_idx": { + "name": "jobs_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "jobs_status_idx": { + "name": "jobs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "jobs_user_id_users_id_fk": { + "name": "jobs_user_id_users_id_fk", + "tableFrom": "jobs", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pipelines": { + "name": "pipelines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "steps": { + "name": "steps", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "pipelines_user_id_users_id_fk": { + "name": "pipelines_user_id_users_id_fk", + "tableFrom": "pipelines", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", "primaryKey": false, "notNull": true, - "autoincrement": false + "default": "''" + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "is_builtin": { + "name": "is_builtin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "roles_created_by_users_id_fk": { + "name": "roles_created_by_users_id_fk", + "tableFrom": "roles", + "tableTo": "users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true } }, "indexes": {}, @@ -195,115 +511,297 @@ }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "checkConstraints": {} + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false }, - "settings": { + "public.settings": { "name": "settings", + "schema": "", "columns": { "key": { "name": "key", "type": "text", "primaryKey": true, - "notNull": true, - "autoincrement": false + "notNull": true }, "value": { "name": "value", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "updated_at": { "name": "updated_at", - "type": "integer", + "type": "timestamp with time zone", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true } }, "indexes": {}, "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "checkConstraints": {} + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false }, - "users": { - "name": "users", + "public.teams": { + "name": "teams", + "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "teams_name_unique": { + "name": "teams_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_files": { + "name": "user_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stored_name": { + "name": "stored_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, "notNull": true, - "autoincrement": false + "default": 1 + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_chain": { + "name": "tool_chain", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_files_user_id_users_id_fk": { + "name": "user_files_user_id_users_id_fk", + "tableFrom": "user_files", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true }, "username": { "name": "username", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "password_hash": { "name": "password_hash", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": false }, "role": { "name": "role", "type": "text", "primaryKey": false, "notNull": true, - "autoincrement": false, "default": "'user'" }, + "team": { + "name": "team", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Default'" + }, "must_change_password": { "name": "must_change_password", - "type": "integer", + "type": "boolean", "primaryKey": false, "notNull": true, - "autoincrement": false, "default": true }, + "auth_provider": { + "name": "auth_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local'" + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, "created_at": { "name": "created_at", - "type": "integer", + "type": "timestamp with time zone", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "updated_at": { "name": "updated_at", - "type": "integer", + "type": "timestamp with time zone", "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "users_username_unique": { - "name": "users_username_unique", - "columns": ["username"], - "isUnique": true + "notNull": true + }, + "analytics_enabled": { + "name": "analytics_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "analytics_consent_shown_at": { + "name": "analytics_consent_shown_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "analytics_consent_remind_at": { + "name": "analytics_consent_remind_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false } }, + "indexes": {}, "foreignKeys": {}, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": ["username"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false } }, - "views": {}, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} + "enums": { + "public.job_status": { + "name": "job_status", + "schema": "public", + "values": ["queued", "processing", "completed", "failed", "canceled"] + } }, - "internal": { - "indexes": {} + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} } } diff --git a/apps/api/drizzle-sqlite-legacy/meta/0001_snapshot.json b/apps/api/drizzle-sqlite-legacy/meta/0001_snapshot.json index 88f526a9..6bf86838 100644 --- a/apps/api/drizzle-sqlite-legacy/meta/0001_snapshot.json +++ b/apps/api/drizzle-sqlite-legacy/meta/0001_snapshot.json @@ -1,67 +1,54 @@ { - "id": "b83e1f2a-9c4d-4e7b-a1f3-8d2c6b5a4e90", - "prevId": "47a9d637-64e3-4cca-a916-cf42ea63b335", - "version": "7", - "dialect": "postgresql", + "version": "6", + "dialect": "sqlite", + "id": "91a14a95-bbcb-46ef-abe3-6d2f6fbc8458", + "prevId": "c7909605-aabf-4832-8ef8-9390c0f7c99a", "tables": { - "public.api_keys": { + "api_keys": { "name": "api_keys", - "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, - "notNull": true + "notNull": true, + "autoincrement": false }, "user_id": { "name": "user_id", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "key_hash": { "name": "key_hash", "type": "text", "primaryKey": false, - "notNull": true - }, - "key_prefix": { - "name": "key_prefix", - "type": "text", - "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false }, "name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true, + "autoincrement": false, "default": "'Default API Key'" }, - "permissions": { - "name": "permissions", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, "created_at": { "name": "created_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "last_used_at": { "name": "last_used_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, - "notNull": false - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false } }, "indexes": {}, @@ -78,423 +65,165 @@ }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "checkConstraints": {} }, - "public.audit_log": { - "name": "audit_log", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "actor_id": { - "name": "actor_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "actor_username": { - "name": "actor_username", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "action": { - "name": "action", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "target_type": { - "name": "target_type", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "target_id": { - "name": "target_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "details": { - "name": "details", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "ip_address": { - "name": "ip_address", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "audit_log_actor_id_users_id_fk": { - "name": "audit_log_actor_id_users_id_fk", - "tableFrom": "audit_log", - "tableTo": "users", - "columnsFrom": ["actor_id"], - "columnsTo": ["id"], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.jobs": { + "jobs": { "name": "jobs", - "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "tool_id": { - "name": "tool_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "pool": { - "name": "pool", - "type": "text", - "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false }, "type": { "name": "type", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "status": { "name": "status", - "type": "job_status", - "typeSchema": "public", + "type": "text", "primaryKey": false, "notNull": true, + "autoincrement": false, "default": "'queued'" }, - "attempts": { - "name": "attempts", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, "progress": { "name": "progress", - "type": "jsonb", + "type": "real", "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false, + "default": 0 }, - "input_refs": { - "name": "input_refs", - "type": "jsonb", + "input_files": { + "name": "input_files", + "type": "text", "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false }, - "output_refs": { - "name": "output_refs", - "type": "jsonb", + "output_path": { + "name": "output_path", + "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "settings": { "name": "settings", - "type": "jsonb", + "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "error": { "name": "error", - "type": "jsonb", + "type": "text", "primaryKey": false, - "notNull": false - }, - "bytes_in": { - "name": "bytes_in", - "type": "bigint", - "primaryKey": false, - "notNull": false - }, - "bytes_out": { - "name": "bytes_out", - "type": "bigint", - "primaryKey": false, - "notNull": false - }, - "duration_ms": { - "name": "duration_ms", - "type": "integer", - "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "created_at": { "name": "created_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, - "notNull": true - }, - "started_at": { - "name": "started_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false }, "completed_at": { "name": "completed_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "jobs_created_at_idx": { - "name": "jobs_created_at_idx", - "columns": [ - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "jobs_status_idx": { - "name": "jobs_status_idx", - "columns": [ - { - "expression": "status", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "jobs_user_id_users_id_fk": { - "name": "jobs_user_id_users_id_fk", - "tableFrom": "jobs", - "tableTo": "users", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "set null", - "onUpdate": "no action" + "notNull": false, + "autoincrement": false } }, + "indexes": {}, + "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "checkConstraints": {} }, - "public.pipelines": { + "pipelines": { "name": "pipelines", - "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false }, "name": { "name": "name", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "description": { "name": "description", "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "steps": { "name": "steps", - "type": "jsonb", + "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "created_at": { "name": "created_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false } }, "indexes": {}, - "foreignKeys": { - "pipelines_user_id_users_id_fk": { - "name": "pipelines_user_id_users_id_fk", - "tableFrom": "pipelines", - "tableTo": "users", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, + "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "checkConstraints": {} }, - "public.roles": { - "name": "roles", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "''" - }, - "permissions": { - "name": "permissions", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "is_builtin": { - "name": "is_builtin", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "created_by": { - "name": "created_by", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "roles_created_by_users_id_fk": { - "name": "roles_created_by_users_id_fk", - "tableFrom": "roles", - "tableTo": "users", - "columnsFrom": ["created_by"], - "columnsTo": ["id"], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "roles_name_unique": { - "name": "roles_name_unique", - "nullsNotDistinct": false, - "columns": ["name"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.sessions": { + "sessions": { "name": "sessions", - "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, - "notNull": true + "notNull": true, + "autoincrement": false }, "user_id": { "name": "user_id", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "expires_at": { "name": "expires_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, - "notNull": true - }, - "id_token": { - "name": "id_token", - "type": "text", - "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false }, "created_at": { "name": "created_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false } }, "indexes": {}, @@ -511,297 +240,115 @@ }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "checkConstraints": {} }, - "public.settings": { + "settings": { "name": "settings", - "schema": "", "columns": { "key": { "name": "key", "type": "text", "primaryKey": true, - "notNull": true + "notNull": true, + "autoincrement": false }, "value": { "name": "value", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "updated_at": { "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.teams": { - "name": "teams", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "teams_name_unique": { - "name": "teams_name_unique", - "nullsNotDistinct": false, - "columns": ["name"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.user_files": { - "name": "user_files", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "original_name": { - "name": "original_name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "stored_name": { - "name": "stored_name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "mime_type": { - "name": "mime_type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "size": { - "name": "size", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "width": { - "name": "width", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "height": { - "name": "height", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "version": { - "name": "version", "type": "integer", "primaryKey": false, "notNull": true, - "default": 1 - }, - "parent_id": { - "name": "parent_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "tool_chain": { - "name": "tool_chain", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true + "autoincrement": false } }, "indexes": {}, - "foreignKeys": { - "user_files_user_id_users_id_fk": { - "name": "user_files_user_id_users_id_fk", - "tableFrom": "user_files", - "tableTo": "users", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, + "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "checkConstraints": {} }, - "public.users": { + "users": { "name": "users", - "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, - "notNull": true + "notNull": true, + "autoincrement": false }, "username": { "name": "username", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "password_hash": { "name": "password_hash", "type": "text", "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false }, "role": { "name": "role", "type": "text", "primaryKey": false, "notNull": true, + "autoincrement": false, "default": "'user'" }, - "team": { - "name": "team", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'Default'" - }, "must_change_password": { "name": "must_change_password", - "type": "boolean", + "type": "integer", "primaryKey": false, "notNull": true, + "autoincrement": false, "default": true }, - "auth_provider": { - "name": "auth_provider", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'local'" - }, - "external_id": { - "name": "external_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": false - }, "created_at": { "name": "created_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "updated_at": { "name": "updated_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, - "notNull": true - }, - "analytics_enabled": { - "name": "analytics_enabled", - "type": "boolean", - "primaryKey": false, - "notNull": false - }, - "analytics_consent_shown_at": { - "name": "analytics_consent_shown_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "analytics_consent_remind_at": { - "name": "analytics_consent_remind_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false } }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { + "indexes": { "users_username_unique": { "name": "users_username_unique", - "nullsNotDistinct": false, - "columns": ["username"] + "columns": ["username"], + "isUnique": true } }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} } }, - "enums": { - "public.job_status": { - "name": "job_status", - "schema": "public", - "values": ["queued", "processing", "completed", "failed", "canceled"] - } - }, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, "views": {}, + "enums": {}, "_meta": { - "columns": {}, "schemas": {}, - "tables": {} + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} } } diff --git a/apps/api/src/lib/media-tool.ts b/apps/api/src/lib/media-tool.ts index 905f4d2c..295d04a0 100644 --- a/apps/api/src/lib/media-tool.ts +++ b/apps/api/src/lib/media-tool.ts @@ -32,6 +32,55 @@ export function audioContentType(ext: string): string { return EXT_AUDIO_CONTENT_TYPES[ext.toLowerCase()] || "audio/mpeg"; } +/** atempo only accepts 0.5..100; chain factors of 0.5 until in range. */ +export function buildAtempoChain(factor: number): string { + const parts: string[] = []; + let f = factor; + while (f < 0.5) { + parts.push("atempo=0.5"); + f /= 0.5; + } + parts.push(`atempo=${f}`); + return parts.join(","); +} + +export interface AudioOutput { + ext: string; + contentType: string; + encodeArgs: string[]; +} + +const AUDIO_OUTPUTS: Record = { + ".mp3": { + ext: ".mp3", + contentType: "audio/mpeg", + encodeArgs: ["-c:a", "libmp3lame", "-b:a", "192k"], + }, + ".wav": { ext: ".wav", contentType: "audio/wav", encodeArgs: ["-c:a", "pcm_s16le"] }, + ".ogg": { + ext: ".ogg", + contentType: "audio/ogg", + encodeArgs: ["-c:a", "libvorbis", "-b:a", "192k"], + }, + ".opus": { + ext: ".opus", + contentType: "audio/opus", + encodeArgs: ["-c:a", "libopus", "-b:a", "128k"], + }, + ".flac": { ext: ".flac", contentType: "audio/flac", encodeArgs: ["-c:a", "flac"] }, + ".m4a": { ext: ".m4a", contentType: "audio/mp4", encodeArgs: ["-c:a", "aac", "-b:a", "192k"] }, + ".aac": { ext: ".m4a", contentType: "audio/mp4", encodeArgs: ["-c:a", "aac", "-b:a", "192k"] }, + ".aiff": { ext: ".aiff", contentType: "audio/aiff", encodeArgs: ["-c:a", "pcm_s16be"] }, +}; + +/** + * Encoder selection for filtered (re-encoded) audio outputs, keyed by the + * SOURCE extension. Decode-only sources (wma/amr/ape/...) fall back to mp3. + */ +export function audioOutputFor(srcExt: string): AudioOutput { + return AUDIO_OUTPUTS[srcExt.toLowerCase()] ?? AUDIO_OUTPUTS[".mp3"]; +} + export interface MediaRunResult { outPath: string; durationS: number | null; @@ -57,9 +106,9 @@ export async function runFfmpegWithProgress( args: string[], durationS: number | null, opts: { timeoutMs?: number } = {}, -): Promise { +): Promise { ctx.report(5, "Preparing"); - await runFfmpeg(args, { + return runFfmpeg(args, { signal: ctx.signal, timeoutMs: opts.timeoutMs ?? 30 * 60_000, onProgress: (p) => { diff --git a/apps/api/src/routes/tool-factory.ts b/apps/api/src/routes/tool-factory.ts index e7f14f4a..7df69020 100644 --- a/apps/api/src/routes/tool-factory.ts +++ b/apps/api/src/routes/tool-factory.ts @@ -209,12 +209,15 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig let clientJobId: string | null = null; let fileCount = 0; const received: ReceivedUpload[] = []; + // Track last part for post-loop field recovery + let lastPart: { fields?: Record } | undefined; // Parse multipart parts (file parts stream to object storage) try { const parts = request.parts(); for await (const part of parts) { + lastPart = part as { fields?: Record }; if (part.type === "file") { fileCount++; if (fileCount > maxInputs) { @@ -248,6 +251,31 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig } } } + + // The upstream parts iterator can terminate before yielding trailing + // fields, but busboy has already populated part.fields on every part. + if (lastPart?.fields) { + const recover = (name: string): string | null => { + const f = lastPart?.fields?.[name]; + const entry = Array.isArray(f) ? f[0] : f; + if (entry != null && typeof (entry as { value?: unknown }).value === "string") { + return (entry as { value: string }).value; + } + return null; + }; + if (settingsRaw === null) { + settingsRaw = recover("settings"); + } + if (fileId === null) { + fileId = recover("fileId"); + } + if (clientJobId === null) { + const raw = recover("clientJobId"); + if (raw !== null && raw.length > 0 && raw.length <= 128) { + clientJobId = raw; + } + } + } } catch (err) { return reply.status(400).send({ error: "Failed to parse multipart request", diff --git a/apps/api/src/routes/tools/audio-channels.ts b/apps/api/src/routes/tools/audio-channels.ts new file mode 100644 index 00000000..1851290a --- /dev/null +++ b/apps/api/src/routes/tools/audio-channels.ts @@ -0,0 +1,51 @@ +import { extname, join } from "node:path"; +import { probeMedia } from "@snapotter/media-engine"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { audioOutputFor, runFfmpegWithProgress, stageMediaInputs } from "../../lib/media-tool.js"; +import { InputValidationError } from "../../modality/contract.js"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({ + mode: z.enum(["stereo-to-mono", "mono-to-stereo", "swap"]), +}); + +export function registerAudioChannels(app: FastifyInstance) { + createToolRoute(app, { + toolId: "audio-channels", + settingsSchema, + process: async () => { + throw new Error("audio-channels is v2-only"); + }, + processV2: async (ctx) => { + const settings = settingsSchema.parse(ctx.settings); + const origExt = extname(ctx.inputs[0].filename) || ".mp3"; + const out = audioOutputFor(origExt); + const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, ""); + const outName = `${base}_channels${out.ext}`; + + const [inPath] = await stageMediaInputs(ctx); + const info = await probeMedia(inPath); + const ch = info.streams.find((s) => s.type === "audio")?.channels ?? 0; + + if ((settings.mode === "stereo-to-mono" || settings.mode === "swap") && ch < 2) { + throw new InputValidationError("This mode needs a stereo input", 422); + } + + const outPath = join(ctx.scratchDir, "media", outName); + let args: string[]; + + if (settings.mode === "stereo-to-mono") { + args = ["-i", inPath, "-ac", "1", ...out.encodeArgs, outPath]; + } else if (settings.mode === "mono-to-stereo") { + args = ["-i", inPath, "-ac", "2", ...out.encodeArgs, outPath]; + } else { + // swap + args = ["-i", inPath, "-af", "pan=stereo|c0=c1|c1=c0", ...out.encodeArgs, outPath]; + } + + await runFfmpegWithProgress(ctx, args, info.durationS); + return { scratchPath: outPath, filename: outName, contentType: out.contentType }; + }, + }); +} diff --git a/apps/api/src/routes/tools/audio-metadata.ts b/apps/api/src/routes/tools/audio-metadata.ts new file mode 100644 index 00000000..e5fd839f --- /dev/null +++ b/apps/api/src/routes/tools/audio-metadata.ts @@ -0,0 +1,67 @@ +import { extname, join } from "node:path"; +import { probeMedia } from "@snapotter/media-engine"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { audioContentType, runFfmpegWithProgress, stageMediaInputs } from "../../lib/media-tool.js"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({ + strip: z.boolean().default(false), + title: z.string().max(500).optional(), + artist: z.string().max(500).optional(), + album: z.string().max(500).optional(), +}); + +export function registerAudioMetadata(app: FastifyInstance) { + createToolRoute(app, { + toolId: "audio-metadata", + settingsSchema, + process: async () => { + throw new Error("audio-metadata is v2-only"); + }, + processV2: async (ctx) => { + const settings = settingsSchema.parse(ctx.settings); + const srcExt = extname(ctx.inputs[0].filename) || ".mp3"; + const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, ""); + const outName = `${base}_metadata${srcExt}`; + const contentType = audioContentType(srcExt); + + const [inPath] = await stageMediaInputs(ctx); + const info = await probeMedia(inPath); + + const args = ["-i", inPath]; + if (settings.strip) { + args.push("-map_metadata", "-1"); + } + for (const [key, value] of [ + ["title", settings.title], + ["artist", settings.artist], + ["album", settings.album], + ] as const) { + if (value !== undefined) { + args.push("-metadata", `${key}=${value}`); + } + } + args.push("-c", "copy"); + + const outPath = join(ctx.scratchDir, "media", outName); + args.push(outPath); + + await runFfmpegWithProgress(ctx, args, info.durationS); + + return { + scratchPath: outPath, + filename: outName, + contentType, + resultPayload: { + metadata: { + container: info.container, + durationS: info.durationS, + bitrateKbps: info.bitrateKbps, + tags: info.tags ?? {}, + }, + }, + }; + }, + }); +} diff --git a/apps/api/src/routes/tools/audio-speed.ts b/apps/api/src/routes/tools/audio-speed.ts new file mode 100644 index 00000000..5e959739 --- /dev/null +++ b/apps/api/src/routes/tools/audio-speed.ts @@ -0,0 +1,31 @@ +import { extname } from "node:path"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { audioOutputFor, buildAtempoChain, runMediaTool } from "../../lib/media-tool.js"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({ + factor: z.number().min(0.25).max(4).default(1.5), +}); + +export function registerAudioSpeed(app: FastifyInstance) { + createToolRoute(app, { + toolId: "audio-speed", + settingsSchema, + process: async () => { + throw new Error("audio-speed is v2-only"); + }, + processV2: async (ctx) => { + const settings = settingsSchema.parse(ctx.settings); + const origExt = extname(ctx.inputs[0].filename) || ".mp3"; + const out = audioOutputFor(origExt); + const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, ""); + const outName = `${base}_speed${out.ext}`; + + const { outPath } = await runMediaTool(ctx, outName, (inPath, outPath) => { + return ["-i", inPath, "-af", buildAtempoChain(settings.factor), ...out.encodeArgs, outPath]; + }); + return { scratchPath: outPath, filename: outName, contentType: out.contentType }; + }, + }); +} diff --git a/apps/api/src/routes/tools/fade-audio.ts b/apps/api/src/routes/tools/fade-audio.ts new file mode 100644 index 00000000..1a83042a --- /dev/null +++ b/apps/api/src/routes/tools/fade-audio.ts @@ -0,0 +1,62 @@ +import { extname, join } from "node:path"; +import { probeMedia } from "@snapotter/media-engine"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { audioOutputFor, runFfmpegWithProgress, stageMediaInputs } from "../../lib/media-tool.js"; +import { InputValidationError } from "../../modality/contract.js"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z + .object({ + fadeInS: z.number().min(0).max(30).default(1), + fadeOutS: z.number().min(0).max(30).default(1), + }) + .refine((s) => s.fadeInS > 0 || s.fadeOutS > 0, { + message: "Set a fade-in or fade-out", + }); + +export function registerFadeAudio(app: FastifyInstance) { + createToolRoute(app, { + toolId: "fade-audio", + settingsSchema, + process: async () => { + throw new Error("fade-audio is v2-only"); + }, + processV2: async (ctx) => { + const settings = settingsSchema.parse(ctx.settings); + const origExt = extname(ctx.inputs[0].filename) || ".mp3"; + const out = audioOutputFor(origExt); + const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, ""); + const outName = `${base}_fade${out.ext}`; + + const [inPath] = await stageMediaInputs(ctx); + const info = await probeMedia(inPath); + const d = info.durationS ?? 0; + + if ((d === 0 || info.durationS === null) && settings.fadeOutS > 0) { + throw new InputValidationError("Could not determine audio duration for fade-out"); + } + + const fin = Math.min(settings.fadeInS, d); + const fout = Math.min(settings.fadeOutS, d); + + const parts: string[] = []; + if (fin > 0) { + parts.push(`afade=t=in:st=0:d=${fin}`); + } + if (fout > 0) { + parts.push(`afade=t=out:st=${Math.max(0, d - fout)}:d=${fout}`); + } + const chain = parts.join(","); + + const outPath = join(ctx.scratchDir, "media", outName); + await runFfmpegWithProgress( + ctx, + ["-i", inPath, "-af", chain, ...out.encodeArgs, outPath], + info.durationS, + ); + + return { scratchPath: outPath, filename: outName, contentType: out.contentType }; + }, + }); +} diff --git a/apps/api/src/routes/tools/index.ts b/apps/api/src/routes/tools/index.ts index 1dd65ae6..50815a62 100644 --- a/apps/api/src/routes/tools/index.ts +++ b/apps/api/src/routes/tools/index.ts @@ -5,6 +5,9 @@ import { db, schema } from "../../db/index.js"; import { registerColorAdjustments } from "./adjust-colors.js"; import { registerAiCanvasExpand } from "./ai-canvas-expand.js"; import { registerAspectPad } from "./aspect-pad.js"; +import { registerAudioChannels } from "./audio-channels.js"; +import { registerAudioMetadata } from "./audio-metadata.js"; +import { registerAudioSpeed } from "./audio-speed.js"; import { registerBarcodeRead } from "./barcode-read.js"; import { registerBeautify } from "./beautify.js"; import { registerBlurFaces } from "./blur-faces.js"; @@ -39,6 +42,7 @@ import { registerEraseObject } from "./erase-object.js"; import { registerExtractAudio } from "./extract-audio.js"; import { registerExtractPages } from "./extract-pages.js"; import { registerExtractSubtitles } from "./extract-subtitles.js"; +import { registerFadeAudio } from "./fade-audio.js"; import { registerFavicon } from "./favicon.js"; import { registerFindDuplicates } from "./find-duplicates.js"; import { registerFlattenPdf } from "./flatten-pdf.js"; @@ -56,10 +60,13 @@ import { registerJsonXml } from "./json-xml.js"; import { registerLinearizePdf } from "./linearize-pdf.js"; import { registerMarkdownToPdf } from "./markdown-to-pdf.js"; import { registerMemeGenerator } from "./meme-generator.js"; +import { registerMergeAudio } from "./merge-audio.js"; import { registerMergePdf } from "./merge-pdf.js"; import { registerMergeVideos } from "./merge-videos.js"; import { registerMuteVideo } from "./mute-video.js"; +import { registerNoiseReduction } from "./noise-reduction.js"; import { registerNoiseRemoval } from "./noise-removal.js"; +import { registerNormalizeAudio } from "./normalize-audio.js"; import { registerNupPdf } from "./nup-pdf.js"; import { registerOcr } from "./ocr.js"; import { registerOptimizeForWeb } from "./optimize-for-web.js"; @@ -71,6 +78,7 @@ import { registerPdfToImage } from "./pdf-to-image.js"; import { registerPdfToText } from "./pdf-to-text.js"; import { registerPdfToWord } from "./pdf-to-word.js"; import { registerPdfaConvert } from "./pdfa-convert.js"; +import { registerPitchShift } from "./pitch-shift.js"; import { registerProtectPdf } from "./protect-pdf.js"; import { registerQrGenerate } from "./qr-generate.js"; import { registerRedEyeRemoval } from "./red-eye-removal.js"; @@ -83,13 +91,17 @@ import { registerReplaceColor } from "./replace-color.js"; import { registerResize } from "./resize.js"; import { registerResizeVideo } from "./resize-video.js"; import { registerRestorePhoto } from "./restore-photo.js"; +import { registerReverseAudio } from "./reverse-audio.js"; import { registerReverseVideo } from "./reverse-video.js"; +import { registerRingtoneMaker } from "./ringtone-maker.js"; import { registerRotate } from "./rotate.js"; import { registerRotatePdf } from "./rotate-pdf.js"; import { registerRotateVideo } from "./rotate-video.js"; import { registerSharpening } from "./sharpening.js"; +import { registerSilenceRemoval } from "./silence-removal.js"; import { registerSmartCrop } from "./smart-crop.js"; import { registerSplit } from "./split.js"; +import { registerSplitAudio } from "./split-audio.js"; import { registerSplitCsv } from "./split-csv.js"; import { registerSplitPdf } from "./split-pdf.js"; import { registerStabilizeVideo } from "./stabilize-video.js"; @@ -110,10 +122,12 @@ import { registerVideoSpeed } from "./video-speed.js"; import { registerVideoToFrames } from "./video-to-frames.js"; import { registerVideoToGif } from "./video-to-gif.js"; import { registerVideoToWebp } from "./video-to-webp.js"; +import { registerVolumeAdjust } from "./volume-adjust.js"; import { registerWatermarkImage } from "./watermark-image.js"; import { registerWatermarkPdf } from "./watermark-pdf.js"; import { registerWatermarkText } from "./watermark-text.js"; import { registerWatermarkVideo } from "./watermark-video.js"; +import { registerWaveformImage } from "./waveform-image.js"; import { registerWordToPdf } from "./word-to-pdf.js"; /** @@ -229,9 +243,23 @@ export async function registerToolRoutes(app: FastifyInstance): Promise { { id: "watermark-video", register: registerWatermarkVideo }, // Audio + { id: "audio-channels", register: registerAudioChannels }, + { id: "audio-metadata", register: registerAudioMetadata }, + { id: "audio-speed", register: registerAudioSpeed }, { id: "convert-audio", register: registerConvertAudio }, - { id: "trim-audio", register: registerTrimAudio }, { id: "extract-audio", register: registerExtractAudio }, + { id: "fade-audio", register: registerFadeAudio }, + { id: "merge-audio", register: registerMergeAudio }, + { id: "noise-reduction", register: registerNoiseReduction }, + { id: "normalize-audio", register: registerNormalizeAudio }, + { id: "pitch-shift", register: registerPitchShift }, + { id: "reverse-audio", register: registerReverseAudio }, + { id: "ringtone-maker", register: registerRingtoneMaker }, + { id: "silence-removal", register: registerSilenceRemoval }, + { id: "split-audio", register: registerSplitAudio }, + { id: "trim-audio", register: registerTrimAudio }, + { id: "volume-adjust", register: registerVolumeAdjust }, + { id: "waveform-image", register: registerWaveformImage }, // PDF & Documents { id: "merge-pdf", register: registerMergePdf }, diff --git a/apps/api/src/routes/tools/merge-audio.ts b/apps/api/src/routes/tools/merge-audio.ts new file mode 100644 index 00000000..f110a9fa --- /dev/null +++ b/apps/api/src/routes/tools/merge-audio.ts @@ -0,0 +1,85 @@ +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { probeMedia } from "@snapotter/media-engine"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { runFfmpegWithProgress, stageMediaInputs } from "../../lib/media-tool.js"; +import { InputValidationError } from "../../modality/contract.js"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({ + format: z.enum(["mp3", "wav", "flac", "m4a"]).default("mp3"), +}); + +const CONTENT_TYPES: Record = { + mp3: "audio/mpeg", + wav: "audio/wav", + flac: "audio/flac", + m4a: "audio/mp4", +}; + +const ENCODERS: Record = { + mp3: ["-c:a", "libmp3lame", "-b:a", "192k"], + wav: ["-c:a", "pcm_s16le"], + flac: ["-c:a", "flac"], + m4a: ["-c:a", "aac", "-b:a", "192k"], +}; + +export function registerMergeAudio(app: FastifyInstance) { + createToolRoute(app, { + toolId: "merge-audio", + maxInputs: 10, + settingsSchema, + process: async () => { + throw new Error("merge-audio is v2-only"); + }, + processV2: async (ctx) => { + if (ctx.inputs.length < 2) { + throw new InputValidationError("Merging needs at least two audio files"); + } + + const settings = settingsSchema.parse(ctx.settings); + const paths = await stageMediaInputs(ctx); + + // Probe each for total duration (progress mapping) + let totalS = 0; + for (const p of paths) { + const info = await probeMedia(p); + totalS += info.durationS ?? 0; + } + + // Build per-input normalize chains + audio-only concat + const parts: string[] = []; + const refs: string[] = []; + for (let i = 0; i < paths.length; i++) { + parts.push( + `[${i}:a]aresample=44100,aformat=sample_fmts=fltp:channel_layouts=stereo[a${i}]`, + ); + refs.push(`[a${i}]`); + } + const filter = `${parts.join(";")};\n${refs.join("")}concat=n=${paths.length}:v=0:a=1[a]`; + + const dir = join(ctx.scratchDir, "media"); + await mkdir(dir, { recursive: true }); + const outPath = join(dir, `merged.${settings.format}`); + + const args = [ + ...paths.flatMap((p) => ["-i", p]), + "-filter_complex", + filter, + "-map", + "[a]", + ...ENCODERS[settings.format], + outPath, + ]; + + await runFfmpegWithProgress(ctx, args, totalS || null); + + return { + scratchPath: outPath, + filename: `merged.${settings.format}`, + contentType: CONTENT_TYPES[settings.format], + }; + }, + }); +} diff --git a/apps/api/src/routes/tools/noise-reduction.ts b/apps/api/src/routes/tools/noise-reduction.ts new file mode 100644 index 00000000..8ac3b84a --- /dev/null +++ b/apps/api/src/routes/tools/noise-reduction.ts @@ -0,0 +1,40 @@ +import { extname } from "node:path"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { audioOutputFor, runMediaTool } from "../../lib/media-tool.js"; +import { createToolRoute } from "../tool-factory.js"; + +const NR_MAP: Record = { + light: 6, + medium: 12, + strong: 24, +}; + +const settingsSchema = z.object({ + strength: z.enum(["light", "medium", "strong"]).default("medium"), +}); + +export function registerNoiseReduction(app: FastifyInstance) { + createToolRoute(app, { + toolId: "noise-reduction", + settingsSchema, + process: async () => { + throw new Error("noise-reduction is v2-only"); + }, + processV2: async (ctx) => { + const settings = settingsSchema.parse(ctx.settings); + const origExt = extname(ctx.inputs[0].filename) || ".mp3"; + const out = audioOutputFor(origExt); + const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, ""); + const outName = `${base}_denoised${out.ext}`; + + const nr = NR_MAP[settings.strength]; + const chain = `afftdn=nr=${nr}:nf=-50`; + + const { outPath } = await runMediaTool(ctx, outName, (inPath, outPath) => { + return ["-i", inPath, "-af", chain, ...out.encodeArgs, outPath]; + }); + return { scratchPath: outPath, filename: outName, contentType: out.contentType }; + }, + }); +} diff --git a/apps/api/src/routes/tools/normalize-audio.ts b/apps/api/src/routes/tools/normalize-audio.ts new file mode 100644 index 00000000..3a690f25 --- /dev/null +++ b/apps/api/src/routes/tools/normalize-audio.ts @@ -0,0 +1,28 @@ +import { extname } from "node:path"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { audioOutputFor, runMediaTool } from "../../lib/media-tool.js"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({}); + +export function registerNormalizeAudio(app: FastifyInstance) { + createToolRoute(app, { + toolId: "normalize-audio", + settingsSchema, + process: async () => { + throw new Error("normalize-audio is v2-only"); + }, + processV2: async (ctx) => { + const origExt = extname(ctx.inputs[0].filename) || ".mp3"; + const out = audioOutputFor(origExt); + const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, ""); + const outName = `${base}_normalized${out.ext}`; + + const { outPath } = await runMediaTool(ctx, outName, (inPath, outPath) => { + return ["-i", inPath, "-af", "loudnorm=I=-16:TP=-1.5:LRA=11", ...out.encodeArgs, outPath]; + }); + return { scratchPath: outPath, filename: outName, contentType: out.contentType }; + }, + }); +} diff --git a/apps/api/src/routes/tools/pitch-shift.ts b/apps/api/src/routes/tools/pitch-shift.ts new file mode 100644 index 00000000..2e131cdb --- /dev/null +++ b/apps/api/src/routes/tools/pitch-shift.ts @@ -0,0 +1,35 @@ +import { extname } from "node:path"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { audioOutputFor, runMediaTool } from "../../lib/media-tool.js"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z + .object({ + semitones: z.number().int().min(-12).max(12).default(3), + }) + .refine((s) => s.semitones !== 0, { message: "Semitones must be nonzero" }); + +export function registerPitchShift(app: FastifyInstance) { + createToolRoute(app, { + toolId: "pitch-shift", + settingsSchema, + process: async () => { + throw new Error("pitch-shift is v2-only"); + }, + processV2: async (ctx) => { + const settings = settingsSchema.parse(ctx.settings); + const origExt = extname(ctx.inputs[0].filename) || ".mp3"; + const out = audioOutputFor(origExt); + const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, ""); + const outName = `${base}_pitch${out.ext}`; + + const ratio = (2 ** (settings.semitones / 12)).toFixed(6); + + const { outPath } = await runMediaTool(ctx, outName, (inPath, outPath) => { + return ["-i", inPath, "-af", `rubberband=pitch=${ratio}`, ...out.encodeArgs, outPath]; + }); + return { scratchPath: outPath, filename: outName, contentType: out.contentType }; + }, + }); +} diff --git a/apps/api/src/routes/tools/reverse-audio.ts b/apps/api/src/routes/tools/reverse-audio.ts new file mode 100644 index 00000000..4efe5f61 --- /dev/null +++ b/apps/api/src/routes/tools/reverse-audio.ts @@ -0,0 +1,28 @@ +import { extname } from "node:path"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { audioOutputFor, runMediaTool } from "../../lib/media-tool.js"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({}); + +export function registerReverseAudio(app: FastifyInstance) { + createToolRoute(app, { + toolId: "reverse-audio", + settingsSchema, + process: async () => { + throw new Error("reverse-audio is v2-only"); + }, + processV2: async (ctx) => { + const origExt = extname(ctx.inputs[0].filename) || ".mp3"; + const out = audioOutputFor(origExt); + const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, ""); + const outName = `${base}_reversed${out.ext}`; + + const { outPath } = await runMediaTool(ctx, outName, (inPath, outPath) => { + return ["-i", inPath, "-af", "areverse", ...out.encodeArgs, outPath]; + }); + return { scratchPath: outPath, filename: outName, contentType: out.contentType }; + }, + }); +} diff --git a/apps/api/src/routes/tools/ringtone-maker.ts b/apps/api/src/routes/tools/ringtone-maker.ts new file mode 100644 index 00000000..f8732828 --- /dev/null +++ b/apps/api/src/routes/tools/ringtone-maker.ts @@ -0,0 +1,58 @@ +import { join } from "node:path"; +import { probeMedia } from "@snapotter/media-engine"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { runFfmpegWithProgress, stageMediaInputs } from "../../lib/media-tool.js"; +import { InputValidationError } from "../../modality/contract.js"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({ + startS: z.number().min(0).default(0), + durationS: z.number().min(1).max(30).default(30), +}); + +export function registerRingtoneMaker(app: FastifyInstance) { + createToolRoute(app, { + toolId: "ringtone-maker", + settingsSchema, + process: async () => { + throw new Error("ringtone-maker is v2-only"); + }, + processV2: async (ctx) => { + const settings = settingsSchema.parse(ctx.settings); + const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, ""); + const outName = `${base}.m4r`; + + const [inPath] = await stageMediaInputs(ctx); + const info = await probeMedia(inPath); + + if (settings.startS >= (info.durationS ?? Infinity)) { + throw new InputValidationError("Start is beyond the end of the audio"); + } + + const outPath = join(ctx.scratchDir, "media", outName); + await runFfmpegWithProgress( + ctx, + [ + "-ss", + String(settings.startS), + "-t", + String(settings.durationS), + "-i", + inPath, + "-vn", + "-c:a", + "aac", + "-b:a", + "192k", + "-f", + "ipod", + outPath, + ], + info.durationS, + ); + + return { scratchPath: outPath, filename: outName, contentType: "audio/mp4" }; + }, + }); +} diff --git a/apps/api/src/routes/tools/silence-removal.ts b/apps/api/src/routes/tools/silence-removal.ts new file mode 100644 index 00000000..d3e94c41 --- /dev/null +++ b/apps/api/src/routes/tools/silence-removal.ts @@ -0,0 +1,38 @@ +import { extname } from "node:path"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { audioOutputFor, runMediaTool } from "../../lib/media-tool.js"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({ + thresholdDb: z.number().min(-80).max(-20).default(-50), + minSilenceS: z.number().min(0.1).max(5).default(0.5), +}); + +export function registerSilenceRemoval(app: FastifyInstance) { + createToolRoute(app, { + toolId: "silence-removal", + settingsSchema, + process: async () => { + throw new Error("silence-removal is v2-only"); + }, + processV2: async (ctx) => { + const settings = settingsSchema.parse(ctx.settings); + const origExt = extname(ctx.inputs[0].filename) || ".mp3"; + const out = audioOutputFor(origExt); + const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, ""); + const outName = `${base}_nosilence${out.ext}`; + + const t = settings.thresholdDb; + const d = settings.minSilenceS; + const chain = + `silenceremove=start_periods=1:start_threshold=${t}dB:start_silence=0.1` + + `:stop_periods=-1:stop_threshold=${t}dB:stop_duration=${d}`; + + const { outPath } = await runMediaTool(ctx, outName, (inPath, outPath) => { + return ["-i", inPath, "-af", chain, ...out.encodeArgs, outPath]; + }); + return { scratchPath: outPath, filename: outName, contentType: out.contentType }; + }, + }); +} diff --git a/apps/api/src/routes/tools/split-audio.ts b/apps/api/src/routes/tools/split-audio.ts new file mode 100644 index 00000000..6566cb9a --- /dev/null +++ b/apps/api/src/routes/tools/split-audio.ts @@ -0,0 +1,145 @@ +import { createWriteStream } from "node:fs"; +import { mkdir, readdir } from "node:fs/promises"; +import { extname, join } from "node:path"; +import { probeMedia, runFfmpeg } from "@snapotter/media-engine"; +import archiver from "archiver"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { stageMediaInputs } from "../../lib/media-tool.js"; +import { InputValidationError } from "../../modality/contract.js"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({ + mode: z.enum(["time", "parts", "silence"]).default("time"), + segmentS: z.number().min(1).max(3600).default(60), + parts: z.number().int().min(2).max(20).default(2), + thresholdDb: z.number().min(-80).max(-20).default(-40), + minSilenceS: z.number().min(0.1).max(10).default(0.3), +}); + +export function registerSplitAudio(app: FastifyInstance) { + createToolRoute(app, { + toolId: "split-audio", + settingsSchema, + process: async () => { + throw new Error("split-audio is v2-only"); + }, + processV2: async (ctx) => { + const settings = settingsSchema.parse(ctx.settings); + const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, ""); + const srcExt = extname(ctx.inputs[0].filename) || ".mp3"; + + const [inPath] = await stageMediaInputs(ctx); + const info = await probeMedia(inPath); + const duration = info.durationS; + + const framesDir = join(ctx.scratchDir, "media", "parts"); + await mkdir(framesDir, { recursive: true }); + + if (settings.mode === "time" || settings.mode === "parts") { + let segLen: number; + if (settings.mode === "parts") { + if (duration === null || duration === undefined) { + throw new InputValidationError("Could not determine audio duration"); + } + segLen = Math.max(0.1, duration / settings.parts + 0.001); + } else { + segLen = settings.segmentS; + } + + ctx.report(5, "Splitting audio"); + await runFfmpeg( + [ + "-i", + inPath, + "-f", + "segment", + "-segment_time", + String(segLen), + "-c", + "copy", + join(framesDir, `part-%03d${srcExt}`), + ], + { signal: ctx.signal, timeoutMs: 10 * 60_000 }, + ); + } else { + // silence mode: pass 1 - detect silence regions + ctx.report(5, "Detecting silence"); + const nullOut = join(ctx.scratchDir, "media", "null.out"); + const stderr = await runFfmpeg( + [ + "-i", + inPath, + "-af", + `silencedetect=noise=${settings.thresholdDb}dB:d=${settings.minSilenceS}`, + "-f", + "null", + nullOut, + ], + { signal: ctx.signal, timeoutMs: 10 * 60_000 }, + ); + + // Parse silence_start / silence_end pairs and compute midpoints + const starts = [...stderr.matchAll(/silence_start:\s*([\d.]+)/g)].map((m) => Number(m[1])); + const ends = [...stderr.matchAll(/silence_end:\s*([\d.]+)/g)].map((m) => Number(m[1])); + const cuts: number[] = []; + for (let i = 0; i < Math.min(starts.length, ends.length); i++) { + cuts.push((starts[i] + ends[i]) / 2); + } + if (cuts.length === 0) { + throw new InputValidationError("No silence found to split at"); + } + + // Build segment boundaries + const d = duration ?? 0; + const boundaries = [0, ...cuts, d]; + const total = boundaries.length - 1; + + for (let seg = 0; seg < total; seg++) { + const from = boundaries[seg]; + const to = boundaries[seg + 1]; + const partPath = join(framesDir, `part-${String(seg).padStart(3, "0")}${srcExt}`); + ctx.report( + Math.min(90, 10 + Math.round(((seg + 1) / total) * 80)), + `Extracting segment ${seg + 1}/${total}`, + ); + await runFfmpeg( + ["-ss", String(from), "-to", String(to), "-i", inPath, "-c", "copy", partPath], + { + signal: ctx.signal, + timeoutMs: 60_000, + }, + ); + } + } + + // Collect produced parts + const files = (await readdir(framesDir)).filter((f) => f.endsWith(srcExt)); + files.sort(); + if (files.length < 2) { + throw new InputValidationError("Nothing to split: output would be a single segment"); + } + + // Zip parts (same pattern as video-to-frames) + ctx.report(92, "Creating archive"); + const zipPath = join(ctx.scratchDir, "media", `${base}_parts.zip`); + await new Promise((resolve, reject) => { + const output = createWriteStream(zipPath); + const archive = archiver("zip", { zlib: { level: 5 } }); + output.on("close", () => resolve()); + archive.on("error", (err: Error) => reject(err)); + archive.pipe(output); + for (const f of files) { + archive.file(join(framesDir, f), { name: f }); + } + void archive.finalize(); + }); + + return { + scratchPath: zipPath, + filename: `${base}_parts.zip`, + contentType: "application/zip", + }; + }, + }); +} diff --git a/apps/api/src/routes/tools/video-speed.ts b/apps/api/src/routes/tools/video-speed.ts index fc0c5520..5c817cd7 100644 --- a/apps/api/src/routes/tools/video-speed.ts +++ b/apps/api/src/routes/tools/video-speed.ts @@ -2,7 +2,12 @@ import { extname, join } from "node:path"; import { probeMedia, resolveEncoder } from "@snapotter/media-engine"; import type { FastifyInstance } from "fastify"; import { z } from "zod"; -import { runFfmpegWithProgress, stageMediaInputs, videoContentType } from "../../lib/media-tool.js"; +import { + buildAtempoChain, + runFfmpegWithProgress, + stageMediaInputs, + videoContentType, +} from "../../lib/media-tool.js"; import { createToolRoute } from "../tool-factory.js"; const settingsSchema = z.object({ @@ -10,18 +15,6 @@ const settingsSchema = z.object({ keepPitch: z.boolean().default(true), }); -/** atempo only accepts 0.5..100; chain factors of 0.5 until in range. */ -export function buildAtempoChain(factor: number): string { - const parts: string[] = []; - let f = factor; - while (f < 0.5) { - parts.push("atempo=0.5"); - f /= 0.5; - } - parts.push(`atempo=${f}`); - return parts.join(","); -} - export function registerVideoSpeed(app: FastifyInstance) { createToolRoute(app, { toolId: "video-speed", diff --git a/apps/api/src/routes/tools/volume-adjust.ts b/apps/api/src/routes/tools/volume-adjust.ts new file mode 100644 index 00000000..2e4ae299 --- /dev/null +++ b/apps/api/src/routes/tools/volume-adjust.ts @@ -0,0 +1,31 @@ +import { extname } from "node:path"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { audioOutputFor, runMediaTool } from "../../lib/media-tool.js"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({ + gainDb: z.number().min(-30).max(30).default(3), +}); + +export function registerVolumeAdjust(app: FastifyInstance) { + createToolRoute(app, { + toolId: "volume-adjust", + settingsSchema, + process: async () => { + throw new Error("volume-adjust is v2-only"); + }, + processV2: async (ctx) => { + const settings = settingsSchema.parse(ctx.settings); + const origExt = extname(ctx.inputs[0].filename) || ".mp3"; + const out = audioOutputFor(origExt); + const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, ""); + const outName = `${base}_volume${out.ext}`; + + const { outPath } = await runMediaTool(ctx, outName, (inPath, outPath) => { + return ["-i", inPath, "-af", `volume=${settings.gainDb}dB`, ...out.encodeArgs, outPath]; + }); + return { scratchPath: outPath, filename: outName, contentType: out.contentType }; + }, + }); +} diff --git a/apps/api/src/routes/tools/waveform-image.ts b/apps/api/src/routes/tools/waveform-image.ts new file mode 100644 index 00000000..19a19a94 --- /dev/null +++ b/apps/api/src/routes/tools/waveform-image.ts @@ -0,0 +1,46 @@ +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { runMediaTool } from "../../lib/media-tool.js"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({ + width: z.number().int().min(256).max(3840).default(1024), + height: z.number().int().min(64).max(1080).default(256), + color: z + .string() + .regex(/^#[0-9a-fA-F]{6}$/) + .default("#4f46e5"), +}); + +export function registerWaveformImage(app: FastifyInstance) { + createToolRoute(app, { + toolId: "waveform-image", + settingsSchema, + process: async () => { + throw new Error("waveform-image is v2-only"); + }, + processV2: async (ctx) => { + const settings = settingsSchema.parse(ctx.settings); + const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, ""); + const outName = `${base}_waveform.png`; + + const c = settings.color.replace("#", "0x"); + const w = settings.width; + const h = settings.height; + + const { outPath } = await runMediaTool(ctx, outName, (inPath, outPath) => { + return [ + "-i", + inPath, + "-filter_complex", + `showwavespic=s=${w}x${h}:colors=${c}`, + "-frames:v", + "1", + outPath, + ]; + }); + + return { scratchPath: outPath, filename: outName, contentType: "image/png" }; + }, + }); +} diff --git a/apps/web/src/components/tools/audio-channels-settings.tsx b/apps/web/src/components/tools/audio-channels-settings.tsx new file mode 100644 index 00000000..5bf92097 --- /dev/null +++ b/apps/web/src/components/tools/audio-channels-settings.tsx @@ -0,0 +1,73 @@ +import { useState } from "react"; +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; +import { useFileStore } from "@/stores/file-store"; + +type ChannelMode = "stereo-to-mono" | "mono-to-stereo" | "swap"; + +export function AudioChannelsSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["audio-channels"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("audio-channels"); + + const [mode, setMode] = useState("mono-to-stereo"); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings = { mode }; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+
+ + +
+ + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/audio-metadata-settings.tsx b/apps/web/src/components/tools/audio-metadata-settings.tsx new file mode 100644 index 00000000..bf69351d --- /dev/null +++ b/apps/web/src/components/tools/audio-metadata-settings.tsx @@ -0,0 +1,113 @@ +import { useState } from "react"; +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; +import { useFileStore } from "@/stores/file-store"; + +export function AudioMetadataSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["audio-metadata"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("audio-metadata"); + + const [strip, setStrip] = useState(false); + const [title, setTitle] = useState(""); + const [artist, setArtist] = useState(""); + const [album, setAlbum] = useState(""); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings: Record = { strip }; + if (title) settings.title = title; + if (artist) settings.artist = artist; + if (album) settings.album = album; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+ + +
+ + setTitle(e.target.value)} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ +
+ + setArtist(e.target.value)} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ +
+ + setAlbum(e.target.value)} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/audio-speed-settings.tsx b/apps/web/src/components/tools/audio-speed-settings.tsx new file mode 100644 index 00000000..ddd682b8 --- /dev/null +++ b/apps/web/src/components/tools/audio-speed-settings.tsx @@ -0,0 +1,71 @@ +import { useState } from "react"; +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; +import { useFileStore } from "@/stores/file-store"; + +export function AudioSpeedSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["audio-speed"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("audio-speed"); + + const [factor, setFactor] = useState(1.5); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings = { factor }; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+
+ + setFactor(Number(e.target.value))} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/fade-audio-settings.tsx b/apps/web/src/components/tools/fade-audio-settings.tsx new file mode 100644 index 00000000..88cbd73e --- /dev/null +++ b/apps/web/src/components/tools/fade-audio-settings.tsx @@ -0,0 +1,88 @@ +import { useState } from "react"; +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; +import { useFileStore } from "@/stores/file-store"; + +export function FadeAudioSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["fade-audio"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("fade-audio"); + + const [fadeInS, setFadeInS] = useState(1); + const [fadeOutS, setFadeOutS] = useState(1); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings = { fadeInS, fadeOutS }; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+
+ + setFadeInS(Number(e.target.value))} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ +
+ + setFadeOutS(Number(e.target.value))} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/merge-audio-settings.tsx b/apps/web/src/components/tools/merge-audio-settings.tsx new file mode 100644 index 00000000..c93a4f80 --- /dev/null +++ b/apps/web/src/components/tools/merge-audio-settings.tsx @@ -0,0 +1,76 @@ +import { useState } from "react"; +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; +import { useFileStore } from "@/stores/file-store"; + +type AudioFormat = "mp3" | "wav" | "flac" | "m4a"; + +export function MergeAudioSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["merge-audio"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("merge-audio"); + + const [outFormat, setOutFormat] = useState("mp3"); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings = { format: outFormat }; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+
+ + +
+ +

{s["order-hint"]}

+ + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/noise-reduction-settings.tsx b/apps/web/src/components/tools/noise-reduction-settings.tsx new file mode 100644 index 00000000..8b070525 --- /dev/null +++ b/apps/web/src/components/tools/noise-reduction-settings.tsx @@ -0,0 +1,73 @@ +import { useState } from "react"; +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; +import { useFileStore } from "@/stores/file-store"; + +type Strength = "light" | "medium" | "strong"; + +export function NoiseReductionSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["noise-reduction"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("noise-reduction"); + + const [strength, setStrength] = useState("medium"); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings = { strength }; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+
+ + +
+ + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/normalize-audio-settings.tsx b/apps/web/src/components/tools/normalize-audio-settings.tsx new file mode 100644 index 00000000..46c1b3c8 --- /dev/null +++ b/apps/web/src/components/tools/normalize-audio-settings.tsx @@ -0,0 +1,54 @@ +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; +import { useFileStore } from "@/stores/file-store"; + +export function NormalizeAudioSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["normalize-audio"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("normalize-audio"); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings = {}; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+

{s.hint}

+ + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/pitch-shift-settings.tsx b/apps/web/src/components/tools/pitch-shift-settings.tsx new file mode 100644 index 00000000..57ae0a45 --- /dev/null +++ b/apps/web/src/components/tools/pitch-shift-settings.tsx @@ -0,0 +1,72 @@ +import { useState } from "react"; +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; +import { useFileStore } from "@/stores/file-store"; + +export function PitchShiftSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["pitch-shift"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("pitch-shift"); + + const [semitones, setSemitones] = useState(3); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings = { semitones }; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+
+ + setSemitones(Number(e.target.value))} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +

{s.hint}

+
+ + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/reverse-audio-settings.tsx b/apps/web/src/components/tools/reverse-audio-settings.tsx new file mode 100644 index 00000000..9f64236f --- /dev/null +++ b/apps/web/src/components/tools/reverse-audio-settings.tsx @@ -0,0 +1,52 @@ +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; +import { useFileStore } from "@/stores/file-store"; + +export function ReverseAudioSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["reverse-audio"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("reverse-audio"); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings = {}; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+ {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/ringtone-maker-settings.tsx b/apps/web/src/components/tools/ringtone-maker-settings.tsx new file mode 100644 index 00000000..be320af3 --- /dev/null +++ b/apps/web/src/components/tools/ringtone-maker-settings.tsx @@ -0,0 +1,88 @@ +import { useState } from "react"; +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; +import { useFileStore } from "@/stores/file-store"; + +export function RingtoneMakerSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["ringtone-maker"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("ringtone-maker"); + + const [startS, setStartS] = useState(0); + const [durationS, setDurationS] = useState(30); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings = { startS, durationS }; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+
+ + setStartS(Number(e.target.value))} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ +
+ + setDurationS(Number(e.target.value))} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +

{s["max-hint"]}

+
+ + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/silence-removal-settings.tsx b/apps/web/src/components/tools/silence-removal-settings.tsx new file mode 100644 index 00000000..c4fa77b9 --- /dev/null +++ b/apps/web/src/components/tools/silence-removal-settings.tsx @@ -0,0 +1,88 @@ +import { useState } from "react"; +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; +import { useFileStore } from "@/stores/file-store"; + +export function SilenceRemovalSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["silence-removal"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("silence-removal"); + + const [thresholdDb, setThresholdDb] = useState(-50); + const [minSilenceS, setMinSilenceS] = useState(0.5); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings = { thresholdDb, minSilenceS }; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+
+ + setThresholdDb(Number(e.target.value))} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ +
+ + setMinSilenceS(Number(e.target.value))} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/split-audio-settings.tsx b/apps/web/src/components/tools/split-audio-settings.tsx new file mode 100644 index 00000000..f86cc259 --- /dev/null +++ b/apps/web/src/components/tools/split-audio-settings.tsx @@ -0,0 +1,155 @@ +import { useState } from "react"; +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; +import { useFileStore } from "@/stores/file-store"; + +type SplitMode = "time" | "parts" | "silence"; + +export function SplitAudioSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["split-audio"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("split-audio"); + + const [mode, setMode] = useState("time"); + const [segmentS, setSegmentS] = useState(60); + const [parts, setParts] = useState(2); + const [thresholdDb, setThresholdDb] = useState(-40); + const [minSilenceS, setMinSilenceS] = useState(0.3); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings: Record = { mode }; + if (mode === "time") settings.segmentS = segmentS; + if (mode === "parts") settings.parts = parts; + if (mode === "silence") { + settings.thresholdDb = thresholdDb; + settings.minSilenceS = minSilenceS; + } + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+
+ + +
+ + {mode === "time" && ( +
+ + setSegmentS(Number(e.target.value))} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ )} + + {mode === "parts" && ( +
+ + setParts(Number(e.target.value))} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ )} + + {mode === "silence" && ( + <> +
+ + setThresholdDb(Number(e.target.value))} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ +
+ + setMinSilenceS(Number(e.target.value))} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ + )} + + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/volume-adjust-settings.tsx b/apps/web/src/components/tools/volume-adjust-settings.tsx new file mode 100644 index 00000000..88ed83dd --- /dev/null +++ b/apps/web/src/components/tools/volume-adjust-settings.tsx @@ -0,0 +1,71 @@ +import { useState } from "react"; +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; +import { useFileStore } from "@/stores/file-store"; + +export function VolumeAdjustSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["volume-adjust"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("volume-adjust"); + + const [gainDb, setGainDb] = useState(3); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings = { gainDb }; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+
+ + setGainDb(Number(e.target.value))} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/waveform-image-settings.tsx b/apps/web/src/components/tools/waveform-image-settings.tsx new file mode 100644 index 00000000..5e405117 --- /dev/null +++ b/apps/web/src/components/tools/waveform-image-settings.tsx @@ -0,0 +1,103 @@ +import { useState } from "react"; +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; +import { useFileStore } from "@/stores/file-store"; + +export function WaveformImageSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["waveform-image"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("waveform-image"); + + const [width, setWidth] = useState(1024); + const [height, setHeight] = useState(256); + const [color, setColor] = useState("#4f46e5"); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings = { width, height, color }; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+
+ + setWidth(Number(e.target.value))} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ +
+ + setHeight(Number(e.target.value))} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ +
+ + setColor(e.target.value)} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/lib/icon-map.ts b/apps/web/src/lib/icon-map.ts index e372d69a..8493d7dd 100644 --- a/apps/web/src/lib/icon-map.ts +++ b/apps/web/src/lib/icon-map.ts @@ -1,9 +1,12 @@ import type { LucideIcon } from "lucide-react"; import { + Activity, Anchor, AppWindow, Archive, AudioLines, + AudioWaveform, + BellRing, BookImage, BookOpen, Braces, @@ -57,6 +60,7 @@ import { MessageSquareText, MicVocal, Minimize2, + Music, Palette, PenLine, PenTool, @@ -88,6 +92,7 @@ import { Volume2, VolumeX, Wand, + Waves, Wrench, Zap, ZoomIn, @@ -96,10 +101,13 @@ import { // Only the icons actually used by tool definitions in @snapotter/shared constants. // Avoids `import * as icons from "lucide-react"` which pulls the entire 1000+ icon library. export const ICON_MAP: Record = { + Activity, Anchor, AppWindow, Archive, AudioLines, + AudioWaveform, + BellRing, BookImage, BookOpen, Braces, @@ -152,6 +160,7 @@ export const ICON_MAP: Record = { MessageSquareText, MicVocal, Minimize2, + Music, Palette, PenLine, PenTool, @@ -182,6 +191,7 @@ export const ICON_MAP: Record = { Volume2, VolumeX, Wand, + Waves, Wrench, Zap, ZoomIn, diff --git a/apps/web/src/lib/tool-display-modes.ts b/apps/web/src/lib/tool-display-modes.ts index 3af8ad6e..235670c2 100644 --- a/apps/web/src/lib/tool-display-modes.ts +++ b/apps/web/src/lib/tool-display-modes.ts @@ -121,9 +121,23 @@ export const TOOL_DISPLAY_MODES: Record = { "video-metadata": "no-comparison", // Audio tools + "audio-channels": "media-player", + "audio-metadata": "no-comparison", + "audio-speed": "media-player", "convert-audio": "media-player", - "trim-audio": "media-player", "extract-audio": "media-player", + "fade-audio": "media-player", + "merge-audio": "media-player", + "noise-reduction": "media-player", + "normalize-audio": "media-player", + "pitch-shift": "media-player", + "reverse-audio": "media-player", + "ringtone-maker": "media-player", + "silence-removal": "media-player", + "split-audio": "no-comparison", + "trim-audio": "media-player", + "volume-adjust": "media-player", + "waveform-image": "no-comparison", // PDF & Document tools "merge-pdf": "document", @@ -165,6 +179,7 @@ export const TOOL_DISPLAY_MODES: Record = { * Consumed by use-tool-processor; backend routes declare maxInputs. */ export const MULTI_FILE_TOOLS: ReadonlySet = new Set([ + "merge-audio", "merge-pdf", "merge-videos", "replace-audio", diff --git a/apps/web/src/lib/tool-registry.tsx b/apps/web/src/lib/tool-registry.tsx index 1c2b058c..ccddccd6 100644 --- a/apps/web/src/lib/tool-registry.tsx +++ b/apps/web/src/lib/tool-registry.tsx @@ -494,6 +494,76 @@ const ExtractAudioSettings = lazy(() => default: m.ExtractAudioSettings, })), ); +const AudioChannelsSettings = lazy(() => + import("@/components/tools/audio-channels-settings").then((m) => ({ + default: m.AudioChannelsSettings, + })), +); +const AudioMetadataSettings = lazy(() => + import("@/components/tools/audio-metadata-settings").then((m) => ({ + default: m.AudioMetadataSettings, + })), +); +const AudioSpeedSettings = lazy(() => + import("@/components/tools/audio-speed-settings").then((m) => ({ + default: m.AudioSpeedSettings, + })), +); +const FadeAudioSettings = lazy(() => + import("@/components/tools/fade-audio-settings").then((m) => ({ + default: m.FadeAudioSettings, + })), +); +const MergeAudioSettings = lazy(() => + import("@/components/tools/merge-audio-settings").then((m) => ({ + default: m.MergeAudioSettings, + })), +); +const NoiseReductionSettings = lazy(() => + import("@/components/tools/noise-reduction-settings").then((m) => ({ + default: m.NoiseReductionSettings, + })), +); +const NormalizeAudioSettings = lazy(() => + import("@/components/tools/normalize-audio-settings").then((m) => ({ + default: m.NormalizeAudioSettings, + })), +); +const PitchShiftSettings = lazy(() => + import("@/components/tools/pitch-shift-settings").then((m) => ({ + default: m.PitchShiftSettings, + })), +); +const ReverseAudioSettings = lazy(() => + import("@/components/tools/reverse-audio-settings").then((m) => ({ + default: m.ReverseAudioSettings, + })), +); +const RingtoneMakerSettings = lazy(() => + import("@/components/tools/ringtone-maker-settings").then((m) => ({ + default: m.RingtoneMakerSettings, + })), +); +const SilenceRemovalSettings = lazy(() => + import("@/components/tools/silence-removal-settings").then((m) => ({ + default: m.SilenceRemovalSettings, + })), +); +const SplitAudioSettings = lazy(() => + import("@/components/tools/split-audio-settings").then((m) => ({ + default: m.SplitAudioSettings, + })), +); +const VolumeAdjustSettings = lazy(() => + import("@/components/tools/volume-adjust-settings").then((m) => ({ + default: m.VolumeAdjustSettings, + })), +); +const WaveformImageSettings = lazy(() => + import("@/components/tools/waveform-image-settings").then((m) => ({ + default: m.WaveformImageSettings, + })), +); const MergePdfSettings = lazy(() => import("@/components/tools/merge-pdf-settings").then((m) => ({ default: m.MergePdfSettings, @@ -809,6 +879,22 @@ const ENTRY_CONFIG: ReadonlyArray<[string, RegistryEntryConfig]> = [ ["trim-audio", { accept: AUDIO_INPUTS.join(","), Settings: TrimAudioSettings }], ["extract-audio", { accept: VIDEO_INPUTS.join(","), Settings: ExtractAudioSettings }], + // Audio depth tools + ["audio-channels", { accept: AUDIO_INPUTS.join(","), Settings: AudioChannelsSettings }], + ["audio-metadata", { accept: AUDIO_INPUTS.join(","), Settings: AudioMetadataSettings }], + ["audio-speed", { accept: AUDIO_INPUTS.join(","), Settings: AudioSpeedSettings }], + ["fade-audio", { accept: AUDIO_INPUTS.join(","), Settings: FadeAudioSettings }], + ["merge-audio", { accept: AUDIO_INPUTS.join(","), Settings: MergeAudioSettings }], + ["noise-reduction", { accept: AUDIO_INPUTS.join(","), Settings: NoiseReductionSettings }], + ["normalize-audio", { accept: AUDIO_INPUTS.join(","), Settings: NormalizeAudioSettings }], + ["pitch-shift", { accept: AUDIO_INPUTS.join(","), Settings: PitchShiftSettings }], + ["reverse-audio", { accept: AUDIO_INPUTS.join(","), Settings: ReverseAudioSettings }], + ["ringtone-maker", { accept: AUDIO_INPUTS.join(","), Settings: RingtoneMakerSettings }], + ["silence-removal", { accept: AUDIO_INPUTS.join(","), Settings: SilenceRemovalSettings }], + ["split-audio", { accept: AUDIO_INPUTS.join(","), Settings: SplitAudioSettings }], + ["volume-adjust", { accept: AUDIO_INPUTS.join(","), Settings: VolumeAdjustSettings }], + ["waveform-image", { accept: AUDIO_INPUTS.join(","), Settings: WaveformImageSettings }], + // PDF & Document tools ["merge-pdf", { accept: ".pdf", Settings: MergePdfSettings }], ["split-pdf", { accept: ".pdf", Settings: SplitPdfSettings }], diff --git a/packages/media-engine/src/ffmpeg.ts b/packages/media-engine/src/ffmpeg.ts index a98a9460..c2fd1f1a 100644 --- a/packages/media-engine/src/ffmpeg.ts +++ b/packages/media-engine/src/ffmpeg.ts @@ -13,12 +13,14 @@ const STDERR_RING_MAX = 16 * 1024; /** * Runs ffmpeg with `-progress pipe:1` appended, parsing progress blocks from * stdout. Rejects with the tail of stderr on non-zero exit, timeout or abort. - * Output must go to a FILE path in args (no stdout piping of media data). + * Resolves with the captured stderr tail (filters like silencedetect report + * there). Output must go to a FILE path in args (no stdout piping of media + * data). */ -export async function runFfmpeg(args: string[], opts: RunFfmpegOptions = {}): Promise { +export async function runFfmpeg(args: string[], opts: RunFfmpegOptions = {}): Promise { const bin = resolveFfmpeg(); if (!bin) throw new Error("ffmpeg binary not found (set FFMPEG_PATH or install ffmpeg)"); - await new Promise((resolvePromise, reject) => { + return new Promise((resolvePromise, reject) => { const child = spawn(bin, ["-hide_banner", "-nostdin", "-y", ...args, "-progress", "pipe:1"], { stdio: ["ignore", "pipe", "pipe"], }); @@ -73,7 +75,7 @@ export async function runFfmpeg(args: string[], opts: RunFfmpegOptions = {}): Pr if (settled) return; settled = true; cleanup(); - if (code === 0) resolvePromise(); + if (code === 0) resolvePromise(stderrTail); else reject(new Error(`ffmpeg exited ${code ?? signal}: ${stderrTail.slice(-2000)}`)); }); }); diff --git a/packages/media-engine/src/ffprobe.ts b/packages/media-engine/src/ffprobe.ts index b550ad40..f93b0149 100644 --- a/packages/media-engine/src/ffprobe.ts +++ b/packages/media-engine/src/ffprobe.ts @@ -7,6 +7,7 @@ export interface MediaStreamInfo { width?: number; height?: number; sampleRate?: number; + channels?: number; } export interface MediaInfo { @@ -14,6 +15,7 @@ export interface MediaInfo { durationS: number | null; bitrateKbps: number | null; streams: MediaStreamInfo[]; + tags?: Record; } export interface ProbeOptions { @@ -70,17 +72,28 @@ export async function probeMedia(filePath: string, opts: ProbeOptions = {}): Pro }); }); const parsed = JSON.parse(stdout) as { - format?: { format_name?: string; duration?: string; bit_rate?: string }; + format?: { + format_name?: string; + duration?: string; + bit_rate?: string; + tags?: Record; + }; streams?: Array<{ codec_type?: string; codec_name?: string; width?: number; height?: number; sample_rate?: string; + channels?: number; }>; }; const duration = parsed.format?.duration ? Number(parsed.format.duration) : null; const bitRate = parsed.format?.bit_rate ? Number(parsed.format.bit_rate) : null; + const rawTags = (parsed.format?.tags ?? {}) as Record; + const tags: Record = {}; + for (const [k, v] of Object.entries(rawTags)) { + if (typeof v === "string" && v.length > 0) tags[k.toLowerCase()] = v; + } return { container: parsed.format?.format_name ?? "unknown", durationS: Number.isFinite(duration as number) ? (duration as number) : null, @@ -93,7 +106,9 @@ export async function probeMedia(filePath: string, opts: ProbeOptions = {}): Pro width: s.width, height: s.height, sampleRate: Number.isFinite(sr) && (sr as number) > 0 ? sr : undefined, + channels: typeof s.channels === "number" && s.channels > 0 ? s.channels : undefined, } as MediaStreamInfo; }), + tags: Object.keys(tags).length > 0 ? tags : undefined, }; } diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 728dea74..b9265d14 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -942,6 +942,160 @@ export const TOOLS: Tool[] = [ acceptedInputs: VIDEO_INPUTS, executionHint: "fast", }, + { + id: "volume-adjust", + name: "Volume Adjust", + description: "Increase or decrease audio volume by a fixed gain", + category: "audio", + icon: "Volume2", + route: "/volume-adjust", + modality: "audio", + acceptedInputs: AUDIO_INPUTS, + executionHint: "fast", + }, + { + id: "normalize-audio", + name: "Normalize Audio", + description: "Even out loudness to broadcast standard levels", + category: "audio", + icon: "AudioWaveform", + route: "/normalize-audio", + modality: "audio", + acceptedInputs: AUDIO_INPUTS, + executionHint: "fast", + }, + { + id: "fade-audio", + name: "Fade Audio", + description: "Add fade-in and fade-out effects to audio", + category: "audio", + icon: "Waves", + route: "/fade-audio", + modality: "audio", + acceptedInputs: AUDIO_INPUTS, + executionHint: "fast", + }, + { + id: "reverse-audio", + name: "Reverse Audio", + description: "Play audio backwards", + category: "audio", + icon: "Rewind", + route: "/reverse-audio", + modality: "audio", + acceptedInputs: AUDIO_INPUTS, + executionHint: "fast", + }, + { + id: "audio-speed", + name: "Audio Speed", + description: "Speed up or slow down audio playback", + category: "audio", + icon: "FastForward", + route: "/audio-speed", + modality: "audio", + acceptedInputs: AUDIO_INPUTS, + executionHint: "fast", + }, + { + id: "pitch-shift", + name: "Pitch Shift", + description: "Raise or lower pitch by semitones without changing speed", + category: "audio", + icon: "Music", + route: "/pitch-shift", + modality: "audio", + acceptedInputs: AUDIO_INPUTS, + executionHint: "fast", + }, + { + id: "audio-channels", + name: "Audio Channels", + description: "Convert between mono, stereo, or swap left and right channels", + category: "audio", + icon: "Split", + route: "/audio-channels", + modality: "audio", + acceptedInputs: AUDIO_INPUTS, + executionHint: "fast", + }, + { + id: "silence-removal", + name: "Silence Removal", + description: "Strip silent sections from audio", + category: "audio", + icon: "VolumeX", + route: "/silence-removal", + modality: "audio", + acceptedInputs: AUDIO_INPUTS, + executionHint: "fast", + }, + { + id: "noise-reduction", + name: "Noise Reduction", + description: "Reduce background noise with FFT-based denoising", + category: "audio", + icon: "Sparkles", + route: "/noise-reduction", + modality: "audio", + acceptedInputs: AUDIO_INPUTS, + executionHint: "fast", + }, + { + id: "merge-audio", + name: "Merge Audio", + description: "Combine multiple audio files into one", + category: "audio", + icon: "Combine", + route: "/merge-audio", + modality: "audio", + acceptedInputs: AUDIO_INPUTS, + executionHint: "fast", + }, + { + id: "split-audio", + name: "Split Audio", + description: "Split audio by time, equal parts, or silence detection", + category: "audio", + icon: "Scissors", + route: "/split-audio", + modality: "audio", + acceptedInputs: AUDIO_INPUTS, + executionHint: "fast", + }, + { + id: "ringtone-maker", + name: "Ringtone Maker", + description: "Create a ringtone clip from any audio file", + category: "audio", + icon: "BellRing", + route: "/ringtone-maker", + modality: "audio", + acceptedInputs: AUDIO_INPUTS, + executionHint: "fast", + }, + { + id: "waveform-image", + name: "Waveform Image", + description: "Generate a waveform visualization as a PNG image", + category: "audio", + icon: "Activity", + route: "/waveform-image", + modality: "audio", + acceptedInputs: AUDIO_INPUTS, + executionHint: "fast", + }, + { + id: "audio-metadata", + name: "Audio Metadata", + description: "View, edit, or strip audio tags (ID3)", + category: "audio", + icon: "Tags", + route: "/audio-metadata", + modality: "audio", + acceptedInputs: AUDIO_INPUTS, + executionHint: "fast", + }, // PDF & Documents { id: "merge-pdf", diff --git a/packages/shared/src/i18n/ar.ts b/packages/shared/src/i18n/ar.ts index 55a745bd..6a62de40 100644 --- a/packages/shared/src/i18n/ar.ts +++ b/packages/shared/src/i18n/ar.ts @@ -396,6 +396,62 @@ export const ar: TranslationKeys = { name: "Extract Audio", description: "Pull the audio track out of a video", }, + "volume-adjust": { + name: "Volume Adjust", + description: "Increase or decrease audio volume by a fixed gain", + }, + "normalize-audio": { + name: "Normalize Audio", + description: "Even out loudness to broadcast standard levels", + }, + "fade-audio": { + name: "Fade Audio", + description: "Add fade-in and fade-out effects to audio", + }, + "reverse-audio": { + name: "Reverse Audio", + description: "Play audio backwards", + }, + "audio-speed": { + name: "Audio Speed", + description: "Speed up or slow down audio playback", + }, + "pitch-shift": { + name: "Pitch Shift", + description: "Raise or lower pitch by semitones without changing speed", + }, + "audio-channels": { + name: "Audio Channels", + description: "Convert between mono, stereo, or swap left and right channels", + }, + "silence-removal": { + name: "Silence Removal", + description: "Strip silent sections from audio", + }, + "noise-reduction": { + name: "Noise Reduction", + description: "Reduce background noise with FFT-based denoising", + }, + "merge-audio": { + name: "Merge Audio", + description: "Combine multiple audio files into one", + }, + "split-audio": { + name: "Split Audio", + description: "Split audio by time, equal parts, or silence detection", + }, + "ringtone-maker": { + name: "Ringtone Maker", + description: "Create a ringtone clip from any audio file", + }, + "waveform-image": { + name: "Waveform Image", + description: "Generate a waveform visualization as a PNG image", + }, + "audio-metadata": { + name: "Audio Metadata", + description: "View, edit, or strip audio tags (ID3)", + }, "merge-pdf": { name: "Merge PDFs", description: "Combine multiple PDFs into one", @@ -1708,6 +1764,117 @@ export const ar: TranslationKeys = { submitBatch: "Extract ({count} files)", progressLabel: "Extracting", }, + "volume-adjust": { + "gain-db": "Gain (dB)", + submit: "Adjust Volume", + submitBatch: "Adjust Volume ({count} files)", + progressLabel: "Adjusting volume", + }, + "normalize-audio": { + hint: "EBU R128 loudness normalization", + submit: "Normalize", + submitBatch: "Normalize ({count} files)", + progressLabel: "Normalizing", + }, + "fade-audio": { + "fade-in-s": "Fade in (seconds)", + "fade-out-s": "Fade out (seconds)", + submit: "Apply Fade", + submitBatch: "Apply Fade ({count} files)", + progressLabel: "Applying fade", + }, + "reverse-audio": { + submit: "Reverse", + submitBatch: "Reverse ({count} files)", + progressLabel: "Reversing", + }, + "audio-speed": { + factor: "Speed factor", + submit: "Change Speed", + submitBatch: "Change Speed ({count} files)", + progressLabel: "Changing speed", + }, + "pitch-shift": { + semitones: "Semitones", + hint: "0 semitones is not allowed", + submit: "Shift Pitch", + submitBatch: "Shift Pitch ({count} files)", + progressLabel: "Shifting pitch", + }, + "audio-channels": { + mode: "Channel mode", + "stereo-to-mono": "Stereo to mono", + "mono-to-stereo": "Mono to stereo", + swap: "Swap left/right", + submit: "Convert Channels", + submitBatch: "Convert Channels ({count} files)", + progressLabel: "Converting channels", + }, + "silence-removal": { + "threshold-db": "Silence threshold (dB)", + "min-silence-s": "Minimum silence duration (seconds)", + submit: "Remove Silence", + submitBatch: "Remove Silence ({count} files)", + progressLabel: "Removing silence", + }, + "noise-reduction": { + strength: "Strength", + light: "Light", + medium: "Medium", + strong: "Strong", + submit: "Reduce Noise", + submitBatch: "Reduce Noise ({count} files)", + progressLabel: "Reducing noise", + }, + "merge-audio": { + format: "Output format", + mp3: "MP3", + wav: "WAV", + flac: "FLAC", + m4a: "M4A", + "order-hint": "Files will be joined in upload order", + submit: "Merge Audio", + submitBatch: "Merge Audio ({count} files)", + progressLabel: "Merging audio", + }, + "split-audio": { + mode: "Split mode", + time: "By time", + parts: "Equal parts", + silence: "By silence", + "segment-s": "Segment length (seconds)", + "num-parts": "Number of parts", + "threshold-db": "Silence threshold (dB)", + "min-silence-s": "Minimum silence duration (seconds)", + submit: "Split Audio", + submitBatch: "Split Audio ({count} files)", + progressLabel: "Splitting audio", + }, + "ringtone-maker": { + "start-s": "Start time (seconds)", + "duration-s": "Duration (seconds)", + "max-hint": "Maximum 30 seconds", + submit: "Create Ringtone", + submitBatch: "Create Ringtone ({count} files)", + progressLabel: "Creating ringtone", + }, + "waveform-image": { + width: "Width (px)", + height: "Height (px)", + color: "Waveform color", + submit: "Generate Waveform", + submitBatch: "Generate Waveform ({count} files)", + progressLabel: "Generating waveform", + }, + "audio-metadata": { + strip: "Strip all metadata", + title: "Title", + artist: "Artist", + album: "Album", + submit: "Apply Metadata", + submitBatch: "Apply Metadata ({count} files)", + progressLabel: "Applying metadata", + }, "merge-pdf": { addFiles: "Add PDFs to merge", needTwo: "At least two PDFs are required", diff --git a/packages/shared/src/i18n/de.ts b/packages/shared/src/i18n/de.ts index 23e1accc..d5fd95f5 100644 --- a/packages/shared/src/i18n/de.ts +++ b/packages/shared/src/i18n/de.ts @@ -401,6 +401,62 @@ export const de: TranslationKeys = { name: "Extract Audio", description: "Pull the audio track out of a video", }, + "volume-adjust": { + name: "Volume Adjust", + description: "Increase or decrease audio volume by a fixed gain", + }, + "normalize-audio": { + name: "Normalize Audio", + description: "Even out loudness to broadcast standard levels", + }, + "fade-audio": { + name: "Fade Audio", + description: "Add fade-in and fade-out effects to audio", + }, + "reverse-audio": { + name: "Reverse Audio", + description: "Play audio backwards", + }, + "audio-speed": { + name: "Audio Speed", + description: "Speed up or slow down audio playback", + }, + "pitch-shift": { + name: "Pitch Shift", + description: "Raise or lower pitch by semitones without changing speed", + }, + "audio-channels": { + name: "Audio Channels", + description: "Convert between mono, stereo, or swap left and right channels", + }, + "silence-removal": { + name: "Silence Removal", + description: "Strip silent sections from audio", + }, + "noise-reduction": { + name: "Noise Reduction", + description: "Reduce background noise with FFT-based denoising", + }, + "merge-audio": { + name: "Merge Audio", + description: "Combine multiple audio files into one", + }, + "split-audio": { + name: "Split Audio", + description: "Split audio by time, equal parts, or silence detection", + }, + "ringtone-maker": { + name: "Ringtone Maker", + description: "Create a ringtone clip from any audio file", + }, + "waveform-image": { + name: "Waveform Image", + description: "Generate a waveform visualization as a PNG image", + }, + "audio-metadata": { + name: "Audio Metadata", + description: "View, edit, or strip audio tags (ID3)", + }, "merge-pdf": { name: "Merge PDFs", description: "Combine multiple PDFs into one", @@ -1721,6 +1777,117 @@ export const de: TranslationKeys = { submitBatch: "Extract ({count} files)", progressLabel: "Extracting", }, + "volume-adjust": { + "gain-db": "Gain (dB)", + submit: "Adjust Volume", + submitBatch: "Adjust Volume ({count} files)", + progressLabel: "Adjusting volume", + }, + "normalize-audio": { + hint: "EBU R128 loudness normalization", + submit: "Normalize", + submitBatch: "Normalize ({count} files)", + progressLabel: "Normalizing", + }, + "fade-audio": { + "fade-in-s": "Fade in (seconds)", + "fade-out-s": "Fade out (seconds)", + submit: "Apply Fade", + submitBatch: "Apply Fade ({count} files)", + progressLabel: "Applying fade", + }, + "reverse-audio": { + submit: "Reverse", + submitBatch: "Reverse ({count} files)", + progressLabel: "Reversing", + }, + "audio-speed": { + factor: "Speed factor", + submit: "Change Speed", + submitBatch: "Change Speed ({count} files)", + progressLabel: "Changing speed", + }, + "pitch-shift": { + semitones: "Semitones", + hint: "0 semitones is not allowed", + submit: "Shift Pitch", + submitBatch: "Shift Pitch ({count} files)", + progressLabel: "Shifting pitch", + }, + "audio-channels": { + mode: "Channel mode", + "stereo-to-mono": "Stereo to mono", + "mono-to-stereo": "Mono to stereo", + swap: "Swap left/right", + submit: "Convert Channels", + submitBatch: "Convert Channels ({count} files)", + progressLabel: "Converting channels", + }, + "silence-removal": { + "threshold-db": "Silence threshold (dB)", + "min-silence-s": "Minimum silence duration (seconds)", + submit: "Remove Silence", + submitBatch: "Remove Silence ({count} files)", + progressLabel: "Removing silence", + }, + "noise-reduction": { + strength: "Strength", + light: "Light", + medium: "Medium", + strong: "Strong", + submit: "Reduce Noise", + submitBatch: "Reduce Noise ({count} files)", + progressLabel: "Reducing noise", + }, + "merge-audio": { + format: "Output format", + mp3: "MP3", + wav: "WAV", + flac: "FLAC", + m4a: "M4A", + "order-hint": "Files will be joined in upload order", + submit: "Merge Audio", + submitBatch: "Merge Audio ({count} files)", + progressLabel: "Merging audio", + }, + "split-audio": { + mode: "Split mode", + time: "By time", + parts: "Equal parts", + silence: "By silence", + "segment-s": "Segment length (seconds)", + "num-parts": "Number of parts", + "threshold-db": "Silence threshold (dB)", + "min-silence-s": "Minimum silence duration (seconds)", + submit: "Split Audio", + submitBatch: "Split Audio ({count} files)", + progressLabel: "Splitting audio", + }, + "ringtone-maker": { + "start-s": "Start time (seconds)", + "duration-s": "Duration (seconds)", + "max-hint": "Maximum 30 seconds", + submit: "Create Ringtone", + submitBatch: "Create Ringtone ({count} files)", + progressLabel: "Creating ringtone", + }, + "waveform-image": { + width: "Width (px)", + height: "Height (px)", + color: "Waveform color", + submit: "Generate Waveform", + submitBatch: "Generate Waveform ({count} files)", + progressLabel: "Generating waveform", + }, + "audio-metadata": { + strip: "Strip all metadata", + title: "Title", + artist: "Artist", + album: "Album", + submit: "Apply Metadata", + submitBatch: "Apply Metadata ({count} files)", + progressLabel: "Applying metadata", + }, "merge-pdf": { addFiles: "Add PDFs to merge", needTwo: "At least two PDFs are required", diff --git a/packages/shared/src/i18n/en.ts b/packages/shared/src/i18n/en.ts index 2535e7fc..2566ceea 100644 --- a/packages/shared/src/i18n/en.ts +++ b/packages/shared/src/i18n/en.ts @@ -356,6 +356,62 @@ export const en = { name: "Extract Audio", description: "Pull the audio track out of a video", }, + "volume-adjust": { + name: "Volume Adjust", + description: "Increase or decrease audio volume by a fixed gain", + }, + "normalize-audio": { + name: "Normalize Audio", + description: "Even out loudness to broadcast standard levels", + }, + "fade-audio": { + name: "Fade Audio", + description: "Add fade-in and fade-out effects to audio", + }, + "reverse-audio": { + name: "Reverse Audio", + description: "Play audio backwards", + }, + "audio-speed": { + name: "Audio Speed", + description: "Speed up or slow down audio playback", + }, + "pitch-shift": { + name: "Pitch Shift", + description: "Raise or lower pitch by semitones without changing speed", + }, + "audio-channels": { + name: "Audio Channels", + description: "Convert between mono, stereo, or swap left and right channels", + }, + "silence-removal": { + name: "Silence Removal", + description: "Strip silent sections from audio", + }, + "noise-reduction": { + name: "Noise Reduction", + description: "Reduce background noise with FFT-based denoising", + }, + "merge-audio": { + name: "Merge Audio", + description: "Combine multiple audio files into one", + }, + "split-audio": { + name: "Split Audio", + description: "Split audio by time, equal parts, or silence detection", + }, + "ringtone-maker": { + name: "Ringtone Maker", + description: "Create a ringtone clip from any audio file", + }, + "waveform-image": { + name: "Waveform Image", + description: "Generate a waveform visualization as a PNG image", + }, + "audio-metadata": { + name: "Audio Metadata", + description: "View, edit, or strip audio tags (ID3)", + }, "merge-pdf": { name: "Merge PDFs", description: "Combine multiple PDFs into one", @@ -1666,6 +1722,117 @@ export const en = { submitBatch: "Extract ({count} files)", progressLabel: "Extracting", }, + "volume-adjust": { + "gain-db": "Gain (dB)", + submit: "Adjust Volume", + submitBatch: "Adjust Volume ({count} files)", + progressLabel: "Adjusting volume", + }, + "normalize-audio": { + hint: "EBU R128 loudness normalization", + submit: "Normalize", + submitBatch: "Normalize ({count} files)", + progressLabel: "Normalizing", + }, + "fade-audio": { + "fade-in-s": "Fade in (seconds)", + "fade-out-s": "Fade out (seconds)", + submit: "Apply Fade", + submitBatch: "Apply Fade ({count} files)", + progressLabel: "Applying fade", + }, + "reverse-audio": { + submit: "Reverse", + submitBatch: "Reverse ({count} files)", + progressLabel: "Reversing", + }, + "audio-speed": { + factor: "Speed factor", + submit: "Change Speed", + submitBatch: "Change Speed ({count} files)", + progressLabel: "Changing speed", + }, + "pitch-shift": { + semitones: "Semitones", + hint: "0 semitones is not allowed", + submit: "Shift Pitch", + submitBatch: "Shift Pitch ({count} files)", + progressLabel: "Shifting pitch", + }, + "audio-channels": { + mode: "Channel mode", + "stereo-to-mono": "Stereo to mono", + "mono-to-stereo": "Mono to stereo", + swap: "Swap left/right", + submit: "Convert Channels", + submitBatch: "Convert Channels ({count} files)", + progressLabel: "Converting channels", + }, + "silence-removal": { + "threshold-db": "Silence threshold (dB)", + "min-silence-s": "Minimum silence duration (seconds)", + submit: "Remove Silence", + submitBatch: "Remove Silence ({count} files)", + progressLabel: "Removing silence", + }, + "noise-reduction": { + strength: "Strength", + light: "Light", + medium: "Medium", + strong: "Strong", + submit: "Reduce Noise", + submitBatch: "Reduce Noise ({count} files)", + progressLabel: "Reducing noise", + }, + "merge-audio": { + format: "Output format", + mp3: "MP3", + wav: "WAV", + flac: "FLAC", + m4a: "M4A", + "order-hint": "Files will be joined in upload order", + submit: "Merge Audio", + submitBatch: "Merge Audio ({count} files)", + progressLabel: "Merging audio", + }, + "split-audio": { + mode: "Split mode", + time: "By time", + parts: "Equal parts", + silence: "By silence", + "segment-s": "Segment length (seconds)", + "num-parts": "Number of parts", + "threshold-db": "Silence threshold (dB)", + "min-silence-s": "Minimum silence duration (seconds)", + submit: "Split Audio", + submitBatch: "Split Audio ({count} files)", + progressLabel: "Splitting audio", + }, + "ringtone-maker": { + "start-s": "Start time (seconds)", + "duration-s": "Duration (seconds)", + "max-hint": "Maximum 30 seconds", + submit: "Create Ringtone", + submitBatch: "Create Ringtone ({count} files)", + progressLabel: "Creating ringtone", + }, + "waveform-image": { + width: "Width (px)", + height: "Height (px)", + color: "Waveform color", + submit: "Generate Waveform", + submitBatch: "Generate Waveform ({count} files)", + progressLabel: "Generating waveform", + }, + "audio-metadata": { + strip: "Strip all metadata", + title: "Title", + artist: "Artist", + album: "Album", + submit: "Apply Metadata", + submitBatch: "Apply Metadata ({count} files)", + progressLabel: "Applying metadata", + }, "merge-pdf": { addFiles: "Add PDFs to merge", needTwo: "At least two PDFs are required", diff --git a/packages/shared/src/i18n/es.ts b/packages/shared/src/i18n/es.ts index 85762b90..78bbf8ba 100644 --- a/packages/shared/src/i18n/es.ts +++ b/packages/shared/src/i18n/es.ts @@ -386,6 +386,62 @@ export const es: TranslationKeys = { name: "Extract Audio", description: "Pull the audio track out of a video", }, + "volume-adjust": { + name: "Volume Adjust", + description: "Increase or decrease audio volume by a fixed gain", + }, + "normalize-audio": { + name: "Normalize Audio", + description: "Even out loudness to broadcast standard levels", + }, + "fade-audio": { + name: "Fade Audio", + description: "Add fade-in and fade-out effects to audio", + }, + "reverse-audio": { + name: "Reverse Audio", + description: "Play audio backwards", + }, + "audio-speed": { + name: "Audio Speed", + description: "Speed up or slow down audio playback", + }, + "pitch-shift": { + name: "Pitch Shift", + description: "Raise or lower pitch by semitones without changing speed", + }, + "audio-channels": { + name: "Audio Channels", + description: "Convert between mono, stereo, or swap left and right channels", + }, + "silence-removal": { + name: "Silence Removal", + description: "Strip silent sections from audio", + }, + "noise-reduction": { + name: "Noise Reduction", + description: "Reduce background noise with FFT-based denoising", + }, + "merge-audio": { + name: "Merge Audio", + description: "Combine multiple audio files into one", + }, + "split-audio": { + name: "Split Audio", + description: "Split audio by time, equal parts, or silence detection", + }, + "ringtone-maker": { + name: "Ringtone Maker", + description: "Create a ringtone clip from any audio file", + }, + "waveform-image": { + name: "Waveform Image", + description: "Generate a waveform visualization as a PNG image", + }, + "audio-metadata": { + name: "Audio Metadata", + description: "View, edit, or strip audio tags (ID3)", + }, "merge-pdf": { name: "Merge PDFs", description: "Combine multiple PDFs into one", @@ -1703,6 +1759,117 @@ export const es: TranslationKeys = { submitBatch: "Extract ({count} files)", progressLabel: "Extracting", }, + "volume-adjust": { + "gain-db": "Gain (dB)", + submit: "Adjust Volume", + submitBatch: "Adjust Volume ({count} files)", + progressLabel: "Adjusting volume", + }, + "normalize-audio": { + hint: "EBU R128 loudness normalization", + submit: "Normalize", + submitBatch: "Normalize ({count} files)", + progressLabel: "Normalizing", + }, + "fade-audio": { + "fade-in-s": "Fade in (seconds)", + "fade-out-s": "Fade out (seconds)", + submit: "Apply Fade", + submitBatch: "Apply Fade ({count} files)", + progressLabel: "Applying fade", + }, + "reverse-audio": { + submit: "Reverse", + submitBatch: "Reverse ({count} files)", + progressLabel: "Reversing", + }, + "audio-speed": { + factor: "Speed factor", + submit: "Change Speed", + submitBatch: "Change Speed ({count} files)", + progressLabel: "Changing speed", + }, + "pitch-shift": { + semitones: "Semitones", + hint: "0 semitones is not allowed", + submit: "Shift Pitch", + submitBatch: "Shift Pitch ({count} files)", + progressLabel: "Shifting pitch", + }, + "audio-channels": { + mode: "Channel mode", + "stereo-to-mono": "Stereo to mono", + "mono-to-stereo": "Mono to stereo", + swap: "Swap left/right", + submit: "Convert Channels", + submitBatch: "Convert Channels ({count} files)", + progressLabel: "Converting channels", + }, + "silence-removal": { + "threshold-db": "Silence threshold (dB)", + "min-silence-s": "Minimum silence duration (seconds)", + submit: "Remove Silence", + submitBatch: "Remove Silence ({count} files)", + progressLabel: "Removing silence", + }, + "noise-reduction": { + strength: "Strength", + light: "Light", + medium: "Medium", + strong: "Strong", + submit: "Reduce Noise", + submitBatch: "Reduce Noise ({count} files)", + progressLabel: "Reducing noise", + }, + "merge-audio": { + format: "Output format", + mp3: "MP3", + wav: "WAV", + flac: "FLAC", + m4a: "M4A", + "order-hint": "Files will be joined in upload order", + submit: "Merge Audio", + submitBatch: "Merge Audio ({count} files)", + progressLabel: "Merging audio", + }, + "split-audio": { + mode: "Split mode", + time: "By time", + parts: "Equal parts", + silence: "By silence", + "segment-s": "Segment length (seconds)", + "num-parts": "Number of parts", + "threshold-db": "Silence threshold (dB)", + "min-silence-s": "Minimum silence duration (seconds)", + submit: "Split Audio", + submitBatch: "Split Audio ({count} files)", + progressLabel: "Splitting audio", + }, + "ringtone-maker": { + "start-s": "Start time (seconds)", + "duration-s": "Duration (seconds)", + "max-hint": "Maximum 30 seconds", + submit: "Create Ringtone", + submitBatch: "Create Ringtone ({count} files)", + progressLabel: "Creating ringtone", + }, + "waveform-image": { + width: "Width (px)", + height: "Height (px)", + color: "Waveform color", + submit: "Generate Waveform", + submitBatch: "Generate Waveform ({count} files)", + progressLabel: "Generating waveform", + }, + "audio-metadata": { + strip: "Strip all metadata", + title: "Title", + artist: "Artist", + album: "Album", + submit: "Apply Metadata", + submitBatch: "Apply Metadata ({count} files)", + progressLabel: "Applying metadata", + }, "merge-pdf": { addFiles: "Add PDFs to merge", needTwo: "At least two PDFs are required", diff --git a/packages/shared/src/i18n/fr.ts b/packages/shared/src/i18n/fr.ts index 496c98d1..19708e4c 100644 --- a/packages/shared/src/i18n/fr.ts +++ b/packages/shared/src/i18n/fr.ts @@ -402,6 +402,62 @@ export const fr: TranslationKeys = { name: "Extract Audio", description: "Pull the audio track out of a video", }, + "volume-adjust": { + name: "Volume Adjust", + description: "Increase or decrease audio volume by a fixed gain", + }, + "normalize-audio": { + name: "Normalize Audio", + description: "Even out loudness to broadcast standard levels", + }, + "fade-audio": { + name: "Fade Audio", + description: "Add fade-in and fade-out effects to audio", + }, + "reverse-audio": { + name: "Reverse Audio", + description: "Play audio backwards", + }, + "audio-speed": { + name: "Audio Speed", + description: "Speed up or slow down audio playback", + }, + "pitch-shift": { + name: "Pitch Shift", + description: "Raise or lower pitch by semitones without changing speed", + }, + "audio-channels": { + name: "Audio Channels", + description: "Convert between mono, stereo, or swap left and right channels", + }, + "silence-removal": { + name: "Silence Removal", + description: "Strip silent sections from audio", + }, + "noise-reduction": { + name: "Noise Reduction", + description: "Reduce background noise with FFT-based denoising", + }, + "merge-audio": { + name: "Merge Audio", + description: "Combine multiple audio files into one", + }, + "split-audio": { + name: "Split Audio", + description: "Split audio by time, equal parts, or silence detection", + }, + "ringtone-maker": { + name: "Ringtone Maker", + description: "Create a ringtone clip from any audio file", + }, + "waveform-image": { + name: "Waveform Image", + description: "Generate a waveform visualization as a PNG image", + }, + "audio-metadata": { + name: "Audio Metadata", + description: "View, edit, or strip audio tags (ID3)", + }, "merge-pdf": { name: "Merge PDFs", description: "Combine multiple PDFs into one", @@ -1722,6 +1778,117 @@ export const fr: TranslationKeys = { submitBatch: "Extract ({count} files)", progressLabel: "Extracting", }, + "volume-adjust": { + "gain-db": "Gain (dB)", + submit: "Adjust Volume", + submitBatch: "Adjust Volume ({count} files)", + progressLabel: "Adjusting volume", + }, + "normalize-audio": { + hint: "EBU R128 loudness normalization", + submit: "Normalize", + submitBatch: "Normalize ({count} files)", + progressLabel: "Normalizing", + }, + "fade-audio": { + "fade-in-s": "Fade in (seconds)", + "fade-out-s": "Fade out (seconds)", + submit: "Apply Fade", + submitBatch: "Apply Fade ({count} files)", + progressLabel: "Applying fade", + }, + "reverse-audio": { + submit: "Reverse", + submitBatch: "Reverse ({count} files)", + progressLabel: "Reversing", + }, + "audio-speed": { + factor: "Speed factor", + submit: "Change Speed", + submitBatch: "Change Speed ({count} files)", + progressLabel: "Changing speed", + }, + "pitch-shift": { + semitones: "Semitones", + hint: "0 semitones is not allowed", + submit: "Shift Pitch", + submitBatch: "Shift Pitch ({count} files)", + progressLabel: "Shifting pitch", + }, + "audio-channels": { + mode: "Channel mode", + "stereo-to-mono": "Stereo to mono", + "mono-to-stereo": "Mono to stereo", + swap: "Swap left/right", + submit: "Convert Channels", + submitBatch: "Convert Channels ({count} files)", + progressLabel: "Converting channels", + }, + "silence-removal": { + "threshold-db": "Silence threshold (dB)", + "min-silence-s": "Minimum silence duration (seconds)", + submit: "Remove Silence", + submitBatch: "Remove Silence ({count} files)", + progressLabel: "Removing silence", + }, + "noise-reduction": { + strength: "Strength", + light: "Light", + medium: "Medium", + strong: "Strong", + submit: "Reduce Noise", + submitBatch: "Reduce Noise ({count} files)", + progressLabel: "Reducing noise", + }, + "merge-audio": { + format: "Output format", + mp3: "MP3", + wav: "WAV", + flac: "FLAC", + m4a: "M4A", + "order-hint": "Files will be joined in upload order", + submit: "Merge Audio", + submitBatch: "Merge Audio ({count} files)", + progressLabel: "Merging audio", + }, + "split-audio": { + mode: "Split mode", + time: "By time", + parts: "Equal parts", + silence: "By silence", + "segment-s": "Segment length (seconds)", + "num-parts": "Number of parts", + "threshold-db": "Silence threshold (dB)", + "min-silence-s": "Minimum silence duration (seconds)", + submit: "Split Audio", + submitBatch: "Split Audio ({count} files)", + progressLabel: "Splitting audio", + }, + "ringtone-maker": { + "start-s": "Start time (seconds)", + "duration-s": "Duration (seconds)", + "max-hint": "Maximum 30 seconds", + submit: "Create Ringtone", + submitBatch: "Create Ringtone ({count} files)", + progressLabel: "Creating ringtone", + }, + "waveform-image": { + width: "Width (px)", + height: "Height (px)", + color: "Waveform color", + submit: "Generate Waveform", + submitBatch: "Generate Waveform ({count} files)", + progressLabel: "Generating waveform", + }, + "audio-metadata": { + strip: "Strip all metadata", + title: "Title", + artist: "Artist", + album: "Album", + submit: "Apply Metadata", + submitBatch: "Apply Metadata ({count} files)", + progressLabel: "Applying metadata", + }, "merge-pdf": { addFiles: "Add PDFs to merge", needTwo: "At least two PDFs are required", diff --git a/packages/shared/src/i18n/hi.ts b/packages/shared/src/i18n/hi.ts index 65fc4bf6..b64e83ff 100644 --- a/packages/shared/src/i18n/hi.ts +++ b/packages/shared/src/i18n/hi.ts @@ -393,6 +393,62 @@ export const hi: TranslationKeys = { name: "Extract Audio", description: "Pull the audio track out of a video", }, + "volume-adjust": { + name: "Volume Adjust", + description: "Increase or decrease audio volume by a fixed gain", + }, + "normalize-audio": { + name: "Normalize Audio", + description: "Even out loudness to broadcast standard levels", + }, + "fade-audio": { + name: "Fade Audio", + description: "Add fade-in and fade-out effects to audio", + }, + "reverse-audio": { + name: "Reverse Audio", + description: "Play audio backwards", + }, + "audio-speed": { + name: "Audio Speed", + description: "Speed up or slow down audio playback", + }, + "pitch-shift": { + name: "Pitch Shift", + description: "Raise or lower pitch by semitones without changing speed", + }, + "audio-channels": { + name: "Audio Channels", + description: "Convert between mono, stereo, or swap left and right channels", + }, + "silence-removal": { + name: "Silence Removal", + description: "Strip silent sections from audio", + }, + "noise-reduction": { + name: "Noise Reduction", + description: "Reduce background noise with FFT-based denoising", + }, + "merge-audio": { + name: "Merge Audio", + description: "Combine multiple audio files into one", + }, + "split-audio": { + name: "Split Audio", + description: "Split audio by time, equal parts, or silence detection", + }, + "ringtone-maker": { + name: "Ringtone Maker", + description: "Create a ringtone clip from any audio file", + }, + "waveform-image": { + name: "Waveform Image", + description: "Generate a waveform visualization as a PNG image", + }, + "audio-metadata": { + name: "Audio Metadata", + description: "View, edit, or strip audio tags (ID3)", + }, "merge-pdf": { name: "Merge PDFs", description: "Combine multiple PDFs into one", @@ -1704,6 +1760,117 @@ export const hi: TranslationKeys = { submitBatch: "Extract ({count} files)", progressLabel: "Extracting", }, + "volume-adjust": { + "gain-db": "Gain (dB)", + submit: "Adjust Volume", + submitBatch: "Adjust Volume ({count} files)", + progressLabel: "Adjusting volume", + }, + "normalize-audio": { + hint: "EBU R128 loudness normalization", + submit: "Normalize", + submitBatch: "Normalize ({count} files)", + progressLabel: "Normalizing", + }, + "fade-audio": { + "fade-in-s": "Fade in (seconds)", + "fade-out-s": "Fade out (seconds)", + submit: "Apply Fade", + submitBatch: "Apply Fade ({count} files)", + progressLabel: "Applying fade", + }, + "reverse-audio": { + submit: "Reverse", + submitBatch: "Reverse ({count} files)", + progressLabel: "Reversing", + }, + "audio-speed": { + factor: "Speed factor", + submit: "Change Speed", + submitBatch: "Change Speed ({count} files)", + progressLabel: "Changing speed", + }, + "pitch-shift": { + semitones: "Semitones", + hint: "0 semitones is not allowed", + submit: "Shift Pitch", + submitBatch: "Shift Pitch ({count} files)", + progressLabel: "Shifting pitch", + }, + "audio-channels": { + mode: "Channel mode", + "stereo-to-mono": "Stereo to mono", + "mono-to-stereo": "Mono to stereo", + swap: "Swap left/right", + submit: "Convert Channels", + submitBatch: "Convert Channels ({count} files)", + progressLabel: "Converting channels", + }, + "silence-removal": { + "threshold-db": "Silence threshold (dB)", + "min-silence-s": "Minimum silence duration (seconds)", + submit: "Remove Silence", + submitBatch: "Remove Silence ({count} files)", + progressLabel: "Removing silence", + }, + "noise-reduction": { + strength: "Strength", + light: "Light", + medium: "Medium", + strong: "Strong", + submit: "Reduce Noise", + submitBatch: "Reduce Noise ({count} files)", + progressLabel: "Reducing noise", + }, + "merge-audio": { + format: "Output format", + mp3: "MP3", + wav: "WAV", + flac: "FLAC", + m4a: "M4A", + "order-hint": "Files will be joined in upload order", + submit: "Merge Audio", + submitBatch: "Merge Audio ({count} files)", + progressLabel: "Merging audio", + }, + "split-audio": { + mode: "Split mode", + time: "By time", + parts: "Equal parts", + silence: "By silence", + "segment-s": "Segment length (seconds)", + "num-parts": "Number of parts", + "threshold-db": "Silence threshold (dB)", + "min-silence-s": "Minimum silence duration (seconds)", + submit: "Split Audio", + submitBatch: "Split Audio ({count} files)", + progressLabel: "Splitting audio", + }, + "ringtone-maker": { + "start-s": "Start time (seconds)", + "duration-s": "Duration (seconds)", + "max-hint": "Maximum 30 seconds", + submit: "Create Ringtone", + submitBatch: "Create Ringtone ({count} files)", + progressLabel: "Creating ringtone", + }, + "waveform-image": { + width: "Width (px)", + height: "Height (px)", + color: "Waveform color", + submit: "Generate Waveform", + submitBatch: "Generate Waveform ({count} files)", + progressLabel: "Generating waveform", + }, + "audio-metadata": { + strip: "Strip all metadata", + title: "Title", + artist: "Artist", + album: "Album", + submit: "Apply Metadata", + submitBatch: "Apply Metadata ({count} files)", + progressLabel: "Applying metadata", + }, "merge-pdf": { addFiles: "Add PDFs to merge", needTwo: "At least two PDFs are required", diff --git a/packages/shared/src/i18n/id.ts b/packages/shared/src/i18n/id.ts index 0a8f4ba6..c9ad66b3 100644 --- a/packages/shared/src/i18n/id.ts +++ b/packages/shared/src/i18n/id.ts @@ -401,6 +401,62 @@ export const id: TranslationKeys = { name: "Extract Audio", description: "Pull the audio track out of a video", }, + "volume-adjust": { + name: "Volume Adjust", + description: "Increase or decrease audio volume by a fixed gain", + }, + "normalize-audio": { + name: "Normalize Audio", + description: "Even out loudness to broadcast standard levels", + }, + "fade-audio": { + name: "Fade Audio", + description: "Add fade-in and fade-out effects to audio", + }, + "reverse-audio": { + name: "Reverse Audio", + description: "Play audio backwards", + }, + "audio-speed": { + name: "Audio Speed", + description: "Speed up or slow down audio playback", + }, + "pitch-shift": { + name: "Pitch Shift", + description: "Raise or lower pitch by semitones without changing speed", + }, + "audio-channels": { + name: "Audio Channels", + description: "Convert between mono, stereo, or swap left and right channels", + }, + "silence-removal": { + name: "Silence Removal", + description: "Strip silent sections from audio", + }, + "noise-reduction": { + name: "Noise Reduction", + description: "Reduce background noise with FFT-based denoising", + }, + "merge-audio": { + name: "Merge Audio", + description: "Combine multiple audio files into one", + }, + "split-audio": { + name: "Split Audio", + description: "Split audio by time, equal parts, or silence detection", + }, + "ringtone-maker": { + name: "Ringtone Maker", + description: "Create a ringtone clip from any audio file", + }, + "waveform-image": { + name: "Waveform Image", + description: "Generate a waveform visualization as a PNG image", + }, + "audio-metadata": { + name: "Audio Metadata", + description: "View, edit, or strip audio tags (ID3)", + }, "merge-pdf": { name: "Merge PDFs", description: "Combine multiple PDFs into one", @@ -1716,6 +1772,117 @@ export const id: TranslationKeys = { submitBatch: "Extract ({count} files)", progressLabel: "Extracting", }, + "volume-adjust": { + "gain-db": "Gain (dB)", + submit: "Adjust Volume", + submitBatch: "Adjust Volume ({count} files)", + progressLabel: "Adjusting volume", + }, + "normalize-audio": { + hint: "EBU R128 loudness normalization", + submit: "Normalize", + submitBatch: "Normalize ({count} files)", + progressLabel: "Normalizing", + }, + "fade-audio": { + "fade-in-s": "Fade in (seconds)", + "fade-out-s": "Fade out (seconds)", + submit: "Apply Fade", + submitBatch: "Apply Fade ({count} files)", + progressLabel: "Applying fade", + }, + "reverse-audio": { + submit: "Reverse", + submitBatch: "Reverse ({count} files)", + progressLabel: "Reversing", + }, + "audio-speed": { + factor: "Speed factor", + submit: "Change Speed", + submitBatch: "Change Speed ({count} files)", + progressLabel: "Changing speed", + }, + "pitch-shift": { + semitones: "Semitones", + hint: "0 semitones is not allowed", + submit: "Shift Pitch", + submitBatch: "Shift Pitch ({count} files)", + progressLabel: "Shifting pitch", + }, + "audio-channels": { + mode: "Channel mode", + "stereo-to-mono": "Stereo to mono", + "mono-to-stereo": "Mono to stereo", + swap: "Swap left/right", + submit: "Convert Channels", + submitBatch: "Convert Channels ({count} files)", + progressLabel: "Converting channels", + }, + "silence-removal": { + "threshold-db": "Silence threshold (dB)", + "min-silence-s": "Minimum silence duration (seconds)", + submit: "Remove Silence", + submitBatch: "Remove Silence ({count} files)", + progressLabel: "Removing silence", + }, + "noise-reduction": { + strength: "Strength", + light: "Light", + medium: "Medium", + strong: "Strong", + submit: "Reduce Noise", + submitBatch: "Reduce Noise ({count} files)", + progressLabel: "Reducing noise", + }, + "merge-audio": { + format: "Output format", + mp3: "MP3", + wav: "WAV", + flac: "FLAC", + m4a: "M4A", + "order-hint": "Files will be joined in upload order", + submit: "Merge Audio", + submitBatch: "Merge Audio ({count} files)", + progressLabel: "Merging audio", + }, + "split-audio": { + mode: "Split mode", + time: "By time", + parts: "Equal parts", + silence: "By silence", + "segment-s": "Segment length (seconds)", + "num-parts": "Number of parts", + "threshold-db": "Silence threshold (dB)", + "min-silence-s": "Minimum silence duration (seconds)", + submit: "Split Audio", + submitBatch: "Split Audio ({count} files)", + progressLabel: "Splitting audio", + }, + "ringtone-maker": { + "start-s": "Start time (seconds)", + "duration-s": "Duration (seconds)", + "max-hint": "Maximum 30 seconds", + submit: "Create Ringtone", + submitBatch: "Create Ringtone ({count} files)", + progressLabel: "Creating ringtone", + }, + "waveform-image": { + width: "Width (px)", + height: "Height (px)", + color: "Waveform color", + submit: "Generate Waveform", + submitBatch: "Generate Waveform ({count} files)", + progressLabel: "Generating waveform", + }, + "audio-metadata": { + strip: "Strip all metadata", + title: "Title", + artist: "Artist", + album: "Album", + submit: "Apply Metadata", + submitBatch: "Apply Metadata ({count} files)", + progressLabel: "Applying metadata", + }, "merge-pdf": { addFiles: "Add PDFs to merge", needTwo: "At least two PDFs are required", diff --git a/packages/shared/src/i18n/it.ts b/packages/shared/src/i18n/it.ts index 3bdf73a4..9005b7b7 100644 --- a/packages/shared/src/i18n/it.ts +++ b/packages/shared/src/i18n/it.ts @@ -400,6 +400,62 @@ export const it: TranslationKeys = { name: "Extract Audio", description: "Pull the audio track out of a video", }, + "volume-adjust": { + name: "Volume Adjust", + description: "Increase or decrease audio volume by a fixed gain", + }, + "normalize-audio": { + name: "Normalize Audio", + description: "Even out loudness to broadcast standard levels", + }, + "fade-audio": { + name: "Fade Audio", + description: "Add fade-in and fade-out effects to audio", + }, + "reverse-audio": { + name: "Reverse Audio", + description: "Play audio backwards", + }, + "audio-speed": { + name: "Audio Speed", + description: "Speed up or slow down audio playback", + }, + "pitch-shift": { + name: "Pitch Shift", + description: "Raise or lower pitch by semitones without changing speed", + }, + "audio-channels": { + name: "Audio Channels", + description: "Convert between mono, stereo, or swap left and right channels", + }, + "silence-removal": { + name: "Silence Removal", + description: "Strip silent sections from audio", + }, + "noise-reduction": { + name: "Noise Reduction", + description: "Reduce background noise with FFT-based denoising", + }, + "merge-audio": { + name: "Merge Audio", + description: "Combine multiple audio files into one", + }, + "split-audio": { + name: "Split Audio", + description: "Split audio by time, equal parts, or silence detection", + }, + "ringtone-maker": { + name: "Ringtone Maker", + description: "Create a ringtone clip from any audio file", + }, + "waveform-image": { + name: "Waveform Image", + description: "Generate a waveform visualization as a PNG image", + }, + "audio-metadata": { + name: "Audio Metadata", + description: "View, edit, or strip audio tags (ID3)", + }, "merge-pdf": { name: "Merge PDFs", description: "Combine multiple PDFs into one", @@ -1715,6 +1771,117 @@ export const it: TranslationKeys = { submitBatch: "Extract ({count} files)", progressLabel: "Extracting", }, + "volume-adjust": { + "gain-db": "Gain (dB)", + submit: "Adjust Volume", + submitBatch: "Adjust Volume ({count} files)", + progressLabel: "Adjusting volume", + }, + "normalize-audio": { + hint: "EBU R128 loudness normalization", + submit: "Normalize", + submitBatch: "Normalize ({count} files)", + progressLabel: "Normalizing", + }, + "fade-audio": { + "fade-in-s": "Fade in (seconds)", + "fade-out-s": "Fade out (seconds)", + submit: "Apply Fade", + submitBatch: "Apply Fade ({count} files)", + progressLabel: "Applying fade", + }, + "reverse-audio": { + submit: "Reverse", + submitBatch: "Reverse ({count} files)", + progressLabel: "Reversing", + }, + "audio-speed": { + factor: "Speed factor", + submit: "Change Speed", + submitBatch: "Change Speed ({count} files)", + progressLabel: "Changing speed", + }, + "pitch-shift": { + semitones: "Semitones", + hint: "0 semitones is not allowed", + submit: "Shift Pitch", + submitBatch: "Shift Pitch ({count} files)", + progressLabel: "Shifting pitch", + }, + "audio-channels": { + mode: "Channel mode", + "stereo-to-mono": "Stereo to mono", + "mono-to-stereo": "Mono to stereo", + swap: "Swap left/right", + submit: "Convert Channels", + submitBatch: "Convert Channels ({count} files)", + progressLabel: "Converting channels", + }, + "silence-removal": { + "threshold-db": "Silence threshold (dB)", + "min-silence-s": "Minimum silence duration (seconds)", + submit: "Remove Silence", + submitBatch: "Remove Silence ({count} files)", + progressLabel: "Removing silence", + }, + "noise-reduction": { + strength: "Strength", + light: "Light", + medium: "Medium", + strong: "Strong", + submit: "Reduce Noise", + submitBatch: "Reduce Noise ({count} files)", + progressLabel: "Reducing noise", + }, + "merge-audio": { + format: "Output format", + mp3: "MP3", + wav: "WAV", + flac: "FLAC", + m4a: "M4A", + "order-hint": "Files will be joined in upload order", + submit: "Merge Audio", + submitBatch: "Merge Audio ({count} files)", + progressLabel: "Merging audio", + }, + "split-audio": { + mode: "Split mode", + time: "By time", + parts: "Equal parts", + silence: "By silence", + "segment-s": "Segment length (seconds)", + "num-parts": "Number of parts", + "threshold-db": "Silence threshold (dB)", + "min-silence-s": "Minimum silence duration (seconds)", + submit: "Split Audio", + submitBatch: "Split Audio ({count} files)", + progressLabel: "Splitting audio", + }, + "ringtone-maker": { + "start-s": "Start time (seconds)", + "duration-s": "Duration (seconds)", + "max-hint": "Maximum 30 seconds", + submit: "Create Ringtone", + submitBatch: "Create Ringtone ({count} files)", + progressLabel: "Creating ringtone", + }, + "waveform-image": { + width: "Width (px)", + height: "Height (px)", + color: "Waveform color", + submit: "Generate Waveform", + submitBatch: "Generate Waveform ({count} files)", + progressLabel: "Generating waveform", + }, + "audio-metadata": { + strip: "Strip all metadata", + title: "Title", + artist: "Artist", + album: "Album", + submit: "Apply Metadata", + submitBatch: "Apply Metadata ({count} files)", + progressLabel: "Applying metadata", + }, "merge-pdf": { addFiles: "Add PDFs to merge", needTwo: "At least two PDFs are required", diff --git a/packages/shared/src/i18n/ja.ts b/packages/shared/src/i18n/ja.ts index 9eeb0706..3d00b0e0 100644 --- a/packages/shared/src/i18n/ja.ts +++ b/packages/shared/src/i18n/ja.ts @@ -364,6 +364,62 @@ export const ja: TranslationKeys = { name: "Extract Audio", description: "Pull the audio track out of a video", }, + "volume-adjust": { + name: "Volume Adjust", + description: "Increase or decrease audio volume by a fixed gain", + }, + "normalize-audio": { + name: "Normalize Audio", + description: "Even out loudness to broadcast standard levels", + }, + "fade-audio": { + name: "Fade Audio", + description: "Add fade-in and fade-out effects to audio", + }, + "reverse-audio": { + name: "Reverse Audio", + description: "Play audio backwards", + }, + "audio-speed": { + name: "Audio Speed", + description: "Speed up or slow down audio playback", + }, + "pitch-shift": { + name: "Pitch Shift", + description: "Raise or lower pitch by semitones without changing speed", + }, + "audio-channels": { + name: "Audio Channels", + description: "Convert between mono, stereo, or swap left and right channels", + }, + "silence-removal": { + name: "Silence Removal", + description: "Strip silent sections from audio", + }, + "noise-reduction": { + name: "Noise Reduction", + description: "Reduce background noise with FFT-based denoising", + }, + "merge-audio": { + name: "Merge Audio", + description: "Combine multiple audio files into one", + }, + "split-audio": { + name: "Split Audio", + description: "Split audio by time, equal parts, or silence detection", + }, + "ringtone-maker": { + name: "Ringtone Maker", + description: "Create a ringtone clip from any audio file", + }, + "waveform-image": { + name: "Waveform Image", + description: "Generate a waveform visualization as a PNG image", + }, + "audio-metadata": { + name: "Audio Metadata", + description: "View, edit, or strip audio tags (ID3)", + }, "merge-pdf": { name: "Merge PDFs", description: "Combine multiple PDFs into one", @@ -1674,6 +1730,117 @@ export const ja: TranslationKeys = { submitBatch: "Extract ({count} files)", progressLabel: "Extracting", }, + "volume-adjust": { + "gain-db": "Gain (dB)", + submit: "Adjust Volume", + submitBatch: "Adjust Volume ({count} files)", + progressLabel: "Adjusting volume", + }, + "normalize-audio": { + hint: "EBU R128 loudness normalization", + submit: "Normalize", + submitBatch: "Normalize ({count} files)", + progressLabel: "Normalizing", + }, + "fade-audio": { + "fade-in-s": "Fade in (seconds)", + "fade-out-s": "Fade out (seconds)", + submit: "Apply Fade", + submitBatch: "Apply Fade ({count} files)", + progressLabel: "Applying fade", + }, + "reverse-audio": { + submit: "Reverse", + submitBatch: "Reverse ({count} files)", + progressLabel: "Reversing", + }, + "audio-speed": { + factor: "Speed factor", + submit: "Change Speed", + submitBatch: "Change Speed ({count} files)", + progressLabel: "Changing speed", + }, + "pitch-shift": { + semitones: "Semitones", + hint: "0 semitones is not allowed", + submit: "Shift Pitch", + submitBatch: "Shift Pitch ({count} files)", + progressLabel: "Shifting pitch", + }, + "audio-channels": { + mode: "Channel mode", + "stereo-to-mono": "Stereo to mono", + "mono-to-stereo": "Mono to stereo", + swap: "Swap left/right", + submit: "Convert Channels", + submitBatch: "Convert Channels ({count} files)", + progressLabel: "Converting channels", + }, + "silence-removal": { + "threshold-db": "Silence threshold (dB)", + "min-silence-s": "Minimum silence duration (seconds)", + submit: "Remove Silence", + submitBatch: "Remove Silence ({count} files)", + progressLabel: "Removing silence", + }, + "noise-reduction": { + strength: "Strength", + light: "Light", + medium: "Medium", + strong: "Strong", + submit: "Reduce Noise", + submitBatch: "Reduce Noise ({count} files)", + progressLabel: "Reducing noise", + }, + "merge-audio": { + format: "Output format", + mp3: "MP3", + wav: "WAV", + flac: "FLAC", + m4a: "M4A", + "order-hint": "Files will be joined in upload order", + submit: "Merge Audio", + submitBatch: "Merge Audio ({count} files)", + progressLabel: "Merging audio", + }, + "split-audio": { + mode: "Split mode", + time: "By time", + parts: "Equal parts", + silence: "By silence", + "segment-s": "Segment length (seconds)", + "num-parts": "Number of parts", + "threshold-db": "Silence threshold (dB)", + "min-silence-s": "Minimum silence duration (seconds)", + submit: "Split Audio", + submitBatch: "Split Audio ({count} files)", + progressLabel: "Splitting audio", + }, + "ringtone-maker": { + "start-s": "Start time (seconds)", + "duration-s": "Duration (seconds)", + "max-hint": "Maximum 30 seconds", + submit: "Create Ringtone", + submitBatch: "Create Ringtone ({count} files)", + progressLabel: "Creating ringtone", + }, + "waveform-image": { + width: "Width (px)", + height: "Height (px)", + color: "Waveform color", + submit: "Generate Waveform", + submitBatch: "Generate Waveform ({count} files)", + progressLabel: "Generating waveform", + }, + "audio-metadata": { + strip: "Strip all metadata", + title: "Title", + artist: "Artist", + album: "Album", + submit: "Apply Metadata", + submitBatch: "Apply Metadata ({count} files)", + progressLabel: "Applying metadata", + }, "merge-pdf": { addFiles: "Add PDFs to merge", needTwo: "At least two PDFs are required", diff --git a/packages/shared/src/i18n/ko.ts b/packages/shared/src/i18n/ko.ts index 63292c51..9477ad58 100644 --- a/packages/shared/src/i18n/ko.ts +++ b/packages/shared/src/i18n/ko.ts @@ -351,6 +351,62 @@ export const ko: TranslationKeys = { name: "Extract Audio", description: "Pull the audio track out of a video", }, + "volume-adjust": { + name: "Volume Adjust", + description: "Increase or decrease audio volume by a fixed gain", + }, + "normalize-audio": { + name: "Normalize Audio", + description: "Even out loudness to broadcast standard levels", + }, + "fade-audio": { + name: "Fade Audio", + description: "Add fade-in and fade-out effects to audio", + }, + "reverse-audio": { + name: "Reverse Audio", + description: "Play audio backwards", + }, + "audio-speed": { + name: "Audio Speed", + description: "Speed up or slow down audio playback", + }, + "pitch-shift": { + name: "Pitch Shift", + description: "Raise or lower pitch by semitones without changing speed", + }, + "audio-channels": { + name: "Audio Channels", + description: "Convert between mono, stereo, or swap left and right channels", + }, + "silence-removal": { + name: "Silence Removal", + description: "Strip silent sections from audio", + }, + "noise-reduction": { + name: "Noise Reduction", + description: "Reduce background noise with FFT-based denoising", + }, + "merge-audio": { + name: "Merge Audio", + description: "Combine multiple audio files into one", + }, + "split-audio": { + name: "Split Audio", + description: "Split audio by time, equal parts, or silence detection", + }, + "ringtone-maker": { + name: "Ringtone Maker", + description: "Create a ringtone clip from any audio file", + }, + "waveform-image": { + name: "Waveform Image", + description: "Generate a waveform visualization as a PNG image", + }, + "audio-metadata": { + name: "Audio Metadata", + description: "View, edit, or strip audio tags (ID3)", + }, "merge-pdf": { name: "Merge PDFs", description: "Combine multiple PDFs into one", @@ -1659,6 +1715,117 @@ export const ko: TranslationKeys = { submitBatch: "Extract ({count} files)", progressLabel: "Extracting", }, + "volume-adjust": { + "gain-db": "Gain (dB)", + submit: "Adjust Volume", + submitBatch: "Adjust Volume ({count} files)", + progressLabel: "Adjusting volume", + }, + "normalize-audio": { + hint: "EBU R128 loudness normalization", + submit: "Normalize", + submitBatch: "Normalize ({count} files)", + progressLabel: "Normalizing", + }, + "fade-audio": { + "fade-in-s": "Fade in (seconds)", + "fade-out-s": "Fade out (seconds)", + submit: "Apply Fade", + submitBatch: "Apply Fade ({count} files)", + progressLabel: "Applying fade", + }, + "reverse-audio": { + submit: "Reverse", + submitBatch: "Reverse ({count} files)", + progressLabel: "Reversing", + }, + "audio-speed": { + factor: "Speed factor", + submit: "Change Speed", + submitBatch: "Change Speed ({count} files)", + progressLabel: "Changing speed", + }, + "pitch-shift": { + semitones: "Semitones", + hint: "0 semitones is not allowed", + submit: "Shift Pitch", + submitBatch: "Shift Pitch ({count} files)", + progressLabel: "Shifting pitch", + }, + "audio-channels": { + mode: "Channel mode", + "stereo-to-mono": "Stereo to mono", + "mono-to-stereo": "Mono to stereo", + swap: "Swap left/right", + submit: "Convert Channels", + submitBatch: "Convert Channels ({count} files)", + progressLabel: "Converting channels", + }, + "silence-removal": { + "threshold-db": "Silence threshold (dB)", + "min-silence-s": "Minimum silence duration (seconds)", + submit: "Remove Silence", + submitBatch: "Remove Silence ({count} files)", + progressLabel: "Removing silence", + }, + "noise-reduction": { + strength: "Strength", + light: "Light", + medium: "Medium", + strong: "Strong", + submit: "Reduce Noise", + submitBatch: "Reduce Noise ({count} files)", + progressLabel: "Reducing noise", + }, + "merge-audio": { + format: "Output format", + mp3: "MP3", + wav: "WAV", + flac: "FLAC", + m4a: "M4A", + "order-hint": "Files will be joined in upload order", + submit: "Merge Audio", + submitBatch: "Merge Audio ({count} files)", + progressLabel: "Merging audio", + }, + "split-audio": { + mode: "Split mode", + time: "By time", + parts: "Equal parts", + silence: "By silence", + "segment-s": "Segment length (seconds)", + "num-parts": "Number of parts", + "threshold-db": "Silence threshold (dB)", + "min-silence-s": "Minimum silence duration (seconds)", + submit: "Split Audio", + submitBatch: "Split Audio ({count} files)", + progressLabel: "Splitting audio", + }, + "ringtone-maker": { + "start-s": "Start time (seconds)", + "duration-s": "Duration (seconds)", + "max-hint": "Maximum 30 seconds", + submit: "Create Ringtone", + submitBatch: "Create Ringtone ({count} files)", + progressLabel: "Creating ringtone", + }, + "waveform-image": { + width: "Width (px)", + height: "Height (px)", + color: "Waveform color", + submit: "Generate Waveform", + submitBatch: "Generate Waveform ({count} files)", + progressLabel: "Generating waveform", + }, + "audio-metadata": { + strip: "Strip all metadata", + title: "Title", + artist: "Artist", + album: "Album", + submit: "Apply Metadata", + submitBatch: "Apply Metadata ({count} files)", + progressLabel: "Applying metadata", + }, "merge-pdf": { addFiles: "Add PDFs to merge", needTwo: "At least two PDFs are required", diff --git a/packages/shared/src/i18n/nl.ts b/packages/shared/src/i18n/nl.ts index 43676422..929a3552 100644 --- a/packages/shared/src/i18n/nl.ts +++ b/packages/shared/src/i18n/nl.ts @@ -401,6 +401,62 @@ export const nl: TranslationKeys = { name: "Extract Audio", description: "Pull the audio track out of a video", }, + "volume-adjust": { + name: "Volume Adjust", + description: "Increase or decrease audio volume by a fixed gain", + }, + "normalize-audio": { + name: "Normalize Audio", + description: "Even out loudness to broadcast standard levels", + }, + "fade-audio": { + name: "Fade Audio", + description: "Add fade-in and fade-out effects to audio", + }, + "reverse-audio": { + name: "Reverse Audio", + description: "Play audio backwards", + }, + "audio-speed": { + name: "Audio Speed", + description: "Speed up or slow down audio playback", + }, + "pitch-shift": { + name: "Pitch Shift", + description: "Raise or lower pitch by semitones without changing speed", + }, + "audio-channels": { + name: "Audio Channels", + description: "Convert between mono, stereo, or swap left and right channels", + }, + "silence-removal": { + name: "Silence Removal", + description: "Strip silent sections from audio", + }, + "noise-reduction": { + name: "Noise Reduction", + description: "Reduce background noise with FFT-based denoising", + }, + "merge-audio": { + name: "Merge Audio", + description: "Combine multiple audio files into one", + }, + "split-audio": { + name: "Split Audio", + description: "Split audio by time, equal parts, or silence detection", + }, + "ringtone-maker": { + name: "Ringtone Maker", + description: "Create a ringtone clip from any audio file", + }, + "waveform-image": { + name: "Waveform Image", + description: "Generate a waveform visualization as a PNG image", + }, + "audio-metadata": { + name: "Audio Metadata", + description: "View, edit, or strip audio tags (ID3)", + }, "merge-pdf": { name: "Merge PDFs", description: "Combine multiple PDFs into one", @@ -1718,6 +1774,117 @@ export const nl: TranslationKeys = { submitBatch: "Extract ({count} files)", progressLabel: "Extracting", }, + "volume-adjust": { + "gain-db": "Gain (dB)", + submit: "Adjust Volume", + submitBatch: "Adjust Volume ({count} files)", + progressLabel: "Adjusting volume", + }, + "normalize-audio": { + hint: "EBU R128 loudness normalization", + submit: "Normalize", + submitBatch: "Normalize ({count} files)", + progressLabel: "Normalizing", + }, + "fade-audio": { + "fade-in-s": "Fade in (seconds)", + "fade-out-s": "Fade out (seconds)", + submit: "Apply Fade", + submitBatch: "Apply Fade ({count} files)", + progressLabel: "Applying fade", + }, + "reverse-audio": { + submit: "Reverse", + submitBatch: "Reverse ({count} files)", + progressLabel: "Reversing", + }, + "audio-speed": { + factor: "Speed factor", + submit: "Change Speed", + submitBatch: "Change Speed ({count} files)", + progressLabel: "Changing speed", + }, + "pitch-shift": { + semitones: "Semitones", + hint: "0 semitones is not allowed", + submit: "Shift Pitch", + submitBatch: "Shift Pitch ({count} files)", + progressLabel: "Shifting pitch", + }, + "audio-channels": { + mode: "Channel mode", + "stereo-to-mono": "Stereo to mono", + "mono-to-stereo": "Mono to stereo", + swap: "Swap left/right", + submit: "Convert Channels", + submitBatch: "Convert Channels ({count} files)", + progressLabel: "Converting channels", + }, + "silence-removal": { + "threshold-db": "Silence threshold (dB)", + "min-silence-s": "Minimum silence duration (seconds)", + submit: "Remove Silence", + submitBatch: "Remove Silence ({count} files)", + progressLabel: "Removing silence", + }, + "noise-reduction": { + strength: "Strength", + light: "Light", + medium: "Medium", + strong: "Strong", + submit: "Reduce Noise", + submitBatch: "Reduce Noise ({count} files)", + progressLabel: "Reducing noise", + }, + "merge-audio": { + format: "Output format", + mp3: "MP3", + wav: "WAV", + flac: "FLAC", + m4a: "M4A", + "order-hint": "Files will be joined in upload order", + submit: "Merge Audio", + submitBatch: "Merge Audio ({count} files)", + progressLabel: "Merging audio", + }, + "split-audio": { + mode: "Split mode", + time: "By time", + parts: "Equal parts", + silence: "By silence", + "segment-s": "Segment length (seconds)", + "num-parts": "Number of parts", + "threshold-db": "Silence threshold (dB)", + "min-silence-s": "Minimum silence duration (seconds)", + submit: "Split Audio", + submitBatch: "Split Audio ({count} files)", + progressLabel: "Splitting audio", + }, + "ringtone-maker": { + "start-s": "Start time (seconds)", + "duration-s": "Duration (seconds)", + "max-hint": "Maximum 30 seconds", + submit: "Create Ringtone", + submitBatch: "Create Ringtone ({count} files)", + progressLabel: "Creating ringtone", + }, + "waveform-image": { + width: "Width (px)", + height: "Height (px)", + color: "Waveform color", + submit: "Generate Waveform", + submitBatch: "Generate Waveform ({count} files)", + progressLabel: "Generating waveform", + }, + "audio-metadata": { + strip: "Strip all metadata", + title: "Title", + artist: "Artist", + album: "Album", + submit: "Apply Metadata", + submitBatch: "Apply Metadata ({count} files)", + progressLabel: "Applying metadata", + }, "merge-pdf": { addFiles: "Add PDFs to merge", needTwo: "At least two PDFs are required", diff --git a/packages/shared/src/i18n/pl.ts b/packages/shared/src/i18n/pl.ts index 817c03ac..53508222 100644 --- a/packages/shared/src/i18n/pl.ts +++ b/packages/shared/src/i18n/pl.ts @@ -402,6 +402,62 @@ export const pl: TranslationKeys = { name: "Extract Audio", description: "Pull the audio track out of a video", }, + "volume-adjust": { + name: "Volume Adjust", + description: "Increase or decrease audio volume by a fixed gain", + }, + "normalize-audio": { + name: "Normalize Audio", + description: "Even out loudness to broadcast standard levels", + }, + "fade-audio": { + name: "Fade Audio", + description: "Add fade-in and fade-out effects to audio", + }, + "reverse-audio": { + name: "Reverse Audio", + description: "Play audio backwards", + }, + "audio-speed": { + name: "Audio Speed", + description: "Speed up or slow down audio playback", + }, + "pitch-shift": { + name: "Pitch Shift", + description: "Raise or lower pitch by semitones without changing speed", + }, + "audio-channels": { + name: "Audio Channels", + description: "Convert between mono, stereo, or swap left and right channels", + }, + "silence-removal": { + name: "Silence Removal", + description: "Strip silent sections from audio", + }, + "noise-reduction": { + name: "Noise Reduction", + description: "Reduce background noise with FFT-based denoising", + }, + "merge-audio": { + name: "Merge Audio", + description: "Combine multiple audio files into one", + }, + "split-audio": { + name: "Split Audio", + description: "Split audio by time, equal parts, or silence detection", + }, + "ringtone-maker": { + name: "Ringtone Maker", + description: "Create a ringtone clip from any audio file", + }, + "waveform-image": { + name: "Waveform Image", + description: "Generate a waveform visualization as a PNG image", + }, + "audio-metadata": { + name: "Audio Metadata", + description: "View, edit, or strip audio tags (ID3)", + }, "merge-pdf": { name: "Merge PDFs", description: "Combine multiple PDFs into one", @@ -1719,6 +1775,117 @@ export const pl: TranslationKeys = { submitBatch: "Extract ({count} files)", progressLabel: "Extracting", }, + "volume-adjust": { + "gain-db": "Gain (dB)", + submit: "Adjust Volume", + submitBatch: "Adjust Volume ({count} files)", + progressLabel: "Adjusting volume", + }, + "normalize-audio": { + hint: "EBU R128 loudness normalization", + submit: "Normalize", + submitBatch: "Normalize ({count} files)", + progressLabel: "Normalizing", + }, + "fade-audio": { + "fade-in-s": "Fade in (seconds)", + "fade-out-s": "Fade out (seconds)", + submit: "Apply Fade", + submitBatch: "Apply Fade ({count} files)", + progressLabel: "Applying fade", + }, + "reverse-audio": { + submit: "Reverse", + submitBatch: "Reverse ({count} files)", + progressLabel: "Reversing", + }, + "audio-speed": { + factor: "Speed factor", + submit: "Change Speed", + submitBatch: "Change Speed ({count} files)", + progressLabel: "Changing speed", + }, + "pitch-shift": { + semitones: "Semitones", + hint: "0 semitones is not allowed", + submit: "Shift Pitch", + submitBatch: "Shift Pitch ({count} files)", + progressLabel: "Shifting pitch", + }, + "audio-channels": { + mode: "Channel mode", + "stereo-to-mono": "Stereo to mono", + "mono-to-stereo": "Mono to stereo", + swap: "Swap left/right", + submit: "Convert Channels", + submitBatch: "Convert Channels ({count} files)", + progressLabel: "Converting channels", + }, + "silence-removal": { + "threshold-db": "Silence threshold (dB)", + "min-silence-s": "Minimum silence duration (seconds)", + submit: "Remove Silence", + submitBatch: "Remove Silence ({count} files)", + progressLabel: "Removing silence", + }, + "noise-reduction": { + strength: "Strength", + light: "Light", + medium: "Medium", + strong: "Strong", + submit: "Reduce Noise", + submitBatch: "Reduce Noise ({count} files)", + progressLabel: "Reducing noise", + }, + "merge-audio": { + format: "Output format", + mp3: "MP3", + wav: "WAV", + flac: "FLAC", + m4a: "M4A", + "order-hint": "Files will be joined in upload order", + submit: "Merge Audio", + submitBatch: "Merge Audio ({count} files)", + progressLabel: "Merging audio", + }, + "split-audio": { + mode: "Split mode", + time: "By time", + parts: "Equal parts", + silence: "By silence", + "segment-s": "Segment length (seconds)", + "num-parts": "Number of parts", + "threshold-db": "Silence threshold (dB)", + "min-silence-s": "Minimum silence duration (seconds)", + submit: "Split Audio", + submitBatch: "Split Audio ({count} files)", + progressLabel: "Splitting audio", + }, + "ringtone-maker": { + "start-s": "Start time (seconds)", + "duration-s": "Duration (seconds)", + "max-hint": "Maximum 30 seconds", + submit: "Create Ringtone", + submitBatch: "Create Ringtone ({count} files)", + progressLabel: "Creating ringtone", + }, + "waveform-image": { + width: "Width (px)", + height: "Height (px)", + color: "Waveform color", + submit: "Generate Waveform", + submitBatch: "Generate Waveform ({count} files)", + progressLabel: "Generating waveform", + }, + "audio-metadata": { + strip: "Strip all metadata", + title: "Title", + artist: "Artist", + album: "Album", + submit: "Apply Metadata", + submitBatch: "Apply Metadata ({count} files)", + progressLabel: "Applying metadata", + }, "merge-pdf": { addFiles: "Add PDFs to merge", needTwo: "At least two PDFs are required", diff --git a/packages/shared/src/i18n/pt-BR.ts b/packages/shared/src/i18n/pt-BR.ts index 69e71041..0a708e99 100644 --- a/packages/shared/src/i18n/pt-BR.ts +++ b/packages/shared/src/i18n/pt-BR.ts @@ -399,6 +399,62 @@ export const ptBR: TranslationKeys = { name: "Extract Audio", description: "Pull the audio track out of a video", }, + "volume-adjust": { + name: "Volume Adjust", + description: "Increase or decrease audio volume by a fixed gain", + }, + "normalize-audio": { + name: "Normalize Audio", + description: "Even out loudness to broadcast standard levels", + }, + "fade-audio": { + name: "Fade Audio", + description: "Add fade-in and fade-out effects to audio", + }, + "reverse-audio": { + name: "Reverse Audio", + description: "Play audio backwards", + }, + "audio-speed": { + name: "Audio Speed", + description: "Speed up or slow down audio playback", + }, + "pitch-shift": { + name: "Pitch Shift", + description: "Raise or lower pitch by semitones without changing speed", + }, + "audio-channels": { + name: "Audio Channels", + description: "Convert between mono, stereo, or swap left and right channels", + }, + "silence-removal": { + name: "Silence Removal", + description: "Strip silent sections from audio", + }, + "noise-reduction": { + name: "Noise Reduction", + description: "Reduce background noise with FFT-based denoising", + }, + "merge-audio": { + name: "Merge Audio", + description: "Combine multiple audio files into one", + }, + "split-audio": { + name: "Split Audio", + description: "Split audio by time, equal parts, or silence detection", + }, + "ringtone-maker": { + name: "Ringtone Maker", + description: "Create a ringtone clip from any audio file", + }, + "waveform-image": { + name: "Waveform Image", + description: "Generate a waveform visualization as a PNG image", + }, + "audio-metadata": { + name: "Audio Metadata", + description: "View, edit, or strip audio tags (ID3)", + }, "merge-pdf": { name: "Merge PDFs", description: "Combine multiple PDFs into one", @@ -1715,6 +1771,117 @@ export const ptBR: TranslationKeys = { submitBatch: "Extract ({count} files)", progressLabel: "Extracting", }, + "volume-adjust": { + "gain-db": "Gain (dB)", + submit: "Adjust Volume", + submitBatch: "Adjust Volume ({count} files)", + progressLabel: "Adjusting volume", + }, + "normalize-audio": { + hint: "EBU R128 loudness normalization", + submit: "Normalize", + submitBatch: "Normalize ({count} files)", + progressLabel: "Normalizing", + }, + "fade-audio": { + "fade-in-s": "Fade in (seconds)", + "fade-out-s": "Fade out (seconds)", + submit: "Apply Fade", + submitBatch: "Apply Fade ({count} files)", + progressLabel: "Applying fade", + }, + "reverse-audio": { + submit: "Reverse", + submitBatch: "Reverse ({count} files)", + progressLabel: "Reversing", + }, + "audio-speed": { + factor: "Speed factor", + submit: "Change Speed", + submitBatch: "Change Speed ({count} files)", + progressLabel: "Changing speed", + }, + "pitch-shift": { + semitones: "Semitones", + hint: "0 semitones is not allowed", + submit: "Shift Pitch", + submitBatch: "Shift Pitch ({count} files)", + progressLabel: "Shifting pitch", + }, + "audio-channels": { + mode: "Channel mode", + "stereo-to-mono": "Stereo to mono", + "mono-to-stereo": "Mono to stereo", + swap: "Swap left/right", + submit: "Convert Channels", + submitBatch: "Convert Channels ({count} files)", + progressLabel: "Converting channels", + }, + "silence-removal": { + "threshold-db": "Silence threshold (dB)", + "min-silence-s": "Minimum silence duration (seconds)", + submit: "Remove Silence", + submitBatch: "Remove Silence ({count} files)", + progressLabel: "Removing silence", + }, + "noise-reduction": { + strength: "Strength", + light: "Light", + medium: "Medium", + strong: "Strong", + submit: "Reduce Noise", + submitBatch: "Reduce Noise ({count} files)", + progressLabel: "Reducing noise", + }, + "merge-audio": { + format: "Output format", + mp3: "MP3", + wav: "WAV", + flac: "FLAC", + m4a: "M4A", + "order-hint": "Files will be joined in upload order", + submit: "Merge Audio", + submitBatch: "Merge Audio ({count} files)", + progressLabel: "Merging audio", + }, + "split-audio": { + mode: "Split mode", + time: "By time", + parts: "Equal parts", + silence: "By silence", + "segment-s": "Segment length (seconds)", + "num-parts": "Number of parts", + "threshold-db": "Silence threshold (dB)", + "min-silence-s": "Minimum silence duration (seconds)", + submit: "Split Audio", + submitBatch: "Split Audio ({count} files)", + progressLabel: "Splitting audio", + }, + "ringtone-maker": { + "start-s": "Start time (seconds)", + "duration-s": "Duration (seconds)", + "max-hint": "Maximum 30 seconds", + submit: "Create Ringtone", + submitBatch: "Create Ringtone ({count} files)", + progressLabel: "Creating ringtone", + }, + "waveform-image": { + width: "Width (px)", + height: "Height (px)", + color: "Waveform color", + submit: "Generate Waveform", + submitBatch: "Generate Waveform ({count} files)", + progressLabel: "Generating waveform", + }, + "audio-metadata": { + strip: "Strip all metadata", + title: "Title", + artist: "Artist", + album: "Album", + submit: "Apply Metadata", + submitBatch: "Apply Metadata ({count} files)", + progressLabel: "Applying metadata", + }, "merge-pdf": { addFiles: "Add PDFs to merge", needTwo: "At least two PDFs are required", diff --git a/packages/shared/src/i18n/ru.ts b/packages/shared/src/i18n/ru.ts index 4da27a4d..190261ba 100644 --- a/packages/shared/src/i18n/ru.ts +++ b/packages/shared/src/i18n/ru.ts @@ -401,6 +401,62 @@ export const ru: TranslationKeys = { name: "Extract Audio", description: "Pull the audio track out of a video", }, + "volume-adjust": { + name: "Volume Adjust", + description: "Increase or decrease audio volume by a fixed gain", + }, + "normalize-audio": { + name: "Normalize Audio", + description: "Even out loudness to broadcast standard levels", + }, + "fade-audio": { + name: "Fade Audio", + description: "Add fade-in and fade-out effects to audio", + }, + "reverse-audio": { + name: "Reverse Audio", + description: "Play audio backwards", + }, + "audio-speed": { + name: "Audio Speed", + description: "Speed up or slow down audio playback", + }, + "pitch-shift": { + name: "Pitch Shift", + description: "Raise or lower pitch by semitones without changing speed", + }, + "audio-channels": { + name: "Audio Channels", + description: "Convert between mono, stereo, or swap left and right channels", + }, + "silence-removal": { + name: "Silence Removal", + description: "Strip silent sections from audio", + }, + "noise-reduction": { + name: "Noise Reduction", + description: "Reduce background noise with FFT-based denoising", + }, + "merge-audio": { + name: "Merge Audio", + description: "Combine multiple audio files into one", + }, + "split-audio": { + name: "Split Audio", + description: "Split audio by time, equal parts, or silence detection", + }, + "ringtone-maker": { + name: "Ringtone Maker", + description: "Create a ringtone clip from any audio file", + }, + "waveform-image": { + name: "Waveform Image", + description: "Generate a waveform visualization as a PNG image", + }, + "audio-metadata": { + name: "Audio Metadata", + description: "View, edit, or strip audio tags (ID3)", + }, "merge-pdf": { name: "Merge PDFs", description: "Combine multiple PDFs into one", @@ -1717,6 +1773,117 @@ export const ru: TranslationKeys = { submitBatch: "Extract ({count} files)", progressLabel: "Extracting", }, + "volume-adjust": { + "gain-db": "Gain (dB)", + submit: "Adjust Volume", + submitBatch: "Adjust Volume ({count} files)", + progressLabel: "Adjusting volume", + }, + "normalize-audio": { + hint: "EBU R128 loudness normalization", + submit: "Normalize", + submitBatch: "Normalize ({count} files)", + progressLabel: "Normalizing", + }, + "fade-audio": { + "fade-in-s": "Fade in (seconds)", + "fade-out-s": "Fade out (seconds)", + submit: "Apply Fade", + submitBatch: "Apply Fade ({count} files)", + progressLabel: "Applying fade", + }, + "reverse-audio": { + submit: "Reverse", + submitBatch: "Reverse ({count} files)", + progressLabel: "Reversing", + }, + "audio-speed": { + factor: "Speed factor", + submit: "Change Speed", + submitBatch: "Change Speed ({count} files)", + progressLabel: "Changing speed", + }, + "pitch-shift": { + semitones: "Semitones", + hint: "0 semitones is not allowed", + submit: "Shift Pitch", + submitBatch: "Shift Pitch ({count} files)", + progressLabel: "Shifting pitch", + }, + "audio-channels": { + mode: "Channel mode", + "stereo-to-mono": "Stereo to mono", + "mono-to-stereo": "Mono to stereo", + swap: "Swap left/right", + submit: "Convert Channels", + submitBatch: "Convert Channels ({count} files)", + progressLabel: "Converting channels", + }, + "silence-removal": { + "threshold-db": "Silence threshold (dB)", + "min-silence-s": "Minimum silence duration (seconds)", + submit: "Remove Silence", + submitBatch: "Remove Silence ({count} files)", + progressLabel: "Removing silence", + }, + "noise-reduction": { + strength: "Strength", + light: "Light", + medium: "Medium", + strong: "Strong", + submit: "Reduce Noise", + submitBatch: "Reduce Noise ({count} files)", + progressLabel: "Reducing noise", + }, + "merge-audio": { + format: "Output format", + mp3: "MP3", + wav: "WAV", + flac: "FLAC", + m4a: "M4A", + "order-hint": "Files will be joined in upload order", + submit: "Merge Audio", + submitBatch: "Merge Audio ({count} files)", + progressLabel: "Merging audio", + }, + "split-audio": { + mode: "Split mode", + time: "By time", + parts: "Equal parts", + silence: "By silence", + "segment-s": "Segment length (seconds)", + "num-parts": "Number of parts", + "threshold-db": "Silence threshold (dB)", + "min-silence-s": "Minimum silence duration (seconds)", + submit: "Split Audio", + submitBatch: "Split Audio ({count} files)", + progressLabel: "Splitting audio", + }, + "ringtone-maker": { + "start-s": "Start time (seconds)", + "duration-s": "Duration (seconds)", + "max-hint": "Maximum 30 seconds", + submit: "Create Ringtone", + submitBatch: "Create Ringtone ({count} files)", + progressLabel: "Creating ringtone", + }, + "waveform-image": { + width: "Width (px)", + height: "Height (px)", + color: "Waveform color", + submit: "Generate Waveform", + submitBatch: "Generate Waveform ({count} files)", + progressLabel: "Generating waveform", + }, + "audio-metadata": { + strip: "Strip all metadata", + title: "Title", + artist: "Artist", + album: "Album", + submit: "Apply Metadata", + submitBatch: "Apply Metadata ({count} files)", + progressLabel: "Applying metadata", + }, "merge-pdf": { addFiles: "Add PDFs to merge", needTwo: "At least two PDFs are required", diff --git a/packages/shared/src/i18n/sv.ts b/packages/shared/src/i18n/sv.ts index a6e5d462..cd1c14ec 100644 --- a/packages/shared/src/i18n/sv.ts +++ b/packages/shared/src/i18n/sv.ts @@ -399,6 +399,62 @@ export const sv: TranslationKeys = { name: "Extract Audio", description: "Pull the audio track out of a video", }, + "volume-adjust": { + name: "Volume Adjust", + description: "Increase or decrease audio volume by a fixed gain", + }, + "normalize-audio": { + name: "Normalize Audio", + description: "Even out loudness to broadcast standard levels", + }, + "fade-audio": { + name: "Fade Audio", + description: "Add fade-in and fade-out effects to audio", + }, + "reverse-audio": { + name: "Reverse Audio", + description: "Play audio backwards", + }, + "audio-speed": { + name: "Audio Speed", + description: "Speed up or slow down audio playback", + }, + "pitch-shift": { + name: "Pitch Shift", + description: "Raise or lower pitch by semitones without changing speed", + }, + "audio-channels": { + name: "Audio Channels", + description: "Convert between mono, stereo, or swap left and right channels", + }, + "silence-removal": { + name: "Silence Removal", + description: "Strip silent sections from audio", + }, + "noise-reduction": { + name: "Noise Reduction", + description: "Reduce background noise with FFT-based denoising", + }, + "merge-audio": { + name: "Merge Audio", + description: "Combine multiple audio files into one", + }, + "split-audio": { + name: "Split Audio", + description: "Split audio by time, equal parts, or silence detection", + }, + "ringtone-maker": { + name: "Ringtone Maker", + description: "Create a ringtone clip from any audio file", + }, + "waveform-image": { + name: "Waveform Image", + description: "Generate a waveform visualization as a PNG image", + }, + "audio-metadata": { + name: "Audio Metadata", + description: "View, edit, or strip audio tags (ID3)", + }, "merge-pdf": { name: "Merge PDFs", description: "Combine multiple PDFs into one", @@ -1714,6 +1770,117 @@ export const sv: TranslationKeys = { submitBatch: "Extract ({count} files)", progressLabel: "Extracting", }, + "volume-adjust": { + "gain-db": "Gain (dB)", + submit: "Adjust Volume", + submitBatch: "Adjust Volume ({count} files)", + progressLabel: "Adjusting volume", + }, + "normalize-audio": { + hint: "EBU R128 loudness normalization", + submit: "Normalize", + submitBatch: "Normalize ({count} files)", + progressLabel: "Normalizing", + }, + "fade-audio": { + "fade-in-s": "Fade in (seconds)", + "fade-out-s": "Fade out (seconds)", + submit: "Apply Fade", + submitBatch: "Apply Fade ({count} files)", + progressLabel: "Applying fade", + }, + "reverse-audio": { + submit: "Reverse", + submitBatch: "Reverse ({count} files)", + progressLabel: "Reversing", + }, + "audio-speed": { + factor: "Speed factor", + submit: "Change Speed", + submitBatch: "Change Speed ({count} files)", + progressLabel: "Changing speed", + }, + "pitch-shift": { + semitones: "Semitones", + hint: "0 semitones is not allowed", + submit: "Shift Pitch", + submitBatch: "Shift Pitch ({count} files)", + progressLabel: "Shifting pitch", + }, + "audio-channels": { + mode: "Channel mode", + "stereo-to-mono": "Stereo to mono", + "mono-to-stereo": "Mono to stereo", + swap: "Swap left/right", + submit: "Convert Channels", + submitBatch: "Convert Channels ({count} files)", + progressLabel: "Converting channels", + }, + "silence-removal": { + "threshold-db": "Silence threshold (dB)", + "min-silence-s": "Minimum silence duration (seconds)", + submit: "Remove Silence", + submitBatch: "Remove Silence ({count} files)", + progressLabel: "Removing silence", + }, + "noise-reduction": { + strength: "Strength", + light: "Light", + medium: "Medium", + strong: "Strong", + submit: "Reduce Noise", + submitBatch: "Reduce Noise ({count} files)", + progressLabel: "Reducing noise", + }, + "merge-audio": { + format: "Output format", + mp3: "MP3", + wav: "WAV", + flac: "FLAC", + m4a: "M4A", + "order-hint": "Files will be joined in upload order", + submit: "Merge Audio", + submitBatch: "Merge Audio ({count} files)", + progressLabel: "Merging audio", + }, + "split-audio": { + mode: "Split mode", + time: "By time", + parts: "Equal parts", + silence: "By silence", + "segment-s": "Segment length (seconds)", + "num-parts": "Number of parts", + "threshold-db": "Silence threshold (dB)", + "min-silence-s": "Minimum silence duration (seconds)", + submit: "Split Audio", + submitBatch: "Split Audio ({count} files)", + progressLabel: "Splitting audio", + }, + "ringtone-maker": { + "start-s": "Start time (seconds)", + "duration-s": "Duration (seconds)", + "max-hint": "Maximum 30 seconds", + submit: "Create Ringtone", + submitBatch: "Create Ringtone ({count} files)", + progressLabel: "Creating ringtone", + }, + "waveform-image": { + width: "Width (px)", + height: "Height (px)", + color: "Waveform color", + submit: "Generate Waveform", + submitBatch: "Generate Waveform ({count} files)", + progressLabel: "Generating waveform", + }, + "audio-metadata": { + strip: "Strip all metadata", + title: "Title", + artist: "Artist", + album: "Album", + submit: "Apply Metadata", + submitBatch: "Apply Metadata ({count} files)", + progressLabel: "Applying metadata", + }, "merge-pdf": { addFiles: "Add PDFs to merge", needTwo: "At least two PDFs are required", diff --git a/packages/shared/src/i18n/th.ts b/packages/shared/src/i18n/th.ts index 1568e3fc..232ebb06 100644 --- a/packages/shared/src/i18n/th.ts +++ b/packages/shared/src/i18n/th.ts @@ -394,6 +394,62 @@ export const th: TranslationKeys = { name: "Extract Audio", description: "Pull the audio track out of a video", }, + "volume-adjust": { + name: "Volume Adjust", + description: "Increase or decrease audio volume by a fixed gain", + }, + "normalize-audio": { + name: "Normalize Audio", + description: "Even out loudness to broadcast standard levels", + }, + "fade-audio": { + name: "Fade Audio", + description: "Add fade-in and fade-out effects to audio", + }, + "reverse-audio": { + name: "Reverse Audio", + description: "Play audio backwards", + }, + "audio-speed": { + name: "Audio Speed", + description: "Speed up or slow down audio playback", + }, + "pitch-shift": { + name: "Pitch Shift", + description: "Raise or lower pitch by semitones without changing speed", + }, + "audio-channels": { + name: "Audio Channels", + description: "Convert between mono, stereo, or swap left and right channels", + }, + "silence-removal": { + name: "Silence Removal", + description: "Strip silent sections from audio", + }, + "noise-reduction": { + name: "Noise Reduction", + description: "Reduce background noise with FFT-based denoising", + }, + "merge-audio": { + name: "Merge Audio", + description: "Combine multiple audio files into one", + }, + "split-audio": { + name: "Split Audio", + description: "Split audio by time, equal parts, or silence detection", + }, + "ringtone-maker": { + name: "Ringtone Maker", + description: "Create a ringtone clip from any audio file", + }, + "waveform-image": { + name: "Waveform Image", + description: "Generate a waveform visualization as a PNG image", + }, + "audio-metadata": { + name: "Audio Metadata", + description: "View, edit, or strip audio tags (ID3)", + }, "merge-pdf": { name: "Merge PDFs", description: "Combine multiple PDFs into one", @@ -1697,6 +1753,117 @@ export const th: TranslationKeys = { submitBatch: "Extract ({count} files)", progressLabel: "Extracting", }, + "volume-adjust": { + "gain-db": "Gain (dB)", + submit: "Adjust Volume", + submitBatch: "Adjust Volume ({count} files)", + progressLabel: "Adjusting volume", + }, + "normalize-audio": { + hint: "EBU R128 loudness normalization", + submit: "Normalize", + submitBatch: "Normalize ({count} files)", + progressLabel: "Normalizing", + }, + "fade-audio": { + "fade-in-s": "Fade in (seconds)", + "fade-out-s": "Fade out (seconds)", + submit: "Apply Fade", + submitBatch: "Apply Fade ({count} files)", + progressLabel: "Applying fade", + }, + "reverse-audio": { + submit: "Reverse", + submitBatch: "Reverse ({count} files)", + progressLabel: "Reversing", + }, + "audio-speed": { + factor: "Speed factor", + submit: "Change Speed", + submitBatch: "Change Speed ({count} files)", + progressLabel: "Changing speed", + }, + "pitch-shift": { + semitones: "Semitones", + hint: "0 semitones is not allowed", + submit: "Shift Pitch", + submitBatch: "Shift Pitch ({count} files)", + progressLabel: "Shifting pitch", + }, + "audio-channels": { + mode: "Channel mode", + "stereo-to-mono": "Stereo to mono", + "mono-to-stereo": "Mono to stereo", + swap: "Swap left/right", + submit: "Convert Channels", + submitBatch: "Convert Channels ({count} files)", + progressLabel: "Converting channels", + }, + "silence-removal": { + "threshold-db": "Silence threshold (dB)", + "min-silence-s": "Minimum silence duration (seconds)", + submit: "Remove Silence", + submitBatch: "Remove Silence ({count} files)", + progressLabel: "Removing silence", + }, + "noise-reduction": { + strength: "Strength", + light: "Light", + medium: "Medium", + strong: "Strong", + submit: "Reduce Noise", + submitBatch: "Reduce Noise ({count} files)", + progressLabel: "Reducing noise", + }, + "merge-audio": { + format: "Output format", + mp3: "MP3", + wav: "WAV", + flac: "FLAC", + m4a: "M4A", + "order-hint": "Files will be joined in upload order", + submit: "Merge Audio", + submitBatch: "Merge Audio ({count} files)", + progressLabel: "Merging audio", + }, + "split-audio": { + mode: "Split mode", + time: "By time", + parts: "Equal parts", + silence: "By silence", + "segment-s": "Segment length (seconds)", + "num-parts": "Number of parts", + "threshold-db": "Silence threshold (dB)", + "min-silence-s": "Minimum silence duration (seconds)", + submit: "Split Audio", + submitBatch: "Split Audio ({count} files)", + progressLabel: "Splitting audio", + }, + "ringtone-maker": { + "start-s": "Start time (seconds)", + "duration-s": "Duration (seconds)", + "max-hint": "Maximum 30 seconds", + submit: "Create Ringtone", + submitBatch: "Create Ringtone ({count} files)", + progressLabel: "Creating ringtone", + }, + "waveform-image": { + width: "Width (px)", + height: "Height (px)", + color: "Waveform color", + submit: "Generate Waveform", + submitBatch: "Generate Waveform ({count} files)", + progressLabel: "Generating waveform", + }, + "audio-metadata": { + strip: "Strip all metadata", + title: "Title", + artist: "Artist", + album: "Album", + submit: "Apply Metadata", + submitBatch: "Apply Metadata ({count} files)", + progressLabel: "Applying metadata", + }, "merge-pdf": { addFiles: "Add PDFs to merge", needTwo: "At least two PDFs are required", diff --git a/packages/shared/src/i18n/tr.ts b/packages/shared/src/i18n/tr.ts index 0e59dddb..d56b389e 100644 --- a/packages/shared/src/i18n/tr.ts +++ b/packages/shared/src/i18n/tr.ts @@ -402,6 +402,62 @@ export const tr: TranslationKeys = { name: "Extract Audio", description: "Pull the audio track out of a video", }, + "volume-adjust": { + name: "Volume Adjust", + description: "Increase or decrease audio volume by a fixed gain", + }, + "normalize-audio": { + name: "Normalize Audio", + description: "Even out loudness to broadcast standard levels", + }, + "fade-audio": { + name: "Fade Audio", + description: "Add fade-in and fade-out effects to audio", + }, + "reverse-audio": { + name: "Reverse Audio", + description: "Play audio backwards", + }, + "audio-speed": { + name: "Audio Speed", + description: "Speed up or slow down audio playback", + }, + "pitch-shift": { + name: "Pitch Shift", + description: "Raise or lower pitch by semitones without changing speed", + }, + "audio-channels": { + name: "Audio Channels", + description: "Convert between mono, stereo, or swap left and right channels", + }, + "silence-removal": { + name: "Silence Removal", + description: "Strip silent sections from audio", + }, + "noise-reduction": { + name: "Noise Reduction", + description: "Reduce background noise with FFT-based denoising", + }, + "merge-audio": { + name: "Merge Audio", + description: "Combine multiple audio files into one", + }, + "split-audio": { + name: "Split Audio", + description: "Split audio by time, equal parts, or silence detection", + }, + "ringtone-maker": { + name: "Ringtone Maker", + description: "Create a ringtone clip from any audio file", + }, + "waveform-image": { + name: "Waveform Image", + description: "Generate a waveform visualization as a PNG image", + }, + "audio-metadata": { + name: "Audio Metadata", + description: "View, edit, or strip audio tags (ID3)", + }, "merge-pdf": { name: "Merge PDFs", description: "Combine multiple PDFs into one", @@ -1719,6 +1775,117 @@ export const tr: TranslationKeys = { submitBatch: "Extract ({count} files)", progressLabel: "Extracting", }, + "volume-adjust": { + "gain-db": "Gain (dB)", + submit: "Adjust Volume", + submitBatch: "Adjust Volume ({count} files)", + progressLabel: "Adjusting volume", + }, + "normalize-audio": { + hint: "EBU R128 loudness normalization", + submit: "Normalize", + submitBatch: "Normalize ({count} files)", + progressLabel: "Normalizing", + }, + "fade-audio": { + "fade-in-s": "Fade in (seconds)", + "fade-out-s": "Fade out (seconds)", + submit: "Apply Fade", + submitBatch: "Apply Fade ({count} files)", + progressLabel: "Applying fade", + }, + "reverse-audio": { + submit: "Reverse", + submitBatch: "Reverse ({count} files)", + progressLabel: "Reversing", + }, + "audio-speed": { + factor: "Speed factor", + submit: "Change Speed", + submitBatch: "Change Speed ({count} files)", + progressLabel: "Changing speed", + }, + "pitch-shift": { + semitones: "Semitones", + hint: "0 semitones is not allowed", + submit: "Shift Pitch", + submitBatch: "Shift Pitch ({count} files)", + progressLabel: "Shifting pitch", + }, + "audio-channels": { + mode: "Channel mode", + "stereo-to-mono": "Stereo to mono", + "mono-to-stereo": "Mono to stereo", + swap: "Swap left/right", + submit: "Convert Channels", + submitBatch: "Convert Channels ({count} files)", + progressLabel: "Converting channels", + }, + "silence-removal": { + "threshold-db": "Silence threshold (dB)", + "min-silence-s": "Minimum silence duration (seconds)", + submit: "Remove Silence", + submitBatch: "Remove Silence ({count} files)", + progressLabel: "Removing silence", + }, + "noise-reduction": { + strength: "Strength", + light: "Light", + medium: "Medium", + strong: "Strong", + submit: "Reduce Noise", + submitBatch: "Reduce Noise ({count} files)", + progressLabel: "Reducing noise", + }, + "merge-audio": { + format: "Output format", + mp3: "MP3", + wav: "WAV", + flac: "FLAC", + m4a: "M4A", + "order-hint": "Files will be joined in upload order", + submit: "Merge Audio", + submitBatch: "Merge Audio ({count} files)", + progressLabel: "Merging audio", + }, + "split-audio": { + mode: "Split mode", + time: "By time", + parts: "Equal parts", + silence: "By silence", + "segment-s": "Segment length (seconds)", + "num-parts": "Number of parts", + "threshold-db": "Silence threshold (dB)", + "min-silence-s": "Minimum silence duration (seconds)", + submit: "Split Audio", + submitBatch: "Split Audio ({count} files)", + progressLabel: "Splitting audio", + }, + "ringtone-maker": { + "start-s": "Start time (seconds)", + "duration-s": "Duration (seconds)", + "max-hint": "Maximum 30 seconds", + submit: "Create Ringtone", + submitBatch: "Create Ringtone ({count} files)", + progressLabel: "Creating ringtone", + }, + "waveform-image": { + width: "Width (px)", + height: "Height (px)", + color: "Waveform color", + submit: "Generate Waveform", + submitBatch: "Generate Waveform ({count} files)", + progressLabel: "Generating waveform", + }, + "audio-metadata": { + strip: "Strip all metadata", + title: "Title", + artist: "Artist", + album: "Album", + submit: "Apply Metadata", + submitBatch: "Apply Metadata ({count} files)", + progressLabel: "Applying metadata", + }, "merge-pdf": { addFiles: "Add PDFs to merge", needTwo: "At least two PDFs are required", diff --git a/packages/shared/src/i18n/uk.ts b/packages/shared/src/i18n/uk.ts index 498594c4..152a7554 100644 --- a/packages/shared/src/i18n/uk.ts +++ b/packages/shared/src/i18n/uk.ts @@ -401,6 +401,62 @@ export const uk: TranslationKeys = { name: "Extract Audio", description: "Pull the audio track out of a video", }, + "volume-adjust": { + name: "Volume Adjust", + description: "Increase or decrease audio volume by a fixed gain", + }, + "normalize-audio": { + name: "Normalize Audio", + description: "Even out loudness to broadcast standard levels", + }, + "fade-audio": { + name: "Fade Audio", + description: "Add fade-in and fade-out effects to audio", + }, + "reverse-audio": { + name: "Reverse Audio", + description: "Play audio backwards", + }, + "audio-speed": { + name: "Audio Speed", + description: "Speed up or slow down audio playback", + }, + "pitch-shift": { + name: "Pitch Shift", + description: "Raise or lower pitch by semitones without changing speed", + }, + "audio-channels": { + name: "Audio Channels", + description: "Convert between mono, stereo, or swap left and right channels", + }, + "silence-removal": { + name: "Silence Removal", + description: "Strip silent sections from audio", + }, + "noise-reduction": { + name: "Noise Reduction", + description: "Reduce background noise with FFT-based denoising", + }, + "merge-audio": { + name: "Merge Audio", + description: "Combine multiple audio files into one", + }, + "split-audio": { + name: "Split Audio", + description: "Split audio by time, equal parts, or silence detection", + }, + "ringtone-maker": { + name: "Ringtone Maker", + description: "Create a ringtone clip from any audio file", + }, + "waveform-image": { + name: "Waveform Image", + description: "Generate a waveform visualization as a PNG image", + }, + "audio-metadata": { + name: "Audio Metadata", + description: "View, edit, or strip audio tags (ID3)", + }, "merge-pdf": { name: "Merge PDFs", description: "Combine multiple PDFs into one", @@ -1717,6 +1773,117 @@ export const uk: TranslationKeys = { submitBatch: "Extract ({count} files)", progressLabel: "Extracting", }, + "volume-adjust": { + "gain-db": "Gain (dB)", + submit: "Adjust Volume", + submitBatch: "Adjust Volume ({count} files)", + progressLabel: "Adjusting volume", + }, + "normalize-audio": { + hint: "EBU R128 loudness normalization", + submit: "Normalize", + submitBatch: "Normalize ({count} files)", + progressLabel: "Normalizing", + }, + "fade-audio": { + "fade-in-s": "Fade in (seconds)", + "fade-out-s": "Fade out (seconds)", + submit: "Apply Fade", + submitBatch: "Apply Fade ({count} files)", + progressLabel: "Applying fade", + }, + "reverse-audio": { + submit: "Reverse", + submitBatch: "Reverse ({count} files)", + progressLabel: "Reversing", + }, + "audio-speed": { + factor: "Speed factor", + submit: "Change Speed", + submitBatch: "Change Speed ({count} files)", + progressLabel: "Changing speed", + }, + "pitch-shift": { + semitones: "Semitones", + hint: "0 semitones is not allowed", + submit: "Shift Pitch", + submitBatch: "Shift Pitch ({count} files)", + progressLabel: "Shifting pitch", + }, + "audio-channels": { + mode: "Channel mode", + "stereo-to-mono": "Stereo to mono", + "mono-to-stereo": "Mono to stereo", + swap: "Swap left/right", + submit: "Convert Channels", + submitBatch: "Convert Channels ({count} files)", + progressLabel: "Converting channels", + }, + "silence-removal": { + "threshold-db": "Silence threshold (dB)", + "min-silence-s": "Minimum silence duration (seconds)", + submit: "Remove Silence", + submitBatch: "Remove Silence ({count} files)", + progressLabel: "Removing silence", + }, + "noise-reduction": { + strength: "Strength", + light: "Light", + medium: "Medium", + strong: "Strong", + submit: "Reduce Noise", + submitBatch: "Reduce Noise ({count} files)", + progressLabel: "Reducing noise", + }, + "merge-audio": { + format: "Output format", + mp3: "MP3", + wav: "WAV", + flac: "FLAC", + m4a: "M4A", + "order-hint": "Files will be joined in upload order", + submit: "Merge Audio", + submitBatch: "Merge Audio ({count} files)", + progressLabel: "Merging audio", + }, + "split-audio": { + mode: "Split mode", + time: "By time", + parts: "Equal parts", + silence: "By silence", + "segment-s": "Segment length (seconds)", + "num-parts": "Number of parts", + "threshold-db": "Silence threshold (dB)", + "min-silence-s": "Minimum silence duration (seconds)", + submit: "Split Audio", + submitBatch: "Split Audio ({count} files)", + progressLabel: "Splitting audio", + }, + "ringtone-maker": { + "start-s": "Start time (seconds)", + "duration-s": "Duration (seconds)", + "max-hint": "Maximum 30 seconds", + submit: "Create Ringtone", + submitBatch: "Create Ringtone ({count} files)", + progressLabel: "Creating ringtone", + }, + "waveform-image": { + width: "Width (px)", + height: "Height (px)", + color: "Waveform color", + submit: "Generate Waveform", + submitBatch: "Generate Waveform ({count} files)", + progressLabel: "Generating waveform", + }, + "audio-metadata": { + strip: "Strip all metadata", + title: "Title", + artist: "Artist", + album: "Album", + submit: "Apply Metadata", + submitBatch: "Apply Metadata ({count} files)", + progressLabel: "Applying metadata", + }, "merge-pdf": { addFiles: "Add PDFs to merge", needTwo: "At least two PDFs are required", diff --git a/packages/shared/src/i18n/vi.ts b/packages/shared/src/i18n/vi.ts index 02ab12b7..1793f0eb 100644 --- a/packages/shared/src/i18n/vi.ts +++ b/packages/shared/src/i18n/vi.ts @@ -402,6 +402,62 @@ export const vi: TranslationKeys = { name: "Extract Audio", description: "Pull the audio track out of a video", }, + "volume-adjust": { + name: "Volume Adjust", + description: "Increase or decrease audio volume by a fixed gain", + }, + "normalize-audio": { + name: "Normalize Audio", + description: "Even out loudness to broadcast standard levels", + }, + "fade-audio": { + name: "Fade Audio", + description: "Add fade-in and fade-out effects to audio", + }, + "reverse-audio": { + name: "Reverse Audio", + description: "Play audio backwards", + }, + "audio-speed": { + name: "Audio Speed", + description: "Speed up or slow down audio playback", + }, + "pitch-shift": { + name: "Pitch Shift", + description: "Raise or lower pitch by semitones without changing speed", + }, + "audio-channels": { + name: "Audio Channels", + description: "Convert between mono, stereo, or swap left and right channels", + }, + "silence-removal": { + name: "Silence Removal", + description: "Strip silent sections from audio", + }, + "noise-reduction": { + name: "Noise Reduction", + description: "Reduce background noise with FFT-based denoising", + }, + "merge-audio": { + name: "Merge Audio", + description: "Combine multiple audio files into one", + }, + "split-audio": { + name: "Split Audio", + description: "Split audio by time, equal parts, or silence detection", + }, + "ringtone-maker": { + name: "Ringtone Maker", + description: "Create a ringtone clip from any audio file", + }, + "waveform-image": { + name: "Waveform Image", + description: "Generate a waveform visualization as a PNG image", + }, + "audio-metadata": { + name: "Audio Metadata", + description: "View, edit, or strip audio tags (ID3)", + }, "merge-pdf": { name: "Merge PDFs", description: "Combine multiple PDFs into one", @@ -1716,6 +1772,117 @@ export const vi: TranslationKeys = { submitBatch: "Extract ({count} files)", progressLabel: "Extracting", }, + "volume-adjust": { + "gain-db": "Gain (dB)", + submit: "Adjust Volume", + submitBatch: "Adjust Volume ({count} files)", + progressLabel: "Adjusting volume", + }, + "normalize-audio": { + hint: "EBU R128 loudness normalization", + submit: "Normalize", + submitBatch: "Normalize ({count} files)", + progressLabel: "Normalizing", + }, + "fade-audio": { + "fade-in-s": "Fade in (seconds)", + "fade-out-s": "Fade out (seconds)", + submit: "Apply Fade", + submitBatch: "Apply Fade ({count} files)", + progressLabel: "Applying fade", + }, + "reverse-audio": { + submit: "Reverse", + submitBatch: "Reverse ({count} files)", + progressLabel: "Reversing", + }, + "audio-speed": { + factor: "Speed factor", + submit: "Change Speed", + submitBatch: "Change Speed ({count} files)", + progressLabel: "Changing speed", + }, + "pitch-shift": { + semitones: "Semitones", + hint: "0 semitones is not allowed", + submit: "Shift Pitch", + submitBatch: "Shift Pitch ({count} files)", + progressLabel: "Shifting pitch", + }, + "audio-channels": { + mode: "Channel mode", + "stereo-to-mono": "Stereo to mono", + "mono-to-stereo": "Mono to stereo", + swap: "Swap left/right", + submit: "Convert Channels", + submitBatch: "Convert Channels ({count} files)", + progressLabel: "Converting channels", + }, + "silence-removal": { + "threshold-db": "Silence threshold (dB)", + "min-silence-s": "Minimum silence duration (seconds)", + submit: "Remove Silence", + submitBatch: "Remove Silence ({count} files)", + progressLabel: "Removing silence", + }, + "noise-reduction": { + strength: "Strength", + light: "Light", + medium: "Medium", + strong: "Strong", + submit: "Reduce Noise", + submitBatch: "Reduce Noise ({count} files)", + progressLabel: "Reducing noise", + }, + "merge-audio": { + format: "Output format", + mp3: "MP3", + wav: "WAV", + flac: "FLAC", + m4a: "M4A", + "order-hint": "Files will be joined in upload order", + submit: "Merge Audio", + submitBatch: "Merge Audio ({count} files)", + progressLabel: "Merging audio", + }, + "split-audio": { + mode: "Split mode", + time: "By time", + parts: "Equal parts", + silence: "By silence", + "segment-s": "Segment length (seconds)", + "num-parts": "Number of parts", + "threshold-db": "Silence threshold (dB)", + "min-silence-s": "Minimum silence duration (seconds)", + submit: "Split Audio", + submitBatch: "Split Audio ({count} files)", + progressLabel: "Splitting audio", + }, + "ringtone-maker": { + "start-s": "Start time (seconds)", + "duration-s": "Duration (seconds)", + "max-hint": "Maximum 30 seconds", + submit: "Create Ringtone", + submitBatch: "Create Ringtone ({count} files)", + progressLabel: "Creating ringtone", + }, + "waveform-image": { + width: "Width (px)", + height: "Height (px)", + color: "Waveform color", + submit: "Generate Waveform", + submitBatch: "Generate Waveform ({count} files)", + progressLabel: "Generating waveform", + }, + "audio-metadata": { + strip: "Strip all metadata", + title: "Title", + artist: "Artist", + album: "Album", + submit: "Apply Metadata", + submitBatch: "Apply Metadata ({count} files)", + progressLabel: "Applying metadata", + }, "merge-pdf": { addFiles: "Add PDFs to merge", needTwo: "At least two PDFs are required", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index 03c08481..9dbee9bd 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -351,6 +351,62 @@ export const zhCN: TranslationKeys = { name: "Extract Audio", description: "Pull the audio track out of a video", }, + "volume-adjust": { + name: "Volume Adjust", + description: "Increase or decrease audio volume by a fixed gain", + }, + "normalize-audio": { + name: "Normalize Audio", + description: "Even out loudness to broadcast standard levels", + }, + "fade-audio": { + name: "Fade Audio", + description: "Add fade-in and fade-out effects to audio", + }, + "reverse-audio": { + name: "Reverse Audio", + description: "Play audio backwards", + }, + "audio-speed": { + name: "Audio Speed", + description: "Speed up or slow down audio playback", + }, + "pitch-shift": { + name: "Pitch Shift", + description: "Raise or lower pitch by semitones without changing speed", + }, + "audio-channels": { + name: "Audio Channels", + description: "Convert between mono, stereo, or swap left and right channels", + }, + "silence-removal": { + name: "Silence Removal", + description: "Strip silent sections from audio", + }, + "noise-reduction": { + name: "Noise Reduction", + description: "Reduce background noise with FFT-based denoising", + }, + "merge-audio": { + name: "Merge Audio", + description: "Combine multiple audio files into one", + }, + "split-audio": { + name: "Split Audio", + description: "Split audio by time, equal parts, or silence detection", + }, + "ringtone-maker": { + name: "Ringtone Maker", + description: "Create a ringtone clip from any audio file", + }, + "waveform-image": { + name: "Waveform Image", + description: "Generate a waveform visualization as a PNG image", + }, + "audio-metadata": { + name: "Audio Metadata", + description: "View, edit, or strip audio tags (ID3)", + }, "merge-pdf": { name: "Merge PDFs", description: "Combine multiple PDFs into one", @@ -1651,6 +1707,117 @@ export const zhCN: TranslationKeys = { submitBatch: "Extract ({count} files)", progressLabel: "Extracting", }, + "volume-adjust": { + "gain-db": "Gain (dB)", + submit: "Adjust Volume", + submitBatch: "Adjust Volume ({count} files)", + progressLabel: "Adjusting volume", + }, + "normalize-audio": { + hint: "EBU R128 loudness normalization", + submit: "Normalize", + submitBatch: "Normalize ({count} files)", + progressLabel: "Normalizing", + }, + "fade-audio": { + "fade-in-s": "Fade in (seconds)", + "fade-out-s": "Fade out (seconds)", + submit: "Apply Fade", + submitBatch: "Apply Fade ({count} files)", + progressLabel: "Applying fade", + }, + "reverse-audio": { + submit: "Reverse", + submitBatch: "Reverse ({count} files)", + progressLabel: "Reversing", + }, + "audio-speed": { + factor: "Speed factor", + submit: "Change Speed", + submitBatch: "Change Speed ({count} files)", + progressLabel: "Changing speed", + }, + "pitch-shift": { + semitones: "Semitones", + hint: "0 semitones is not allowed", + submit: "Shift Pitch", + submitBatch: "Shift Pitch ({count} files)", + progressLabel: "Shifting pitch", + }, + "audio-channels": { + mode: "Channel mode", + "stereo-to-mono": "Stereo to mono", + "mono-to-stereo": "Mono to stereo", + swap: "Swap left/right", + submit: "Convert Channels", + submitBatch: "Convert Channels ({count} files)", + progressLabel: "Converting channels", + }, + "silence-removal": { + "threshold-db": "Silence threshold (dB)", + "min-silence-s": "Minimum silence duration (seconds)", + submit: "Remove Silence", + submitBatch: "Remove Silence ({count} files)", + progressLabel: "Removing silence", + }, + "noise-reduction": { + strength: "Strength", + light: "Light", + medium: "Medium", + strong: "Strong", + submit: "Reduce Noise", + submitBatch: "Reduce Noise ({count} files)", + progressLabel: "Reducing noise", + }, + "merge-audio": { + format: "Output format", + mp3: "MP3", + wav: "WAV", + flac: "FLAC", + m4a: "M4A", + "order-hint": "Files will be joined in upload order", + submit: "Merge Audio", + submitBatch: "Merge Audio ({count} files)", + progressLabel: "Merging audio", + }, + "split-audio": { + mode: "Split mode", + time: "By time", + parts: "Equal parts", + silence: "By silence", + "segment-s": "Segment length (seconds)", + "num-parts": "Number of parts", + "threshold-db": "Silence threshold (dB)", + "min-silence-s": "Minimum silence duration (seconds)", + submit: "Split Audio", + submitBatch: "Split Audio ({count} files)", + progressLabel: "Splitting audio", + }, + "ringtone-maker": { + "start-s": "Start time (seconds)", + "duration-s": "Duration (seconds)", + "max-hint": "Maximum 30 seconds", + submit: "Create Ringtone", + submitBatch: "Create Ringtone ({count} files)", + progressLabel: "Creating ringtone", + }, + "waveform-image": { + width: "Width (px)", + height: "Height (px)", + color: "Waveform color", + submit: "Generate Waveform", + submitBatch: "Generate Waveform ({count} files)", + progressLabel: "Generating waveform", + }, + "audio-metadata": { + strip: "Strip all metadata", + title: "Title", + artist: "Artist", + album: "Album", + submit: "Apply Metadata", + submitBatch: "Apply Metadata ({count} files)", + progressLabel: "Applying metadata", + }, "merge-pdf": { addFiles: "Add PDFs to merge", needTwo: "At least two PDFs are required", diff --git a/packages/shared/src/i18n/zh-TW.ts b/packages/shared/src/i18n/zh-TW.ts index 82d94e62..07b6d89b 100644 --- a/packages/shared/src/i18n/zh-TW.ts +++ b/packages/shared/src/i18n/zh-TW.ts @@ -350,6 +350,62 @@ export const zhTW: TranslationKeys = { name: "Extract Audio", description: "Pull the audio track out of a video", }, + "volume-adjust": { + name: "Volume Adjust", + description: "Increase or decrease audio volume by a fixed gain", + }, + "normalize-audio": { + name: "Normalize Audio", + description: "Even out loudness to broadcast standard levels", + }, + "fade-audio": { + name: "Fade Audio", + description: "Add fade-in and fade-out effects to audio", + }, + "reverse-audio": { + name: "Reverse Audio", + description: "Play audio backwards", + }, + "audio-speed": { + name: "Audio Speed", + description: "Speed up or slow down audio playback", + }, + "pitch-shift": { + name: "Pitch Shift", + description: "Raise or lower pitch by semitones without changing speed", + }, + "audio-channels": { + name: "Audio Channels", + description: "Convert between mono, stereo, or swap left and right channels", + }, + "silence-removal": { + name: "Silence Removal", + description: "Strip silent sections from audio", + }, + "noise-reduction": { + name: "Noise Reduction", + description: "Reduce background noise with FFT-based denoising", + }, + "merge-audio": { + name: "Merge Audio", + description: "Combine multiple audio files into one", + }, + "split-audio": { + name: "Split Audio", + description: "Split audio by time, equal parts, or silence detection", + }, + "ringtone-maker": { + name: "Ringtone Maker", + description: "Create a ringtone clip from any audio file", + }, + "waveform-image": { + name: "Waveform Image", + description: "Generate a waveform visualization as a PNG image", + }, + "audio-metadata": { + name: "Audio Metadata", + description: "View, edit, or strip audio tags (ID3)", + }, "merge-pdf": { name: "Merge PDFs", description: "Combine multiple PDFs into one", @@ -1649,6 +1705,117 @@ export const zhTW: TranslationKeys = { submitBatch: "Extract ({count} files)", progressLabel: "Extracting", }, + "volume-adjust": { + "gain-db": "Gain (dB)", + submit: "Adjust Volume", + submitBatch: "Adjust Volume ({count} files)", + progressLabel: "Adjusting volume", + }, + "normalize-audio": { + hint: "EBU R128 loudness normalization", + submit: "Normalize", + submitBatch: "Normalize ({count} files)", + progressLabel: "Normalizing", + }, + "fade-audio": { + "fade-in-s": "Fade in (seconds)", + "fade-out-s": "Fade out (seconds)", + submit: "Apply Fade", + submitBatch: "Apply Fade ({count} files)", + progressLabel: "Applying fade", + }, + "reverse-audio": { + submit: "Reverse", + submitBatch: "Reverse ({count} files)", + progressLabel: "Reversing", + }, + "audio-speed": { + factor: "Speed factor", + submit: "Change Speed", + submitBatch: "Change Speed ({count} files)", + progressLabel: "Changing speed", + }, + "pitch-shift": { + semitones: "Semitones", + hint: "0 semitones is not allowed", + submit: "Shift Pitch", + submitBatch: "Shift Pitch ({count} files)", + progressLabel: "Shifting pitch", + }, + "audio-channels": { + mode: "Channel mode", + "stereo-to-mono": "Stereo to mono", + "mono-to-stereo": "Mono to stereo", + swap: "Swap left/right", + submit: "Convert Channels", + submitBatch: "Convert Channels ({count} files)", + progressLabel: "Converting channels", + }, + "silence-removal": { + "threshold-db": "Silence threshold (dB)", + "min-silence-s": "Minimum silence duration (seconds)", + submit: "Remove Silence", + submitBatch: "Remove Silence ({count} files)", + progressLabel: "Removing silence", + }, + "noise-reduction": { + strength: "Strength", + light: "Light", + medium: "Medium", + strong: "Strong", + submit: "Reduce Noise", + submitBatch: "Reduce Noise ({count} files)", + progressLabel: "Reducing noise", + }, + "merge-audio": { + format: "Output format", + mp3: "MP3", + wav: "WAV", + flac: "FLAC", + m4a: "M4A", + "order-hint": "Files will be joined in upload order", + submit: "Merge Audio", + submitBatch: "Merge Audio ({count} files)", + progressLabel: "Merging audio", + }, + "split-audio": { + mode: "Split mode", + time: "By time", + parts: "Equal parts", + silence: "By silence", + "segment-s": "Segment length (seconds)", + "num-parts": "Number of parts", + "threshold-db": "Silence threshold (dB)", + "min-silence-s": "Minimum silence duration (seconds)", + submit: "Split Audio", + submitBatch: "Split Audio ({count} files)", + progressLabel: "Splitting audio", + }, + "ringtone-maker": { + "start-s": "Start time (seconds)", + "duration-s": "Duration (seconds)", + "max-hint": "Maximum 30 seconds", + submit: "Create Ringtone", + submitBatch: "Create Ringtone ({count} files)", + progressLabel: "Creating ringtone", + }, + "waveform-image": { + width: "Width (px)", + height: "Height (px)", + color: "Waveform color", + submit: "Generate Waveform", + submitBatch: "Generate Waveform ({count} files)", + progressLabel: "Generating waveform", + }, + "audio-metadata": { + strip: "Strip all metadata", + title: "Title", + artist: "Artist", + album: "Album", + submit: "Apply Metadata", + submitBatch: "Apply Metadata ({count} files)", + progressLabel: "Applying metadata", + }, "merge-pdf": { addFiles: "Add PDFs to merge", needTwo: "At least two PDFs are required", diff --git a/tests/fixtures/media/tone-gap.wav b/tests/fixtures/media/tone-gap.wav new file mode 100644 index 00000000..450e6c97 Binary files /dev/null and b/tests/fixtures/media/tone-gap.wav differ diff --git a/tests/fixtures/media/tone-stereo.wav b/tests/fixtures/media/tone-stereo.wav new file mode 100644 index 00000000..7224c760 Binary files /dev/null and b/tests/fixtures/media/tone-stereo.wav differ diff --git a/tests/helpers/tool-default-settings.ts b/tests/helpers/tool-default-settings.ts index 2bc8ca6d..edcaf896 100644 --- a/tests/helpers/tool-default-settings.ts +++ b/tests/helpers/tool-default-settings.ts @@ -29,6 +29,7 @@ export const TOOL_SETTINGS_OVERRIDES: Record = { "rotate-video": { transform: "cw90" }, "resize-video": { preset: "720p" }, "watermark-video": { text: "CONFIDENTIAL" }, + "audio-channels": { mode: "mono-to-stereo" }, }; export function defaultSettingsFor(toolId: string): unknown { diff --git a/tests/integration/audio-channels.test.ts b/tests/integration/audio-channels.test.ts new file mode 100644 index 00000000..52ae8bac --- /dev/null +++ b/tests/integration/audio-channels.test.ts @@ -0,0 +1,86 @@ +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const STEREO = readFileSync(join(__dirname, "..", "fixtures", "media", "tone-stereo.wav")); +const MONO_MP3 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp3")); + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +function postTool( + filename: string, + contentTypeHeader: string, + fileContent: Buffer, + settings: Record, +) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename, contentType: contentTypeHeader, content: fileContent }, + { name: "settings", content: JSON.stringify(settings) }, + ]); + return testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/audio-channels", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +describe.skipIf(!ffmpegAvailable())("audio-channels (requires ffmpeg)", () => { + it("stereo-to-mono on stereo file produces mono output", async () => { + const res = await postTool("tone-stereo.wav", "audio/wav", STEREO, { + mode: "stereo-to-mono", + }); + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl }); + expect(dl.statusCode).toBe(200); + + const tmpDir = mkdtempSync(join(tmpdir(), "ch-test-")); + const probeFile = join(tmpDir, "mono.wav"); + writeFileSync(probeFile, dl.rawPayload); + const info = await probeMedia(probeFile); + const audio = info.streams.find((s) => s.type === "audio"); + expect(audio?.channels).toBe(1); + }, 60_000); + + it("swap on stereo file returns 200 with 2 channels", async () => { + const res = await postTool("tone-stereo.wav", "audio/wav", STEREO, { mode: "swap" }); + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl }); + + const tmpDir = mkdtempSync(join(tmpdir(), "ch-test-")); + const probeFile = join(tmpDir, "swapped.wav"); + writeFileSync(probeFile, dl.rawPayload); + const info = await probeMedia(probeFile); + const audio = info.streams.find((s) => s.type === "audio"); + expect(audio?.channels).toBe(2); + }, 60_000); + + it("stereo-to-mono on mono input returns 422", async () => { + const res = await postTool("tiny.mp3", "audio/mpeg", MONO_MP3, { + mode: "stereo-to-mono", + }); + expect(res.statusCode).toBe(422); + const body = JSON.parse(res.body); + expect(body.details).toMatch(/stereo input/i); + }, 60_000); + + it("rejects missing mode (400)", async () => { + const res = await postTool("tone-stereo.wav", "audio/wav", STEREO, {}); + expect(res.statusCode).toBe(400); + }); +}); diff --git a/tests/integration/audio-metadata.test.ts b/tests/integration/audio-metadata.test.ts new file mode 100644 index 00000000..3e0c1bc1 --- /dev/null +++ b/tests/integration/audio-metadata.test.ts @@ -0,0 +1,68 @@ +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const MP3 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp3")); + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +async function runTool(settings: Record) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.mp3", contentType: "audio/mpeg", content: MP3 }, + { name: "settings", content: JSON.stringify(settings) }, + ]); + return testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/audio-metadata", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +describe.skipIf(!ffmpegAvailable())("audio-metadata (requires ffmpeg)", () => { + it("sets title tag and envelope contains metadata with tags object", async () => { + const res = await runTool({ title: "EnvelopeTitle2026" }); + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + + // Envelope carries resultPayload.metadata at top level (sync passthrough) + expect(envelope.metadata).toBeDefined(); + expect(typeof envelope.metadata.tags).toBe("object"); + + // Download and probe the output to verify the tag was written + const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl }); + expect(dl.statusCode).toBe(200); + + const tmpDir = mkdtempSync(join(tmpdir(), "audio-meta-")); + const probeFile = join(tmpDir, "tagged.mp3"); + writeFileSync(probeFile, dl.rawPayload); + const info = await probeMedia(probeFile); + expect(info.tags?.title).toBe("EnvelopeTitle2026"); + }, 60_000); + + it("strips metadata with strip true and returns 200", async () => { + const res = await runTool({ strip: true }); + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + }, 60_000); + + it("rejects title exceeding 500 characters (400)", async () => { + const res = await runTool({ title: "x".repeat(501) }); + expect(res.statusCode).toBe(400); + }); +}); diff --git a/tests/integration/audio-speed.test.ts b/tests/integration/audio-speed.test.ts new file mode 100644 index 00000000..489d38b6 --- /dev/null +++ b/tests/integration/audio-speed.test.ts @@ -0,0 +1,60 @@ +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const MP3 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp3")); + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +async function runTool(settings: Record) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.mp3", contentType: "audio/mpeg", content: MP3 }, + { name: "settings", content: JSON.stringify(settings) }, + ]); + return testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/audio-speed", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +describe.skipIf(!ffmpegAvailable())("audio-speed (requires ffmpeg)", () => { + it("doubles speed and halves duration", async () => { + const res = await runTool({ factor: 2 }); + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + + const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl }); + expect(dl.statusCode).toBe(200); + + const tmpDir = mkdtempSync(join(tmpdir(), "aspeed-test-")); + const probeFile = join(tmpDir, "sped.mp3"); + writeFileSync(probeFile, dl.rawPayload); + const info = await probeMedia(probeFile); + + // tiny.mp3 is ~1.044s; at 2x speed output should be ~0.522s (within 25%) + const expected = 1.044 / 2; + expect(info.durationS).toBeGreaterThan(expected * 0.75); + expect(info.durationS).toBeLessThan(expected * 1.25); + }, 60_000); + + it("rejects factor out of range", async () => { + const res = await runTool({ factor: 10 }); + expect(res.statusCode).toBe(400); + }); +}); diff --git a/tests/integration/fade-audio.test.ts b/tests/integration/fade-audio.test.ts new file mode 100644 index 00000000..e4d14002 --- /dev/null +++ b/tests/integration/fade-audio.test.ts @@ -0,0 +1,52 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { ffmpegAvailable } from "@snapotter/media-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const MP3 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp3")); + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +async function runTool(settings: Record) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.mp3", contentType: "audio/mpeg", content: MP3 }, + { name: "settings", content: JSON.stringify(settings) }, + ]); + return testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/fade-audio", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +describe.skipIf(!ffmpegAvailable())("fade-audio (requires ffmpeg)", () => { + it("applies default fades and returns 200", async () => { + const res = await runTool({ fadeInS: 1, fadeOutS: 1 }); + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + const dl = await testApp.app.inject({ + method: "GET", + url: envelope.downloadUrl, + }); + expect(dl.statusCode).toBe(200); + expect(dl.rawPayload.length).toBeGreaterThan(100); + }, 60_000); + + it("rejects when both fades are 0", async () => { + const res = await runTool({ fadeInS: 0, fadeOutS: 0 }); + expect(res.statusCode).toBe(400); + }); +}); diff --git a/tests/integration/merge-audio.test.ts b/tests/integration/merge-audio.test.ts new file mode 100644 index 00000000..cdea1cb3 --- /dev/null +++ b/tests/integration/merge-audio.test.ts @@ -0,0 +1,70 @@ +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const MP3 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp3")); +const WAV = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.wav")); + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +describe.skipIf(!ffmpegAvailable())("merge-audio (requires ffmpeg)", () => { + it("merges two audio files with different sample rates", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.mp3", contentType: "audio/mpeg", content: MP3 }, + { name: "file", filename: "tiny.wav", contentType: "audio/wav", content: WAV }, + { name: "settings", content: JSON.stringify({ format: "mp3" }) }, + ]); + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/merge-audio", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + + const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl }); + expect(dl.statusCode).toBe(200); + + // Probe: duration should be close to 1.044 + 1.0 = 2.044s + const tmpDir = mkdtempSync(join(tmpdir(), "merge-audio-test-")); + const probeFile = join(tmpDir, "merged.mp3"); + writeFileSync(probeFile, dl.rawPayload); + const info = await probeMedia(probeFile); + const expected = 2.04; + expect(info.durationS).toBeDefined(); + expect(Math.abs((info.durationS ?? 0) - expected)).toBeLessThan(expected * 0.3); + }, 60_000); + + it("rejects when only one file is provided", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.mp3", contentType: "audio/mpeg", content: MP3 }, + { name: "settings", content: JSON.stringify({}) }, + ]); + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/merge-audio", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(422); + const parsed = JSON.parse(res.body); + expect(parsed.details).toMatch(/at least two/i); + }, 60_000); +}); diff --git a/tests/integration/noise-reduction.test.ts b/tests/integration/noise-reduction.test.ts new file mode 100644 index 00000000..0590f289 --- /dev/null +++ b/tests/integration/noise-reduction.test.ts @@ -0,0 +1,52 @@ +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const MP3 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp3")); + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +async function runTool(settings: Record) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.mp3", contentType: "audio/mpeg", content: MP3 }, + { name: "settings", content: JSON.stringify(settings) }, + ]); + return testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/noise-reduction", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +describe.skipIf(!ffmpegAvailable())("noise-reduction (requires ffmpeg)", () => { + it("denoises with medium strength and returns audio", async () => { + const res = await runTool({ strength: "medium" }); + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + + const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl }); + expect(dl.statusCode).toBe(200); + + const tmpDir = mkdtempSync(join(tmpdir(), "denoise-test-")); + const probeFile = join(tmpDir, "denoised.mp3"); + writeFileSync(probeFile, dl.rawPayload); + const info = await probeMedia(probeFile); + const audio = info.streams.find((s) => s.type === "audio"); + expect(audio).toBeDefined(); + }, 60_000); +}); diff --git a/tests/integration/normalize-audio.test.ts b/tests/integration/normalize-audio.test.ts new file mode 100644 index 00000000..67701201 --- /dev/null +++ b/tests/integration/normalize-audio.test.ts @@ -0,0 +1,47 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { ffmpegAvailable } from "@snapotter/media-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const WAV = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.wav")); + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +async function runTool(settings: Record) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.wav", contentType: "audio/wav", content: WAV }, + { name: "settings", content: JSON.stringify(settings) }, + ]); + return testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/normalize-audio", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +describe.skipIf(!ffmpegAvailable())("normalize-audio (requires ffmpeg)", () => { + it("normalizes 8 kHz WAV and returns 200", async () => { + const res = await runTool({}); + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + const dl = await testApp.app.inject({ + method: "GET", + url: envelope.downloadUrl, + }); + expect(dl.statusCode).toBe(200); + expect(dl.rawPayload.length).toBeGreaterThan(100); + }, 60_000); +}); diff --git a/tests/integration/pitch-shift.test.ts b/tests/integration/pitch-shift.test.ts new file mode 100644 index 00000000..04d233f1 --- /dev/null +++ b/tests/integration/pitch-shift.test.ts @@ -0,0 +1,59 @@ +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const MP3 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp3")); + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +async function runTool(settings: Record) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.mp3", contentType: "audio/mpeg", content: MP3 }, + { name: "settings", content: JSON.stringify(settings) }, + ]); + return testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/pitch-shift", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +describe.skipIf(!ffmpegAvailable())("pitch-shift (requires ffmpeg)", () => { + it("shifts +12 semitones with duration roughly unchanged", async () => { + const res = await runTool({ semitones: 12 }); + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + + const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl }); + expect(dl.statusCode).toBe(200); + + const tmpDir = mkdtempSync(join(tmpdir(), "pitch-test-")); + const probeFile = join(tmpDir, "pitched.mp3"); + writeFileSync(probeFile, dl.rawPayload); + const info = await probeMedia(probeFile); + + // rubberband preserves tempo; duration should be roughly 1.044s (30% tolerance) + expect(info.durationS).toBeGreaterThan(1.044 * 0.7); + expect(info.durationS).toBeLessThan(1.044 * 1.3); + }, 60_000); + + it("rejects semitones 0", async () => { + const res = await runTool({ semitones: 0 }); + expect(res.statusCode).toBe(400); + }); +}); diff --git a/tests/integration/reverse-audio.test.ts b/tests/integration/reverse-audio.test.ts new file mode 100644 index 00000000..a564afce --- /dev/null +++ b/tests/integration/reverse-audio.test.ts @@ -0,0 +1,64 @@ +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const MP3 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp3")); + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +async function runTool(settings: Record) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.mp3", contentType: "audio/mpeg", content: MP3 }, + { name: "settings", content: JSON.stringify(settings) }, + ]); + return testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/reverse-audio", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +describe.skipIf(!ffmpegAvailable())("reverse-audio (requires ffmpeg)", () => { + it("reverses audio and returns 200 with duration roughly matching source", async () => { + // Probe source duration + const srcTmpDir = mkdtempSync(join(tmpdir(), "rev-src-")); + const srcFile = join(srcTmpDir, "tiny.mp3"); + writeFileSync(srcFile, MP3); + const srcInfo = await probeMedia(srcFile); + + const res = await runTool({}); + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + const dl = await testApp.app.inject({ + method: "GET", + url: envelope.downloadUrl, + }); + expect(dl.statusCode).toBe(200); + expect(dl.rawPayload.length).toBeGreaterThan(100); + + const tmpDir = mkdtempSync(join(tmpdir(), "rev-test-")); + const probeFile = join(tmpDir, "reversed.mp3"); + writeFileSync(probeFile, dl.rawPayload); + const info = await probeMedia(probeFile); + expect(info.durationS).not.toBeNull(); + // Duration should be roughly the same (30% tolerance) + const srcDur = srcInfo.durationS ?? 1; + expect(info.durationS as number).toBeGreaterThan(srcDur * 0.7); + expect(info.durationS as number).toBeLessThan(srcDur * 1.3); + }, 60_000); +}); diff --git a/tests/integration/ringtone-maker.test.ts b/tests/integration/ringtone-maker.test.ts new file mode 100644 index 00000000..5aa30bc8 --- /dev/null +++ b/tests/integration/ringtone-maker.test.ts @@ -0,0 +1,51 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { ffmpegAvailable } from "@snapotter/media-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const MP3 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp3")); + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +async function runTool(settings: Record) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.mp3", contentType: "audio/mpeg", content: MP3 }, + { name: "settings", content: JSON.stringify(settings) }, + ]); + return testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/ringtone-maker", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +describe.skipIf(!ffmpegAvailable())("ringtone-maker (requires ffmpeg)", () => { + it("creates m4r ringtone with defaults and returns 200", async () => { + const res = await runTool({}); + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + + const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl }); + expect(dl.statusCode).toBe(200); + expect(dl.rawPayload.length).toBeGreaterThan(200); + }, 60_000); + + it("rejects startS beyond audio duration (422)", async () => { + const res = await runTool({ startS: 5 }); + expect(res.statusCode).toBe(422); + expect(res.body).toMatch(/beyond the end/i); + }, 60_000); +}); diff --git a/tests/integration/silence-removal.test.ts b/tests/integration/silence-removal.test.ts new file mode 100644 index 00000000..b4481515 --- /dev/null +++ b/tests/integration/silence-removal.test.ts @@ -0,0 +1,54 @@ +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const TONE_GAP = readFileSync(join(__dirname, "..", "fixtures", "media", "tone-gap.wav")); + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +async function runTool(settings: Record) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tone-gap.wav", contentType: "audio/wav", content: TONE_GAP }, + { name: "settings", content: JSON.stringify(settings) }, + ]); + return testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/silence-removal", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +describe.skipIf(!ffmpegAvailable())("silence-removal (requires ffmpeg)", () => { + it("removes silent gap and shortens duration below 1.05s", async () => { + const res = await runTool({ thresholdDb: -40, minSilenceS: 0.2 }); + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + + const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl }); + expect(dl.statusCode).toBe(200); + + const tmpDir = mkdtempSync(join(tmpdir(), "silence-test-")); + const probeFile = join(tmpDir, "nosilence.wav"); + writeFileSync(probeFile, dl.rawPayload); + const info = await probeMedia(probeFile); + + // tone-gap.wav is ~1.2s with a 0.4s silent middle; output should be shorter than 1.05s + expect(info.durationS).toBeLessThan(1.05); + expect(info.durationS).toBeGreaterThan(0.3); + }, 60_000); +}); diff --git a/tests/integration/split-audio.test.ts b/tests/integration/split-audio.test.ts new file mode 100644 index 00000000..ec37d5c1 --- /dev/null +++ b/tests/integration/split-audio.test.ts @@ -0,0 +1,126 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { ffmpegAvailable } from "@snapotter/media-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const MP3 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp3")); +const TONE_GAP = readFileSync(join(__dirname, "..", "fixtures", "media", "tone-gap.wav")); + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +describe.skipIf(!ffmpegAvailable())("split-audio (requires ffmpeg)", () => { + it("splits into 2 parts via parts mode", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.mp3", contentType: "audio/mpeg", content: MP3 }, + { name: "settings", content: JSON.stringify({ mode: "parts", parts: 2 }) }, + ]); + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/split-audio", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + + const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl }); + expect(dl.statusCode).toBe(200); + // ZIP magic: PK\x03\x04 + expect(dl.rawPayload[0]).toBe(0x50); + expect(dl.rawPayload[1]).toBe(0x4b); + }, 60_000); + + it("splits by silence on tone-gap.wav", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tone-gap.wav", contentType: "audio/wav", content: TONE_GAP }, + { + name: "settings", + content: JSON.stringify({ mode: "silence", thresholdDb: -40, minSilenceS: 0.2 }), + }, + ]); + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/split-audio", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl }); + expect(dl.statusCode).toBe(200); + // ZIP magic + expect(dl.rawPayload[0]).toBe(0x50); + expect(dl.rawPayload[1]).toBe(0x4b); + expect(dl.rawPayload.length).toBeGreaterThan(400); + }, 60_000); + + it("rejects silence mode when no silence found", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.mp3", contentType: "audio/mpeg", content: MP3 }, + { + name: "settings", + content: JSON.stringify({ mode: "silence", thresholdDb: -40, minSilenceS: 0.2 }), + }, + ]); + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/split-audio", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(422); + const parsed = JSON.parse(res.body); + expect(parsed.details).toMatch(/no silence found/i); + }, 60_000); + + it("splits by time mode on tone-gap.wav (segmentS=1)", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tone-gap.wav", contentType: "audio/wav", content: TONE_GAP }, + { name: "settings", content: JSON.stringify({ mode: "time", segmentS: 1 }) }, + ]); + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/split-audio", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl }); + expect(dl.statusCode).toBe(200); + // ZIP magic + expect(dl.rawPayload[0]).toBe(0x50); + expect(dl.rawPayload[1]).toBe(0x4b); + }, 60_000); + + it("rejects parts=1 (schema minimum is 2)", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.mp3", contentType: "audio/mpeg", content: MP3 }, + { name: "settings", content: JSON.stringify({ mode: "parts", parts: 1 }) }, + ]); + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/split-audio", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(400); + }, 60_000); +}); diff --git a/tests/integration/volume-adjust.test.ts b/tests/integration/volume-adjust.test.ts new file mode 100644 index 00000000..742e3a2c --- /dev/null +++ b/tests/integration/volume-adjust.test.ts @@ -0,0 +1,60 @@ +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const MP3 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp3")); + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +async function runTool(settings: Record) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.mp3", contentType: "audio/mpeg", content: MP3 }, + { name: "settings", content: JSON.stringify(settings) }, + ]); + return testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/volume-adjust", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +describe.skipIf(!ffmpegAvailable())("volume-adjust (requires ffmpeg)", () => { + it("adjusts volume and returns 200 with audio stream", async () => { + const res = await runTool({ gainDb: 3 }); + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + const dl = await testApp.app.inject({ + method: "GET", + url: envelope.downloadUrl, + }); + expect(dl.statusCode).toBe(200); + expect(dl.rawPayload.length).toBeGreaterThan(100); + + const tmpDir = mkdtempSync(join(tmpdir(), "vol-test-")); + const probeFile = join(tmpDir, "adjusted.mp3"); + writeFileSync(probeFile, dl.rawPayload); + const info = await probeMedia(probeFile); + const audio = info.streams.find((s) => s.type === "audio"); + expect(audio).toBeDefined(); + }, 60_000); + + it("rejects gainDb out of range (50)", async () => { + const res = await runTool({ gainDb: 50 }); + expect(res.statusCode).toBe(400); + }); +}); diff --git a/tests/integration/waveform-image.test.ts b/tests/integration/waveform-image.test.ts new file mode 100644 index 00000000..0ac22599 --- /dev/null +++ b/tests/integration/waveform-image.test.ts @@ -0,0 +1,55 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { ffmpegAvailable } from "@snapotter/media-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const MP3 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp3")); + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +async function runTool(settings: Record) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.mp3", contentType: "audio/mpeg", content: MP3 }, + { name: "settings", content: JSON.stringify(settings) }, + ]); + return testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/waveform-image", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +describe.skipIf(!ffmpegAvailable())("waveform-image (requires ffmpeg)", () => { + it("generates PNG waveform with default settings", async () => { + const res = await runTool({}); + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + + const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl }); + expect(dl.statusCode).toBe(200); + + // PNG magic bytes: 89 50 4E 47 + expect(dl.rawPayload[0]).toBe(0x89); + expect(dl.rawPayload[1]).toBe(0x50); + expect(dl.rawPayload[2]).toBe(0x4e); + expect(dl.rawPayload[3]).toBe(0x47); + }, 60_000); + + it("rejects invalid color string (400)", async () => { + const res = await runTool({ color: "blue" }); + expect(res.statusCode).toBe(400); + }); +}); diff --git a/tests/unit/api/audio-output.test.ts b/tests/unit/api/audio-output.test.ts new file mode 100644 index 00000000..598345eb --- /dev/null +++ b/tests/unit/api/audio-output.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { audioOutputFor } from "../../../apps/api/src/lib/media-tool.js"; + +describe("audioOutputFor", () => { + it("normalises uppercase extensions", () => { + const out = audioOutputFor(".MP3"); + expect(out.ext).toBe(".mp3"); + expect(out.contentType).toBe("audio/mpeg"); + expect(out.encodeArgs).toContain("libmp3lame"); + }); + + it("falls back to mp3 for decode-only formats", () => { + const out = audioOutputFor(".wma"); + expect(out.ext).toBe(".mp3"); + expect(out.contentType).toBe("audio/mpeg"); + }); + + it("maps .aac input to .m4a output", () => { + const out = audioOutputFor(".aac"); + expect(out.ext).toBe(".m4a"); + expect(out.contentType).toBe("audio/mp4"); + expect(out.encodeArgs).toContain("aac"); + }); +}); diff --git a/tests/unit/api/tool-factory-route.test.ts b/tests/unit/api/tool-factory-route.test.ts index b7834f15..3ba41da0 100644 --- a/tests/unit/api/tool-factory-route.test.ts +++ b/tests/unit/api/tool-factory-route.test.ts @@ -136,7 +136,7 @@ vi.mock("sharp", () => ({ // ── Imports ───────────────────────────────────────────────────────────── -import { waitForJob } from "../../../apps/api/src/jobs/enqueue.js"; +import { enqueueToolJob, waitForJob } from "../../../apps/api/src/jobs/enqueue.js"; import { isToolInstalled } from "../../../apps/api/src/lib/feature-status.js"; import { validateImageBuffer } from "../../../apps/api/src/lib/file-validation.js"; import type { AnyToolRouteConfig } from "../../../apps/api/src/routes/tool-factory.js"; @@ -575,4 +575,149 @@ describe("createToolRoute", () => { ); }); }); + + describe("multipart field recovery", () => { + it("recovers settings from part.fields when the iterator drops trailing fields", async () => { + const app = createMockApp(); + const id = uniqueId(); + createToolRoute(app as never, makeMockConfig(id)); + const handler = app.routes[`/api/v1/tools/${id}`]; + const reply = createMockReply(); + + // Simulate the @fastify/multipart race: the iterator yields only the + // file part; the settings field is present only on part.fields. + const req = { + parts: () => ({ + [Symbol.asyncIterator]: async function* () { + yield { + type: "file", + filename: "test.png", + file: (async function* () { + yield Buffer.from("png-data"); + })(), + fields: { + settings: { value: '{"x":1}' }, + }, + }; + }, + }), + log: { warn: vi.fn(), error: vi.fn(), info: vi.fn() }, + }; + + await handler(req, reply); + + // Settings must be recovered as {x:1}, not fall through to defaults ({}) + const enqueueCall = vi.mocked(enqueueToolJob).mock.calls[0][0]; + expect(enqueueCall.settings).toEqual({ x: 1 }); + expect(reply.send).toHaveBeenCalledWith( + expect.objectContaining({ jobId: expect.any(String) }), + ); + }); + + it("does not overwrite settings already collected from the iterator", async () => { + const app = createMockApp(); + const id = uniqueId(); + createToolRoute(app as never, makeMockConfig(id)); + const handler = app.routes[`/api/v1/tools/${id}`]; + const reply = createMockReply(); + + // Both the iterator and part.fields carry settings; the iterator value wins + const req = { + parts: () => ({ + [Symbol.asyncIterator]: async function* () { + yield { + type: "file", + filename: "test.png", + file: (async function* () { + yield Buffer.from("png-data"); + })(), + fields: { + settings: { value: '{"from":"fields"}' }, + }, + }; + yield { + type: "field", + fieldname: "settings", + value: '{"from":"iterator"}', + file: (async function* () {})(), + fields: { + settings: { value: '{"from":"fields"}' }, + }, + }; + }, + }), + log: { warn: vi.fn(), error: vi.fn(), info: vi.fn() }, + }; + + await handler(req, reply); + + const enqueueCall = vi.mocked(enqueueToolJob).mock.calls[0][0]; + expect(enqueueCall.settings).toEqual({ from: "iterator" }); + }); + + it("recovers fileId and clientJobId from part.fields", async () => { + const app = createMockApp(); + const id = uniqueId(); + createToolRoute(app as never, makeMockConfig(id)); + const handler = app.routes[`/api/v1/tools/${id}`]; + const reply = createMockReply(); + + const req = { + parts: () => ({ + [Symbol.asyncIterator]: async function* () { + yield { + type: "file", + filename: "test.png", + file: (async function* () { + yield Buffer.from("png-data"); + })(), + fields: { + settings: { value: "{}" }, + fileId: { value: "f-123" }, + clientJobId: { value: "cj-456" }, + }, + }; + }, + }), + log: { warn: vi.fn(), error: vi.fn(), info: vi.fn() }, + }; + + await handler(req, reply); + + const enqueueCall = vi.mocked(enqueueToolJob).mock.calls[0][0]; + expect(enqueueCall.fileId).toBe("f-123"); + expect(enqueueCall.clientJobId).toBe("cj-456"); + }); + + it("handles array-form fields from part.fields", async () => { + const app = createMockApp(); + const id = uniqueId(); + createToolRoute(app as never, makeMockConfig(id)); + const handler = app.routes[`/api/v1/tools/${id}`]; + const reply = createMockReply(); + + const req = { + parts: () => ({ + [Symbol.asyncIterator]: async function* () { + yield { + type: "file", + filename: "test.png", + file: (async function* () { + yield Buffer.from("png-data"); + })(), + fields: { + settings: [{ value: '{"arr":true}' }], + }, + }; + }, + }), + log: { warn: vi.fn(), error: vi.fn(), info: vi.fn() }, + }; + + await handler(req, reply); + + const enqueueCall = vi.mocked(enqueueToolJob).mock.calls[0][0]; + expect(enqueueCall.settings).toEqual({ arr: true }); + }); + }); }); diff --git a/tests/unit/api/video-speed.test.ts b/tests/unit/api/video-speed.test.ts index 282bd7b2..3915a1f3 100644 --- a/tests/unit/api/video-speed.test.ts +++ b/tests/unit/api/video-speed.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { buildAtempoChain } from "../../../apps/api/src/routes/tools/video-speed.js"; +import { buildAtempoChain } from "../../../apps/api/src/lib/media-tool.js"; describe("buildAtempoChain", () => { it("returns a single atempo for factor >= 0.5", () => {