feat(tools): 2.0 phase 5 wave 3b - audio depth (14 tools) (#222)

This commit is contained in:
SnapOtter
2026-06-13 10:19:06 +08:00
parent 5f98b48593
commit 638288e196
81 changed files with 7646 additions and 718 deletions
@@ -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": {}
}
}
@@ -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": {}
}
}
+51 -2
View File
@@ -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<string, AudioOutput> = {
".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<void> {
): Promise<string> {
ctx.report(5, "Preparing");
await runFfmpeg(args, {
return runFfmpeg(args, {
signal: ctx.signal,
timeoutMs: opts.timeoutMs ?? 30 * 60_000,
onProgress: (p) => {
+28
View File
@@ -209,12 +209,15 @@ export function createToolRoute<T>(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<string, unknown> } | 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<string, unknown> };
if (part.type === "file") {
fileCount++;
if (fileCount > maxInputs) {
@@ -248,6 +251,31 @@ export function createToolRoute<T>(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",
@@ -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 };
},
});
}
@@ -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 ?? {},
},
},
};
},
});
}
+31
View File
@@ -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 };
},
});
}
+62
View File
@@ -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 };
},
});
}
+29 -1
View File
@@ -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<void> {
{ 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 },
+85
View File
@@ -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<string, string> = {
mp3: "audio/mpeg",
wav: "audio/wav",
flac: "audio/flac",
m4a: "audio/mp4",
};
const ENCODERS: Record<string, string[]> = {
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],
};
},
});
}
@@ -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<string, number> = {
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 };
},
});
}
@@ -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 };
},
});
}
+35
View File
@@ -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 };
},
});
}
@@ -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 };
},
});
}
@@ -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" };
},
});
}
@@ -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 };
},
});
}
+145
View File
@@ -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<void>((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",
};
},
});
}
+6 -13
View File
@@ -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",
@@ -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 };
},
});
}
@@ -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" };
},
});
}
@@ -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<ChannelMode>("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 (
<div className="space-y-4">
<div>
<label htmlFor="ac-mode" className="text-xs text-muted-foreground">
{s.mode}
</label>
<select
id="ac-mode"
value={mode}
onChange={(e) => setMode(e.target.value as ChannelMode)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
>
<option value="stereo-to-mono">{s["stereo-to-mono"]}</option>
<option value="mono-to-stereo">{s["mono-to-stereo"]}</option>
<option value="swap">{s.swap}</option>
</select>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="audio-channels-submit"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
</button>
)}
</div>
);
}
@@ -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<string, unknown> = { 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 (
<div className="space-y-4">
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={strip}
onChange={(e) => setStrip(e.target.checked)}
className="rounded"
/>
{s.strip}
</label>
<div>
<label htmlFor="am-title" className="text-xs text-muted-foreground">
{s.title}
</label>
<input
id="am-title"
type="text"
maxLength={500}
value={title}
onChange={(e) => 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"
/>
</div>
<div>
<label htmlFor="am-artist" className="text-xs text-muted-foreground">
{s.artist}
</label>
<input
id="am-artist"
type="text"
maxLength={500}
value={artist}
onChange={(e) => 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"
/>
</div>
<div>
<label htmlFor="am-album" className="text-xs text-muted-foreground">
{s.album}
</label>
<input
id="am-album"
type="text"
maxLength={500}
value={album}
onChange={(e) => 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"
/>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="audio-metadata-submit"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
</button>
)}
</div>
);
}
@@ -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 (
<div className="space-y-4">
<div>
<label htmlFor="as-factor" className="text-xs text-muted-foreground">
{s.factor}
</label>
<input
id="as-factor"
type="number"
min={0.25}
max={4}
step={0.25}
value={factor}
onChange={(e) => 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"
/>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="audio-speed-submit"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
</button>
)}
</div>
);
}
@@ -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 (
<div className="space-y-4">
<div>
<label htmlFor="fa-fadein" className="text-xs text-muted-foreground">
{s["fade-in-s"]}
</label>
<input
id="fa-fadein"
type="number"
min={0}
max={30}
step={0.5}
value={fadeInS}
onChange={(e) => 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"
/>
</div>
<div>
<label htmlFor="fa-fadeout" className="text-xs text-muted-foreground">
{s["fade-out-s"]}
</label>
<input
id="fa-fadeout"
type="number"
min={0}
max={30}
step={0.5}
value={fadeOutS}
onChange={(e) => 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"
/>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="fade-audio-submit"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
</button>
)}
</div>
);
}
@@ -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<AudioFormat>("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 (
<div className="space-y-4">
<div>
<label htmlFor="ma-format" className="text-xs text-muted-foreground">
{s.format}
</label>
<select
id="ma-format"
value={outFormat}
onChange={(e) => setOutFormat(e.target.value as AudioFormat)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
>
<option value="mp3">{s.mp3}</option>
<option value="wav">{s.wav}</option>
<option value="flac">{s.flac}</option>
<option value="m4a">{s.m4a}</option>
</select>
</div>
<p className="text-[10px] text-muted-foreground">{s["order-hint"]}</p>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="merge-audio-submit"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
</button>
)}
</div>
);
}
@@ -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<Strength>("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 (
<div className="space-y-4">
<div>
<label htmlFor="nr-strength" className="text-xs text-muted-foreground">
{s.strength}
</label>
<select
id="nr-strength"
value={strength}
onChange={(e) => setStrength(e.target.value as Strength)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
>
<option value="light">{s.light}</option>
<option value="medium">{s.medium}</option>
<option value="strong">{s.strong}</option>
</select>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="noise-reduction-submit"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
</button>
)}
</div>
);
}
@@ -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 (
<div className="space-y-4">
<p className="text-[10px] text-muted-foreground">{s.hint}</p>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="normalize-audio-submit"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
</button>
)}
</div>
);
}
@@ -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 (
<div className="space-y-4">
<div>
<label htmlFor="ps-semi" className="text-xs text-muted-foreground">
{s.semitones}
</label>
<input
id="ps-semi"
type="number"
min={-12}
max={12}
step={1}
value={semitones}
onChange={(e) => 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"
/>
<p className="text-[10px] text-muted-foreground mt-0.5">{s.hint}</p>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="pitch-shift-submit"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
</button>
)}
</div>
);
}
@@ -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 (
<div className="space-y-4">
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="reverse-audio-submit"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
</button>
)}
</div>
);
}
@@ -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 (
<div className="space-y-4">
<div>
<label htmlFor="rm-start" className="text-xs text-muted-foreground">
{s["start-s"]}
</label>
<input
id="rm-start"
type="number"
min={0}
step={0.5}
value={startS}
onChange={(e) => 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"
/>
</div>
<div>
<label htmlFor="rm-duration" className="text-xs text-muted-foreground">
{s["duration-s"]}
</label>
<input
id="rm-duration"
type="number"
min={1}
max={30}
step={0.5}
value={durationS}
onChange={(e) => 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"
/>
<p className="text-[10px] text-muted-foreground mt-0.5">{s["max-hint"]}</p>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="ringtone-maker-submit"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
</button>
)}
</div>
);
}
@@ -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 (
<div className="space-y-4">
<div>
<label htmlFor="sr-threshold" className="text-xs text-muted-foreground">
{s["threshold-db"]}
</label>
<input
id="sr-threshold"
type="number"
min={-80}
max={-20}
step={1}
value={thresholdDb}
onChange={(e) => 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"
/>
</div>
<div>
<label htmlFor="sr-minsilence" className="text-xs text-muted-foreground">
{s["min-silence-s"]}
</label>
<input
id="sr-minsilence"
type="number"
min={0.1}
max={5}
step={0.1}
value={minSilenceS}
onChange={(e) => 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"
/>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="silence-removal-submit"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
</button>
)}
</div>
);
}
@@ -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<SplitMode>("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<string, unknown> = { 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 (
<div className="space-y-4">
<div>
<label htmlFor="sa-mode" className="text-xs text-muted-foreground">
{s.mode}
</label>
<select
id="sa-mode"
value={mode}
onChange={(e) => setMode(e.target.value as SplitMode)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
>
<option value="time">{s.time}</option>
<option value="parts">{s.parts}</option>
<option value="silence">{s.silence}</option>
</select>
</div>
{mode === "time" && (
<div>
<label htmlFor="sa-segment" className="text-xs text-muted-foreground">
{s["segment-s"]}
</label>
<input
id="sa-segment"
type="number"
min={1}
max={3600}
step={1}
value={segmentS}
onChange={(e) => 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"
/>
</div>
)}
{mode === "parts" && (
<div>
<label htmlFor="sa-parts" className="text-xs text-muted-foreground">
{s["num-parts"]}
</label>
<input
id="sa-parts"
type="number"
min={2}
max={20}
step={1}
value={parts}
onChange={(e) => 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"
/>
</div>
)}
{mode === "silence" && (
<>
<div>
<label htmlFor="sa-threshold" className="text-xs text-muted-foreground">
{s["threshold-db"]}
</label>
<input
id="sa-threshold"
type="number"
min={-80}
max={-20}
step={1}
value={thresholdDb}
onChange={(e) => 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"
/>
</div>
<div>
<label htmlFor="sa-minsilence" className="text-xs text-muted-foreground">
{s["min-silence-s"]}
</label>
<input
id="sa-minsilence"
type="number"
min={0.1}
max={10}
step={0.1}
value={minSilenceS}
onChange={(e) => 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"
/>
</div>
</>
)}
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="split-audio-submit"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
</button>
)}
</div>
);
}
@@ -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 (
<div className="space-y-4">
<div>
<label htmlFor="va-gain" className="text-xs text-muted-foreground">
{s["gain-db"]}
</label>
<input
id="va-gain"
type="number"
min={-30}
max={30}
step={0.5}
value={gainDb}
onChange={(e) => 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"
/>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="volume-adjust-submit"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
</button>
)}
</div>
);
}
@@ -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 (
<div className="space-y-4">
<div>
<label htmlFor="wi-width" className="text-xs text-muted-foreground">
{s.width}
</label>
<input
id="wi-width"
type="number"
min={256}
max={3840}
step={1}
value={width}
onChange={(e) => 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"
/>
</div>
<div>
<label htmlFor="wi-height" className="text-xs text-muted-foreground">
{s.height}
</label>
<input
id="wi-height"
type="number"
min={64}
max={1080}
step={1}
value={height}
onChange={(e) => 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"
/>
</div>
<div>
<label htmlFor="wi-color" className="text-xs text-muted-foreground">
{s.color}
</label>
<input
id="wi-color"
type="text"
maxLength={7}
value={color}
onChange={(e) => 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"
/>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="waveform-image-submit"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
</button>
)}
</div>
);
}
+10
View File
@@ -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<string, LucideIcon> = {
Activity,
Anchor,
AppWindow,
Archive,
AudioLines,
AudioWaveform,
BellRing,
BookImage,
BookOpen,
Braces,
@@ -152,6 +160,7 @@ export const ICON_MAP: Record<string, LucideIcon> = {
MessageSquareText,
MicVocal,
Minimize2,
Music,
Palette,
PenLine,
PenTool,
@@ -182,6 +191,7 @@ export const ICON_MAP: Record<string, LucideIcon> = {
Volume2,
VolumeX,
Wand,
Waves,
Wrench,
Zap,
ZoomIn,
+16 -1
View File
@@ -121,9 +121,23 @@ export const TOOL_DISPLAY_MODES: Record<string, DisplayMode> = {
"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<string, DisplayMode> = {
* Consumed by use-tool-processor; backend routes declare maxInputs.
*/
export const MULTI_FILE_TOOLS: ReadonlySet<string> = new Set([
"merge-audio",
"merge-pdf",
"merge-videos",
"replace-audio",
+86
View File
@@ -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 }],
+6 -4
View File
@@ -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<void> {
export async function runFfmpeg(args: string[], opts: RunFfmpegOptions = {}): Promise<string> {
const bin = resolveFfmpeg();
if (!bin) throw new Error("ffmpeg binary not found (set FFMPEG_PATH or install ffmpeg)");
await new Promise<void>((resolvePromise, reject) => {
return new Promise<string>((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)}`));
});
});
+16 -1
View File
@@ -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<string, string>;
}
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<string, unknown>;
};
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<string, unknown>;
const tags: Record<string, string> = {};
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,
};
}
+154
View File
@@ -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",
+167
View File
@@ -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",
+167
View File
@@ -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",
+167
View File
@@ -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",
+167
View File
@@ -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",
+167
View File
@@ -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",
+167
View File
@@ -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",
+167
View File
@@ -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",
+167
View File
@@ -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",
+167
View File
@@ -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",
+167
View File
@@ -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",
+167
View File
@@ -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",
+167
View File
@@ -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",
+167
View File
@@ -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",
+167
View File
@@ -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",
+167
View File
@@ -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",
+167
View File
@@ -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",
+167
View File
@@ -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",
+167
View File
@@ -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",
+167
View File
@@ -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",
+167
View File
@@ -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",
+167
View File
@@ -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",
Binary file not shown.
Binary file not shown.
+1
View File
@@ -29,6 +29,7 @@ export const TOOL_SETTINGS_OVERRIDES: Record<string, unknown> = {
"rotate-video": { transform: "cw90" },
"resize-video": { preset: "720p" },
"watermark-video": { text: "CONFIDENTIAL" },
"audio-channels": { mode: "mono-to-stereo" },
};
export function defaultSettingsFor(toolId: string): unknown {
+86
View File
@@ -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<string, unknown>,
) {
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);
});
});
+68
View File
@@ -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<string, unknown>) {
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);
});
});
+60
View File
@@ -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<string, unknown>) {
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);
});
});
+52
View File
@@ -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<string, unknown>) {
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);
});
});
+70
View File
@@ -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);
});
+52
View File
@@ -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<string, unknown>) {
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);
});
+47
View File
@@ -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<string, unknown>) {
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);
});
+59
View File
@@ -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<string, unknown>) {
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);
});
});
+64
View File
@@ -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<string, unknown>) {
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);
});
+51
View File
@@ -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<string, unknown>) {
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);
});
+54
View File
@@ -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<string, unknown>) {
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);
});
+126
View File
@@ -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);
});
+60
View File
@@ -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<string, unknown>) {
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);
});
});
+55
View File
@@ -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<string, unknown>) {
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);
});
});
+24
View File
@@ -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");
});
});
+146 -1
View File
@@ -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 });
});
});
});
+1 -1
View File
@@ -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", () => {