diff --git a/package-lock.json b/package-lock.json index a1708ac03..8f33b7ea0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,8 @@ "axios": "^1.8.2", "dompurify": "^3.2.5", "dotenv": "^16.4.5", + "extendable-media-recorder": "^9.2.27", + "extendable-media-recorder-wav-encoder": "^7.0.129", "firebase": "^11.6.0", "fuse.js": "^7.0.0", "jsondiffpatch": "^0.6.0", @@ -877,13 +879,10 @@ } }, "node_modules/@babel/runtime": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.10.tgz", - "integrity": "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==", + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", + "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", "license": "MIT", - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, "engines": { "node": ">=6.9.0" } @@ -5724,6 +5723,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/automation-events": { + "version": "7.1.11", + "resolved": "https://registry.npmjs.org/automation-events/-/automation-events-7.1.11.tgz", + "integrity": "sha512-TnclbJ0482ydRenzrR9FIbqalHScBBdQTIXv8tVunhYx8dq7E0Eq5v5CSAo67YmLXNbx5jCstHcLZDJ33iONDw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18.2.0" + } + }, "node_modules/autoprefixer": { "version": "10.4.19", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.19.tgz", @@ -5974,6 +5986,18 @@ "node": ">=8" } }, + "node_modules/broker-factory": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/broker-factory/-/broker-factory-3.1.7.tgz", + "integrity": "sha512-RxbMXWq/Qvw9aLZMvuooMtVTm2/SV9JEpxpBbMuFhYAnDaZxctbJ+1b9ucHxADk/eQNqDijvWQjLVARqExAeyg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "fast-unique-numbers": "^9.0.22", + "tslib": "^2.8.1", + "worker-factory": "^7.0.43" + } + }, "node_modules/browserslist": { "version": "4.24.5", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.5.tgz", @@ -8330,6 +8354,56 @@ "node": ">=0.10.0" } }, + "node_modules/extendable-media-recorder": { + "version": "9.2.27", + "resolved": "https://registry.npmjs.org/extendable-media-recorder/-/extendable-media-recorder-9.2.27.tgz", + "integrity": "sha512-2X+Ixi1cxLek0Cj9x9atmhQ+apG+LwJpP2p3ypP8Pxau0poDnicrg7FTfPVQV5PW/3DHFm/eQ16vbgo5Yk3HGQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "media-encoder-host": "^9.0.20", + "multi-buffer-data-view": "^6.0.22", + "recorder-audio-worklet": "^6.0.48", + "standardized-audio-context": "^25.3.77", + "subscribable-things": "^2.1.53", + "tslib": "^2.8.1" + } + }, + "node_modules/extendable-media-recorder-wav-encoder": { + "version": "7.0.129", + "resolved": "https://registry.npmjs.org/extendable-media-recorder-wav-encoder/-/extendable-media-recorder-wav-encoder-7.0.129.tgz", + "integrity": "sha512-/wqM2hnzvLy/iUlg/EU3JIF8MJcidy8I77Z7CCm5+CVEClDfcs6bH9PgghuisndwKTaud0Dh48RTD83gkfEjCw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "extendable-media-recorder-wav-encoder-broker": "^7.0.119", + "extendable-media-recorder-wav-encoder-worker": "^8.0.116", + "tslib": "^2.8.1" + } + }, + "node_modules/extendable-media-recorder-wav-encoder-broker": { + "version": "7.0.119", + "resolved": "https://registry.npmjs.org/extendable-media-recorder-wav-encoder-broker/-/extendable-media-recorder-wav-encoder-broker-7.0.119.tgz", + "integrity": "sha512-BLrFOnqFLpsmmNpSk/TfjNs4j6ImCSGtoryIpRlqNu5S/Avt6gRJI0s4UYvdK7h17PCi+8vaDr75blvmU1sYlw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "broker-factory": "^3.1.7", + "extendable-media-recorder-wav-encoder-worker": "^8.0.116", + "tslib": "^2.8.1" + } + }, + "node_modules/extendable-media-recorder-wav-encoder-worker": { + "version": "8.0.116", + "resolved": "https://registry.npmjs.org/extendable-media-recorder-wav-encoder-worker/-/extendable-media-recorder-wav-encoder-worker-8.0.116.tgz", + "integrity": "sha512-bJPR0B7ZHeoqi9YoSie+UXAfEYya3efQ9eLiWuyK4KcOv+SuYQvWCoyzX5kjvb6GqIBCUnev5xulfeHRlyCwvw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "tslib": "^2.8.1", + "worker-factory": "^7.0.43" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -8373,6 +8447,19 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-unique-numbers": { + "version": "9.0.22", + "resolved": "https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-9.0.22.tgz", + "integrity": "sha512-dBR+30yHAqBGvOuxxQdnn2lTLHCO6r/9B+M4yF8mNrzr3u1yiF+YVJ6u3GTyPN/VRWqaE1FcscZDdBgVKmrmQQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18.2.0" + } + }, "node_modules/fast-uri": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.3.tgz", @@ -11408,6 +11495,43 @@ "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", "license": "MIT" }, + "node_modules/media-encoder-host": { + "version": "9.0.20", + "resolved": "https://registry.npmjs.org/media-encoder-host/-/media-encoder-host-9.0.20.tgz", + "integrity": "sha512-IyEYxw6az97RNuETOAZV4YZqNAPOiF9GKIp5mVZb4HOyWd6mhkWQ34ydOzhqAWogMyc4W05kjN/VCgTtgyFmsw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "media-encoder-host-broker": "^8.0.19", + "media-encoder-host-worker": "^10.0.19", + "tslib": "^2.8.1" + } + }, + "node_modules/media-encoder-host-broker": { + "version": "8.0.19", + "resolved": "https://registry.npmjs.org/media-encoder-host-broker/-/media-encoder-host-broker-8.0.19.tgz", + "integrity": "sha512-lTpsNuaZdTCdtTHsOyww7Ae0Mwv+7mFS+O4YkFYWhXwVs0rm6XbRK5jRRn5JmcX3n1eTE1lQS5RgX8qbNaIjSg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "broker-factory": "^3.1.7", + "fast-unique-numbers": "^9.0.22", + "media-encoder-host-worker": "^10.0.19", + "tslib": "^2.8.1" + } + }, + "node_modules/media-encoder-host-worker": { + "version": "10.0.19", + "resolved": "https://registry.npmjs.org/media-encoder-host-worker/-/media-encoder-host-worker-10.0.19.tgz", + "integrity": "sha512-I8fwc6f41peER3RFSiwDxnIHbqU7p3pc2ghQozcw9CQfL0mWEo4IjQJtyswrrlL/HO2pgVSMQbaNzE4q/0mfDQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "extendable-media-recorder-wav-encoder-broker": "^7.0.119", + "tslib": "^2.8.1", + "worker-factory": "^7.0.43" + } + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -12182,6 +12306,19 @@ "dev": true, "license": "MIT" }, + "node_modules/multi-buffer-data-view": { + "version": "6.0.22", + "resolved": "https://registry.npmjs.org/multi-buffer-data-view/-/multi-buffer-data-view-6.0.22.tgz", + "integrity": "sha512-SsI/exkodHsh+ofCV7An2PZWRaJC7eFVl7gtHQlMWFEDmWtb7cELr/GK32Nhe/6dZQhbr81o+Moswx9aXN3RRg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18.2.0" + } + }, "node_modules/mustache": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", @@ -13754,10 +13891,31 @@ "node": ">=8.10.0" } }, - "node_modules/regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" + "node_modules/recorder-audio-worklet": { + "version": "6.0.48", + "resolved": "https://registry.npmjs.org/recorder-audio-worklet/-/recorder-audio-worklet-6.0.48.tgz", + "integrity": "sha512-PVlq/1hjCrPcUGqARg8rR30A303xDCao0jmlBTaUaKkN3Xme58RI7EQxurv8rw2eDwVrN+nrni0UoJoa5/v+zg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "broker-factory": "^3.1.7", + "fast-unique-numbers": "^9.0.22", + "recorder-audio-worklet-processor": "^5.0.35", + "standardized-audio-context": "^25.3.77", + "subscribable-things": "^2.1.53", + "tslib": "^2.8.1", + "worker-factory": "^7.0.43" + } + }, + "node_modules/recorder-audio-worklet-processor": { + "version": "5.0.35", + "resolved": "https://registry.npmjs.org/recorder-audio-worklet-processor/-/recorder-audio-worklet-processor-5.0.35.tgz", + "integrity": "sha512-5Nzbk/6QzC3QFQ1EG2SE34c1ygLE22lIOvLyjy7N6XxE/jpAZrL4e7xR+yihiTaG3ajiWy6UjqL4XEBMM9ahFQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "tslib": "^2.8.1" + } }, "node_modules/registry-auth-token": { "version": "5.0.3", @@ -14274,6 +14432,12 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rxjs-interop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/rxjs-interop/-/rxjs-interop-2.0.0.tgz", + "integrity": "sha512-ASEq9atUw7lualXB+knvgtvwkCEvGWV2gDD/8qnASzBkzEARZck9JAyxmY8OS6Nc1pCPEgDTKNcx+YqqYfzArw==", + "license": "MIT" + }, "node_modules/sade": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", @@ -14666,6 +14830,17 @@ "dev": true, "license": "MIT" }, + "node_modules/standardized-audio-context": { + "version": "25.3.77", + "resolved": "https://registry.npmjs.org/standardized-audio-context/-/standardized-audio-context-25.3.77.tgz", + "integrity": "sha512-Ki9zNz6pKcC5Pi+QPjPyVsD9GwJIJWgryji0XL9cAJXMGyn+dPOf6Qik1AHei0+UNVcc4BOCa0hWLBzlwqsW/A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.6", + "automation-events": "^7.0.9", + "tslib": "^2.7.0" + } + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -14845,6 +15020,17 @@ "integrity": "sha512-H2N9c26eXjzL/S/K+i/RHHcFanE74dptvvjM8iwzwbVcWY/zjBbgRqF3K0DY4+OD+uTTASTBvDoxPDaPN02D7g==", "dev": true }, + "node_modules/subscribable-things": { + "version": "2.1.53", + "resolved": "https://registry.npmjs.org/subscribable-things/-/subscribable-things-2.1.53.tgz", + "integrity": "sha512-zWvN9F/eYQWDKszXl4NXkyqPXvMDZDmXfcHiM5C5WQZTTY2OK+2TZeDlA9oio69FEPqPu9T6yeEcAhQ2uRmnaw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "rxjs-interop": "^2.0.0", + "tslib": "^2.8.1" + } + }, "node_modules/sucrase": { "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", @@ -17374,6 +17560,17 @@ "node": ">=0.10.0" } }, + "node_modules/worker-factory": { + "version": "7.0.43", + "resolved": "https://registry.npmjs.org/worker-factory/-/worker-factory-7.0.43.tgz", + "integrity": "sha512-SACVoj3gWKtMVyT9N+VD11Pd/Xe58+ZFfp8b7y/PagOvj3i8lU3Uyj+Lj7WYTmSBvNLC0JFaQkx44E6DhH5+WA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "fast-unique-numbers": "^9.0.22", + "tslib": "^2.8.1" + } + }, "node_modules/wrap-ansi": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", diff --git a/package.json b/package.json index 0e00fa692..94eba827f 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,8 @@ "axios": "^1.8.2", "dompurify": "^3.2.5", "dotenv": "^16.4.5", + "extendable-media-recorder": "^9.2.27", + "extendable-media-recorder-wav-encoder": "^7.0.129", "firebase": "^11.6.0", "fuse.js": "^7.0.0", "jsondiffpatch": "^0.6.0", diff --git a/src/extensions/core/uploadAudio.ts b/src/extensions/core/uploadAudio.ts index afd52f4cf..701fc0b5d 100644 --- a/src/extensions/core/uploadAudio.ts +++ b/src/extensions/core/uploadAudio.ts @@ -1,5 +1,9 @@ import type { LGraphNode } from '@comfyorg/litegraph' -import type { IStringWidget } from '@comfyorg/litegraph/dist/types/widgets' +import type { + IBaseWidget, + IStringWidget +} from '@comfyorg/litegraph/dist/types/widgets' +import { MediaRecorder as ExtendableMediaRecorder } from 'extendable-media-recorder' import { useChainCallback } from '@/composables/functional/useChainCallback' import { useNodeDragAndDrop } from '@/composables/node/useNodeDragAndDrop' @@ -9,6 +13,7 @@ import { t } from '@/i18n' import type { ResultItemType } from '@/schemas/apiSchema' import type { ComfyNodeDef } from '@/schemas/nodeDefSchema' import type { DOMWidget } from '@/scripts/domWidget' +import { useAudioService } from '@/services/audioService' import { useToastStore } from '@/stores/toastStore' import { NodeLocatorId } from '@/types' import { getNodeByLocatorId } from '@/utils/graphTraversalUtil' @@ -257,3 +262,167 @@ app.registerExtension({ } } }) + +app.registerExtension({ + name: 'Comfy.RecordAudio', + + getCustomWidgets() { + return { + AUDIO_RECORD(node, inputName: string) { + const audio = document.createElement('audio') + audio.controls = true + audio.classList.add('comfy-audio') + audio.setAttribute('name', 'media') + + const audioUIWidget: DOMWidget = + node.addDOMWidget(inputName, /* name=*/ 'audioUI', audio) + + let mediaRecorder: MediaRecorder | null = null + let isRecording = false + let audioChunks: Blob[] = [] + let currentStream: MediaStream | null = null + let recordWidget: IBaseWidget | null = null + + let stopPromise: Promise | null = null + let stopResolve: (() => void) | null = null + + audioUIWidget.serializeValue = async () => { + if (isRecording && mediaRecorder) { + stopPromise = new Promise((resolve) => { + stopResolve = resolve + }) + + mediaRecorder.stop() + + await stopPromise + } + + const audioSrc = audioUIWidget.element.src + + if (!audioSrc) { + useToastStore().addAlert(t('g.noAudioRecorded')) + return '' + } + + const blob = await fetch(audioSrc).then((r) => r.blob()) + + return await useAudioService().convertBlobToFileAndSubmit(blob) + } + + recordWidget = node.addWidget( + 'button', + inputName, + '', + async () => { + if (!isRecording) { + try { + currentStream = await navigator.mediaDevices.getUserMedia({ + audio: true + }) + + mediaRecorder = new ExtendableMediaRecorder(currentStream, { + mimeType: 'audio/wav' + }) as unknown as MediaRecorder + + audioChunks = [] + + mediaRecorder.ondataavailable = (event) => { + audioChunks.push(event.data) + } + + mediaRecorder.onstop = async () => { + const audioBlob = new Blob(audioChunks, { type: 'audio/wav' }) + + useAudioService().stopAllTracks(currentStream) + + if ( + audioUIWidget.element.src && + audioUIWidget.element.src.startsWith('blob:') + ) { + URL.revokeObjectURL(audioUIWidget.element.src) + } + + audioUIWidget.element.src = URL.createObjectURL(audioBlob) + + isRecording = false + + if (recordWidget) { + recordWidget.label = t('g.startRecording') + } + + if (stopResolve) { + stopResolve() + stopResolve = null + stopPromise = null + } + } + + mediaRecorder.onerror = (event) => { + console.error('MediaRecorder error:', event) + useAudioService().stopAllTracks(currentStream) + isRecording = false + + if (recordWidget) { + recordWidget.label = t('g.startRecording') + } + + if (stopResolve) { + stopResolve() + stopResolve = null + stopPromise = null + } + } + + mediaRecorder.start() + isRecording = true + if (recordWidget) { + recordWidget.label = t('g.stopRecording') + } + } catch (err) { + console.error('Error accessing microphone:', err) + useToastStore().addAlert(t('g.micPermissionDenied')) + + if (mediaRecorder) { + try { + mediaRecorder.stop() + } catch {} + } + useAudioService().stopAllTracks(currentStream) + currentStream = null + isRecording = false + if (recordWidget) { + recordWidget.label = t('g.startRecording') + } + } + } else if (mediaRecorder && isRecording) { + mediaRecorder.stop() + } + }, + { serialize: false } + ) + + recordWidget.label = t('g.startRecording') + + const originalOnRemoved = node.onRemoved + node.onRemoved = function () { + if (isRecording && mediaRecorder) { + mediaRecorder.stop() + } + useAudioService().stopAllTracks(currentStream) + if (audioUIWidget.element.src?.startsWith('blob:')) { + URL.revokeObjectURL(audioUIWidget.element.src) + } + originalOnRemoved?.call(this) + } + + return { widget: recordWidget } + } + } + }, + + async nodeCreated(node) { + if (node.constructor.comfyClass !== 'RecordAudio') return + + await useAudioService().registerWavEncoder() + } +}) diff --git a/src/locales/en/commands.json b/src/locales/en/commands.json index 780ec7164..9960595ac 100644 --- a/src/locales/en/commands.json +++ b/src/locales/en/commands.json @@ -158,6 +158,12 @@ "Comfy_Manager_ToggleManagerProgressDialog": { "label": "Toggle the Custom Nodes Manager Progress Bar" }, + "Comfy_MaskEditor_BrushSize_Decrease": { + "label": "Decrease Brush Size in MaskEditor" + }, + "Comfy_MaskEditor_BrushSize_Increase": { + "label": "Increase Brush Size in MaskEditor" + }, "Comfy_MaskEditor_OpenMaskEditor": { "label": "Open Mask Editor for Selected Node" }, diff --git a/src/locales/en/main.json b/src/locales/en/main.json index ddd9d2313..fdda70053 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -134,6 +134,10 @@ "releaseTitle": "{package} {version} Release", "progressCountOf": "of", "keybindingAlreadyExists": "Keybinding already exists on", + "startRecording": "Start Recording", + "stopRecording": "Stop Recording", + "micPermissionDenied": "Microphone permission denied", + "noAudioRecorded": "No audio recorded", "nodesRunning": "nodes running" }, "manager": { @@ -973,6 +977,8 @@ "Load Default Workflow": "Load Default Workflow", "Toggle the Custom Nodes Manager": "Toggle the Custom Nodes Manager", "Toggle the Custom Nodes Manager Progress Bar": "Toggle the Custom Nodes Manager Progress Bar", + "Decrease Brush Size in MaskEditor": "Decrease Brush Size in MaskEditor", + "Increase Brush Size in MaskEditor": "Increase Brush Size in MaskEditor", "Open Mask Editor for Selected Node": "Open Mask Editor for Selected Node", "New": "New", "Clipspace": "Clipspace", diff --git a/src/locales/es/commands.json b/src/locales/es/commands.json index 0160c9814..4c8d12e4d 100644 --- a/src/locales/es/commands.json +++ b/src/locales/es/commands.json @@ -158,6 +158,12 @@ "Comfy_Manager_ToggleManagerProgressDialog": { "label": "Alternar diálogo de progreso del administrador" }, + "Comfy_MaskEditor_BrushSize_Decrease": { + "label": "Disminuir tamaño del pincel en MaskEditor" + }, + "Comfy_MaskEditor_BrushSize_Increase": { + "label": "Aumentar tamaño del pincel en MaskEditor" + }, "Comfy_MaskEditor_OpenMaskEditor": { "label": "Abrir editor de máscara para el nodo seleccionado" }, diff --git a/src/locales/es/main.json b/src/locales/es/main.json index 983ec7f7b..78c4720aa 100644 --- a/src/locales/es/main.json +++ b/src/locales/es/main.json @@ -331,12 +331,14 @@ "loadingPanel": "Cargando panel {panel}...", "login": "Iniciar sesión", "logs": "Registros", + "micPermissionDenied": "Permiso de micrófono denegado", "migrate": "Migrar", "missing": "Faltante", "name": "Nombre", "newFolder": "Nueva carpeta", "next": "Siguiente", "no": "No", + "noAudioRecorded": "No se grabó audio", "noResultsFound": "No se encontraron resultados", "noTasksFound": "No se encontraron tareas", "noTasksFoundMessage": "No hay tareas en la cola.", @@ -376,7 +378,9 @@ "showReport": "Mostrar informe", "sort": "Ordenar", "source": "Fuente", + "startRecording": "Iniciar grabación", "status": "Estado", + "stopRecording": "Detener grabación", "success": "Éxito", "systemInfo": "Información del sistema", "terminal": "Terminal", @@ -747,6 +751,7 @@ "Contact Support": "Contactar soporte", "Convert Selection to Subgraph": "Convertir selección en subgrafo", "Convert selected nodes to group node": "Convertir nodos seleccionados en nodo de grupo", + "Decrease Brush Size in MaskEditor": "Disminuir tamaño del pincel en MaskEditor", "Delete Selected Items": "Eliminar elementos seleccionados", "Desktop User Guide": "Guía de usuario de escritorio", "Duplicate Current Workflow": "Duplicar flujo de trabajo actual", @@ -758,6 +763,7 @@ "Give Feedback": "Dar retroalimentación", "Group Selected Nodes": "Agrupar nodos seleccionados", "Help": "Ayuda", + "Increase Brush Size in MaskEditor": "Aumentar tamaño del pincel en MaskEditor", "Interrupt": "Interrumpir", "Load Default Workflow": "Cargar flujo de trabajo predeterminado", "Manage group nodes": "Gestionar nodos de grupo", diff --git a/src/locales/fr/commands.json b/src/locales/fr/commands.json index e1595c471..7408742d8 100644 --- a/src/locales/fr/commands.json +++ b/src/locales/fr/commands.json @@ -158,6 +158,12 @@ "Comfy_Manager_ToggleManagerProgressDialog": { "label": "Basculer la boîte de dialogue de progression" }, + "Comfy_MaskEditor_BrushSize_Decrease": { + "label": "Réduire la taille du pinceau dans MaskEditor" + }, + "Comfy_MaskEditor_BrushSize_Increase": { + "label": "Augmenter la taille du pinceau dans MaskEditor" + }, "Comfy_MaskEditor_OpenMaskEditor": { "label": "Ouvrir l'éditeur de masque pour le nœud sélectionné" }, diff --git a/src/locales/fr/main.json b/src/locales/fr/main.json index f765a7723..9b980590e 100644 --- a/src/locales/fr/main.json +++ b/src/locales/fr/main.json @@ -331,12 +331,14 @@ "loadingPanel": "Chargement du panneau {panel}...", "login": "Connexion", "logs": "Journaux", + "micPermissionDenied": "Permission du microphone refusée", "migrate": "Migrer", "missing": "Manquant", "name": "Nom", "newFolder": "Nouveau dossier", "next": "Suivant", "no": "Non", + "noAudioRecorded": "Aucun audio enregistré", "noResultsFound": "Aucun résultat trouvé", "noTasksFound": "Aucune tâche trouvée", "noTasksFoundMessage": "Il n'y a pas de tâches dans la file d'attente.", @@ -376,7 +378,9 @@ "showReport": "Afficher le rapport", "sort": "Trier", "source": "Source", + "startRecording": "Commencer l’enregistrement", "status": "Statut", + "stopRecording": "Arrêter l’enregistrement", "success": "Succès", "systemInfo": "Informations système", "terminal": "Terminal", @@ -747,6 +751,7 @@ "Contact Support": "Contacter le support", "Convert Selection to Subgraph": "Convertir la sélection en sous-graphe", "Convert selected nodes to group node": "Convertir les nœuds sélectionnés en nœud de groupe", + "Decrease Brush Size in MaskEditor": "Réduire la taille du pinceau dans MaskEditor", "Delete Selected Items": "Supprimer les éléments sélectionnés", "Desktop User Guide": "Guide de l'utilisateur de bureau", "Duplicate Current Workflow": "Dupliquer le flux de travail actuel", @@ -758,6 +763,7 @@ "Give Feedback": "Donnez votre avis", "Group Selected Nodes": "Grouper les nœuds sélectionnés", "Help": "Aide", + "Increase Brush Size in MaskEditor": "Augmenter la taille du pinceau dans MaskEditor", "Interrupt": "Interrompre", "Load Default Workflow": "Charger le flux de travail par défaut", "Manage group nodes": "Gérer les nœuds de groupe", diff --git a/src/locales/ja/commands.json b/src/locales/ja/commands.json index 3ee56580e..c0018f2f8 100644 --- a/src/locales/ja/commands.json +++ b/src/locales/ja/commands.json @@ -158,6 +158,12 @@ "Comfy_Manager_ToggleManagerProgressDialog": { "label": "プログレスダイアログの切り替え" }, + "Comfy_MaskEditor_BrushSize_Decrease": { + "label": "マスクエディタでブラシサイズを縮小" + }, + "Comfy_MaskEditor_BrushSize_Increase": { + "label": "マスクエディタでブラシサイズを大きくする" + }, "Comfy_MaskEditor_OpenMaskEditor": { "label": "選択したノードのマスクエディタを開く" }, diff --git a/src/locales/ja/main.json b/src/locales/ja/main.json index 3b09fa966..f5d25e391 100644 --- a/src/locales/ja/main.json +++ b/src/locales/ja/main.json @@ -331,12 +331,14 @@ "loadingPanel": "{panel} パネルを読み込み中...", "login": "ログイン", "logs": "ログ", + "micPermissionDenied": "マイクの許可が拒否されました", "migrate": "移行する", "missing": "不足している", "name": "名前", "newFolder": "新しいフォルダー", "next": "次へ", "no": "いいえ", + "noAudioRecorded": "音声が録音されていません", "noResultsFound": "結果が見つかりません", "noTasksFound": "タスクが見つかりません", "noTasksFoundMessage": "キューにタスクがありません。", @@ -376,7 +378,9 @@ "showReport": "レポートを表示", "sort": "並び替え", "source": "ソース", + "startRecording": "録音開始", "status": "ステータス", + "stopRecording": "録音停止", "success": "成功", "systemInfo": "システム情報", "terminal": "ターミナル", @@ -747,6 +751,7 @@ "Contact Support": "サポートに連絡", "Convert Selection to Subgraph": "選択範囲をサブグラフに変換", "Convert selected nodes to group node": "選択したノードをグループノードに変換", + "Decrease Brush Size in MaskEditor": "マスクエディタでブラシサイズを小さくする", "Delete Selected Items": "選択したアイテムを削除", "Desktop User Guide": "デスクトップユーザーガイド", "Duplicate Current Workflow": "現在のワークフローを複製", @@ -758,6 +763,7 @@ "Give Feedback": "フィードバックを送る", "Group Selected Nodes": "選択したノードをグループ化", "Help": "ヘルプ", + "Increase Brush Size in MaskEditor": "マスクエディタでブラシサイズを大きくする", "Interrupt": "中断", "Load Default Workflow": "デフォルトワークフローを読み込む", "Manage group nodes": "グループノードを管理", diff --git a/src/locales/ko/commands.json b/src/locales/ko/commands.json index becb3f560..ef413dfbd 100644 --- a/src/locales/ko/commands.json +++ b/src/locales/ko/commands.json @@ -158,6 +158,12 @@ "Comfy_Manager_ToggleManagerProgressDialog": { "label": "진행 상황 대화 상자 전환" }, + "Comfy_MaskEditor_BrushSize_Decrease": { + "label": "MaskEditor에서 브러시 크기 줄이기" + }, + "Comfy_MaskEditor_BrushSize_Increase": { + "label": "MaskEditor에서 브러시 크기 늘리기" + }, "Comfy_MaskEditor_OpenMaskEditor": { "label": "선택한 노드 마스크 편집기 열기" }, diff --git a/src/locales/ko/main.json b/src/locales/ko/main.json index 8300b780c..57d42879e 100644 --- a/src/locales/ko/main.json +++ b/src/locales/ko/main.json @@ -331,12 +331,14 @@ "loadingPanel": "{panel} 패널 불러오는 중...", "login": "로그인", "logs": "로그", + "micPermissionDenied": "마이크 권한이 거부되었습니다", "migrate": "이전(migrate)", "missing": "누락됨", "name": "이름", "newFolder": "새 폴더", "next": "다음", "no": "아니오", + "noAudioRecorded": "녹음된 오디오가 없습니다", "noResultsFound": "결과를 찾을 수 없습니다.", "noTasksFound": "작업을 찾을 수 없습니다.", "noTasksFoundMessage": "대기열에 작업이 없습니다.", @@ -376,7 +378,9 @@ "showReport": "보고서 보기", "sort": "정렬", "source": "소스", + "startRecording": "녹음 시작", "status": "상태", + "stopRecording": "녹음 중지", "success": "성공", "systemInfo": "시스템 정보", "terminal": "터미널", @@ -747,6 +751,7 @@ "Contact Support": "고객 지원 문의", "Convert Selection to Subgraph": "선택 영역을 서브그래프로 변환", "Convert selected nodes to group node": "선택한 노드를 그룹 노드로 변환", + "Decrease Brush Size in MaskEditor": "MaskEditor에서 브러시 크기 줄이기", "Delete Selected Items": "선택한 항목 삭제", "Desktop User Guide": "데스크톱 사용자 가이드", "Duplicate Current Workflow": "현재 워크플로 복제", @@ -758,6 +763,7 @@ "Give Feedback": "피드백 제공", "Group Selected Nodes": "선택한 노드 그룹화", "Help": "도움말", + "Increase Brush Size in MaskEditor": "MaskEditor에서 브러시 크기 늘리기", "Interrupt": "중단", "Load Default Workflow": "기본 워크플로 불러오기", "Manage group nodes": "그룹 노드 관리", diff --git a/src/locales/ru/commands.json b/src/locales/ru/commands.json index ff3ae1195..8dbcfefa2 100644 --- a/src/locales/ru/commands.json +++ b/src/locales/ru/commands.json @@ -158,6 +158,12 @@ "Comfy_Manager_ToggleManagerProgressDialog": { "label": "Переключить диалоговое окно прогресса" }, + "Comfy_MaskEditor_BrushSize_Decrease": { + "label": "Уменьшить размер кисти в MaskEditor" + }, + "Comfy_MaskEditor_BrushSize_Increase": { + "label": "Увеличить размер кисти в MaskEditor" + }, "Comfy_MaskEditor_OpenMaskEditor": { "label": "Открыть редактор масок для выбранной ноды" }, diff --git a/src/locales/ru/main.json b/src/locales/ru/main.json index 568a523ff..2cb55de81 100644 --- a/src/locales/ru/main.json +++ b/src/locales/ru/main.json @@ -331,12 +331,14 @@ "loadingPanel": "Загрузка панели {panel}...", "login": "Вход", "logs": "Логи", + "micPermissionDenied": "Доступ к микрофону запрещён", "migrate": "Мигрировать", "missing": "Отсутствует", "name": "Имя", "newFolder": "Новая папка", "next": "Далее", "no": "Нет", + "noAudioRecorded": "Аудио не записано", "noResultsFound": "Результатов не найдено", "noTasksFound": "Задачи не найдены", "noTasksFoundMessage": "В очереди нет задач.", @@ -376,7 +378,9 @@ "showReport": "Показать отчёт", "sort": "Сортировать", "source": "Источник", + "startRecording": "Начать запись", "status": "Статус", + "stopRecording": "Остановить запись", "success": "Успех", "systemInfo": "Информация о системе", "terminal": "Терминал", @@ -747,6 +751,7 @@ "Contact Support": "Связаться с поддержкой", "Convert Selection to Subgraph": "Преобразовать выделенное в подграф", "Convert selected nodes to group node": "Преобразовать выбранные ноды в групповую ноду", + "Decrease Brush Size in MaskEditor": "Уменьшить размер кисти в MaskEditor", "Delete Selected Items": "Удалить выбранные элементы", "Desktop User Guide": "Руководство пользователя для настольных ПК", "Duplicate Current Workflow": "Дублировать текущий рабочий процесс", @@ -758,6 +763,7 @@ "Give Feedback": "Оставить отзыв", "Group Selected Nodes": "Сгруппировать выбранные ноды", "Help": "Помощь", + "Increase Brush Size in MaskEditor": "Увеличить размер кисти в MaskEditor", "Interrupt": "Прервать", "Load Default Workflow": "Загрузить стандартный рабочий процесс", "Manage group nodes": "Управление групповыми нодами", diff --git a/src/locales/zh-TW/commands.json b/src/locales/zh-TW/commands.json index c5afb8c82..d0b160b12 100644 --- a/src/locales/zh-TW/commands.json +++ b/src/locales/zh-TW/commands.json @@ -158,6 +158,12 @@ "Comfy_Manager_ToggleManagerProgressDialog": { "label": "切換自訂節點管理器進度條" }, + "Comfy_MaskEditor_BrushSize_Decrease": { + "label": "減少 MaskEditor 畫筆大小" + }, + "Comfy_MaskEditor_BrushSize_Increase": { + "label": "增加 MaskEditor 畫筆大小" + }, "Comfy_MaskEditor_OpenMaskEditor": { "label": "為選取的節點開啟 Mask 編輯器" }, diff --git a/src/locales/zh-TW/main.json b/src/locales/zh-TW/main.json index 3c159c09b..8de440278 100644 --- a/src/locales/zh-TW/main.json +++ b/src/locales/zh-TW/main.json @@ -331,12 +331,14 @@ "loadingPanel": "正在載入{panel}面板...", "login": "登入", "logs": "日誌", + "micPermissionDenied": "麥克風權限被拒絕", "migrate": "遷移", "missing": "缺少", "name": "名稱", "newFolder": "新資料夾", "next": "下一步", "no": "否", + "noAudioRecorded": "沒有錄製到音訊", "noResultsFound": "找不到結果", "noTasksFound": "找不到任務", "noTasksFoundMessage": "佇列中沒有任務。", @@ -376,7 +378,9 @@ "showReport": "顯示報告", "sort": "排序", "source": "來源", + "startRecording": "開始錄音", "status": "狀態", + "stopRecording": "停止錄音", "success": "成功", "systemInfo": "系統資訊", "terminal": "終端機", @@ -747,6 +751,7 @@ "Contact Support": "聯絡支援", "Convert Selection to Subgraph": "將選取內容轉為子圖", "Convert selected nodes to group node": "將選取節點轉為群組節點", + "Decrease Brush Size in MaskEditor": "在 MaskEditor 中減小筆刷大小", "Delete Selected Items": "刪除選取項目", "Desktop User Guide": "桌面應用程式使用指南", "Duplicate Current Workflow": "複製目前工作流程", @@ -758,6 +763,7 @@ "Give Feedback": "提供意見回饋", "Group Selected Nodes": "群組選取節點", "Help": "說明", + "Increase Brush Size in MaskEditor": "在 MaskEditor 中增大筆刷大小", "Interrupt": "中斷", "Load Default Workflow": "載入預設工作流程", "Manage group nodes": "管理群組節點", diff --git a/src/locales/zh/commands.json b/src/locales/zh/commands.json index a0dfcc84c..81020f269 100644 --- a/src/locales/zh/commands.json +++ b/src/locales/zh/commands.json @@ -158,6 +158,12 @@ "Comfy_Manager_ToggleManagerProgressDialog": { "label": "切换进度对话框" }, + "Comfy_MaskEditor_BrushSize_Decrease": { + "label": "減小 MaskEditor 中的筆刷大小" + }, + "Comfy_MaskEditor_BrushSize_Increase": { + "label": "增加 MaskEditor 畫筆大小" + }, "Comfy_MaskEditor_OpenMaskEditor": { "label": "打开选中节点的遮罩编辑器" }, diff --git a/src/locales/zh/main.json b/src/locales/zh/main.json index cdab22410..da2fd0606 100644 --- a/src/locales/zh/main.json +++ b/src/locales/zh/main.json @@ -331,12 +331,14 @@ "loadingPanel": "正在加载{panel}面板...", "login": "登录", "logs": "日志", + "micPermissionDenied": "麦克风权限被拒绝", "migrate": "迁移", "missing": "缺失", "name": "名称", "newFolder": "新文件夹", "next": "下一个", "no": "否", + "noAudioRecorded": "未录制音频", "noResultsFound": "未找到结果", "noTasksFound": "未找到任务", "noTasksFoundMessage": "队列中没有任务。", @@ -376,7 +378,9 @@ "showReport": "显示报告", "sort": "排序", "source": "来源", + "startRecording": "开始录音", "status": "状态", + "stopRecording": "停止录音", "success": "成功", "systemInfo": "系统信息", "terminal": "终端", @@ -747,6 +751,7 @@ "Contact Support": "联系支持", "Convert Selection to Subgraph": "将选中内容转换为子图", "Convert selected nodes to group node": "将选中节点转换为组节点", + "Decrease Brush Size in MaskEditor": "在 MaskEditor 中減小筆刷大小", "Delete Selected Items": "删除选定的项目", "Desktop User Guide": "桌面端用户指南", "Duplicate Current Workflow": "复制当前工作流", @@ -758,6 +763,7 @@ "Give Feedback": "提供反馈", "Group Selected Nodes": "将选中节点转换为组节点", "Help": "帮助", + "Increase Brush Size in MaskEditor": "在 MaskEditor 中增大筆刷大小", "Interrupt": "中断", "Load Default Workflow": "加载默认工作流", "Manage group nodes": "管理组节点", diff --git a/src/services/audioService.ts b/src/services/audioService.ts new file mode 100644 index 000000000..7621e3037 --- /dev/null +++ b/src/services/audioService.ts @@ -0,0 +1,84 @@ +import { register } from 'extendable-media-recorder' +import { connect } from 'extendable-media-recorder-wav-encoder' + +import { api } from '@/scripts/api' +import { useToastStore } from '@/stores/toastStore' + +export interface AudioRecordingError { + type: 'permission' | 'not_supported' | 'encoder' | 'recording' | 'unknown' + message: string + originalError?: unknown +} + +let isEncoderRegistered: boolean = false + +export const useAudioService = () => { + const handleError = ( + type: AudioRecordingError['type'], + message: string, + originalError?: unknown + ) => { + console.error(`Audio Service Error (${type}):`, message, originalError) + } + + const stopAllTracks = (currentStream: MediaStream | null) => { + if (currentStream) { + currentStream.getTracks().forEach((track) => { + track.stop() + }) + currentStream = null + } + } + + const registerWavEncoder = async (): Promise => { + if (isEncoderRegistered) { + return + } + + try { + await register(await connect()) + isEncoderRegistered = true + } catch (err) { + if ( + err instanceof Error && + err.message.includes('already an encoder stored') + ) { + isEncoderRegistered = true + } else { + handleError('encoder', 'Failed to register WAV encoder', err) + } + } + } + + const convertBlobToFileAndSubmit = async (blob: Blob): Promise => { + const name = `recording-${Date.now()}.wav` + const file = new File([blob], name, { type: blob.type || 'audio/wav' }) + + const body = new FormData() + body.append('image', file) + body.append('subfolder', 'audio') + body.append('type', 'temp') + + const resp = await api.fetchApi('/upload/image', { + method: 'POST', + body + }) + + if (resp.status !== 200) { + const err = `Error uploading temp file: ${resp.status} - ${resp.statusText}` + useToastStore().addAlert(err) + throw new Error(err) + } + + const tempAudio = await resp.json() + + return `audio/${tempAudio.name} [temp]` + } + + return { + // Methods + convertBlobToFileAndSubmit, + registerWavEncoder, + stopAllTracks + } +} diff --git a/tests-ui/tests/audioService.test.ts b/tests-ui/tests/audioService.test.ts new file mode 100644 index 000000000..68f37680b --- /dev/null +++ b/tests-ui/tests/audioService.test.ts @@ -0,0 +1,332 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { + type AudioRecordingError, + useAudioService +} from '@/services/audioService' + +const mockRegister = vi.hoisted(() => vi.fn()) +const mockConnect = vi.hoisted(() => vi.fn()) + +const mockApi = vi.hoisted(() => ({ + fetchApi: vi.fn() +})) + +const mockToastStore = vi.hoisted(() => ({ + addAlert: vi.fn() +})) + +vi.mock('extendable-media-recorder', () => ({ + register: mockRegister +})) + +vi.mock('extendable-media-recorder-wav-encoder', () => ({ + connect: mockConnect +})) + +vi.mock('@/scripts/api', () => ({ + api: mockApi +})) + +vi.mock('@/stores/toastStore', () => ({ + useToastStore: vi.fn(() => mockToastStore) +})) + +describe('useAudioService', () => { + let service: ReturnType + + const mockBlob = new Blob(['test audio data'], { type: 'audio/wav' }) + const mockUploadResponse = { + name: 'test-audio-123.wav' + } + + beforeEach(() => { + vi.clearAllMocks() + + vi.spyOn(console, 'error').mockImplementation(() => {}) + + mockConnect.mockResolvedValue('mock-encoder') + mockRegister.mockResolvedValue(undefined) + mockApi.fetchApi.mockResolvedValue({ + status: 200, + json: () => Promise.resolve(mockUploadResponse) + }) + + service = useAudioService() + }) + + describe('initialization', () => { + it('should initialize service with required methods', () => { + expect(service).toHaveProperty('registerWavEncoder') + expect(service).toHaveProperty('stopAllTracks') + expect(service).toHaveProperty('convertBlobToFileAndSubmit') + expect(typeof service.registerWavEncoder).toBe('function') + expect(typeof service.stopAllTracks).toBe('function') + expect(typeof service.convertBlobToFileAndSubmit).toBe('function') + }) + }) + + describe('registerWavEncoder', () => { + it('should register WAV encoder successfully on first call', async () => { + await service.registerWavEncoder() + + expect(mockConnect).toHaveBeenCalledTimes(1) + expect(mockRegister).toHaveBeenCalledWith('mock-encoder') + }) + + it('should not register again if already registered', async () => { + await service.registerWavEncoder() + + mockConnect.mockClear() + mockRegister.mockClear() + + await service.registerWavEncoder() + + expect(mockConnect).not.toHaveBeenCalled() + expect(mockRegister).not.toHaveBeenCalled() + }) + + it('should handle "already an encoder stored" error gracefully', async () => { + const error = new Error( + 'There is already an encoder stored which handles exactly the same mime types.' + ) + mockRegister.mockRejectedValueOnce(error) + + await service.registerWavEncoder() + + expect(mockConnect).toHaveBeenCalledTimes(0) + expect(mockRegister).toHaveBeenCalledTimes(0) + expect(console.error).not.toHaveBeenCalled() + }) + }) + + describe('stopAllTracks', () => { + it('should stop all tracks in a stream', () => { + const mockTrack1 = { stop: vi.fn() } + const mockTrack2 = { stop: vi.fn() } + const mockStream = { + getTracks: vi.fn().mockReturnValue([mockTrack1, mockTrack2]) + } as unknown as MediaStream + + service.stopAllTracks(mockStream) + + expect(mockStream.getTracks).toHaveBeenCalledTimes(1) + expect(mockTrack1.stop).toHaveBeenCalledTimes(1) + expect(mockTrack2.stop).toHaveBeenCalledTimes(1) + }) + + it('should handle null stream gracefully', () => { + expect(() => service.stopAllTracks(null)).not.toThrow() + }) + + it('should handle stream with no tracks', () => { + const mockStream = { + getTracks: vi.fn().mockReturnValue([]) + } as unknown as MediaStream + + expect(() => service.stopAllTracks(mockStream)).not.toThrow() + expect(mockStream.getTracks).toHaveBeenCalledTimes(1) + }) + + it('should handle tracks that throw on stop', () => { + const mockTrack1 = { stop: vi.fn() } + const mockTrack2 = { + stop: vi.fn().mockImplementation(() => { + throw new Error('Stop failed') + }) + } + const mockStream = { + getTracks: vi.fn().mockReturnValue([mockTrack1, mockTrack2]) + } as unknown as MediaStream + + expect(() => service.stopAllTracks(mockStream)).toThrow() + expect(mockTrack1.stop).toHaveBeenCalledTimes(1) + expect(mockTrack2.stop).toHaveBeenCalledTimes(1) + }) + }) + + describe('convertBlobToFileAndSubmit', () => { + it('should convert blob to file and upload successfully', async () => { + const result = await service.convertBlobToFileAndSubmit(mockBlob) + + expect(mockApi.fetchApi).toHaveBeenCalledWith('/upload/image', { + method: 'POST', + body: expect.any(FormData) + }) + + expect(result).toBe('audio/test-audio-123.wav [temp]') + }) + + it('should create file with correct name and type', async () => { + const mockTimestamp = 1640995200000 + vi.spyOn(Date, 'now').mockReturnValue(mockTimestamp) + + await service.convertBlobToFileAndSubmit(mockBlob) + + const formDataCall = mockApi.fetchApi.mock.calls[0][1].body as FormData + const uploadedFile = formDataCall.get('image') as File + + expect(uploadedFile).toBeInstanceOf(File) + expect(uploadedFile.name).toBe(`recording-${mockTimestamp}.wav`) + expect(uploadedFile.type).toBe('audio/wav') + }) + + it('should set correct form data fields', async () => { + await service.convertBlobToFileAndSubmit(mockBlob) + + const formDataCall = mockApi.fetchApi.mock.calls[0][1].body as FormData + + expect(formDataCall.get('subfolder')).toBe('audio') + expect(formDataCall.get('type')).toBe('temp') + expect(formDataCall.get('image')).toBeInstanceOf(File) + }) + + it('should handle blob with different type', async () => { + const customBlob = new Blob(['test'], { type: 'audio/ogg' }) + + await service.convertBlobToFileAndSubmit(customBlob) + + const formDataCall = mockApi.fetchApi.mock.calls[0][1].body as FormData + const uploadedFile = formDataCall.get('image') as File + + expect(uploadedFile.type).toBe('audio/ogg') + }) + + it('should handle blob with no type', async () => { + const customBlob = new Blob(['test']) + + await service.convertBlobToFileAndSubmit(customBlob) + + const formDataCall = mockApi.fetchApi.mock.calls[0][1].body as FormData + const uploadedFile = formDataCall.get('image') as File + + expect(uploadedFile.type).toBe('audio/wav') // Should default to audio/wav + }) + + it('should handle upload failure with error status', async () => { + mockApi.fetchApi.mockResolvedValueOnce({ + status: 500, + statusText: 'Internal Server Error' + }) + + await expect( + service.convertBlobToFileAndSubmit(mockBlob) + ).rejects.toThrow( + 'Error uploading temp file: 500 - Internal Server Error' + ) + + expect(mockToastStore.addAlert).toHaveBeenCalledWith( + 'Error uploading temp file: 500 - Internal Server Error' + ) + }) + + it('should handle network errors', async () => { + const networkError = new Error('Network Error') + mockApi.fetchApi.mockRejectedValueOnce(networkError) + + await expect( + service.convertBlobToFileAndSubmit(mockBlob) + ).rejects.toThrow('Network Error') + }) + + it('should handle different status codes', async () => { + const testCases = [ + { status: 400, statusText: 'Bad Request' }, + { status: 403, statusText: 'Forbidden' }, + { status: 404, statusText: 'Not Found' }, + { status: 413, statusText: 'Payload Too Large' } + ] + + for (const testCase of testCases) { + mockApi.fetchApi.mockResolvedValueOnce(testCase) + + await expect( + service.convertBlobToFileAndSubmit(mockBlob) + ).rejects.toThrow( + `Error uploading temp file: ${testCase.status} - ${testCase.statusText}` + ) + + expect(mockToastStore.addAlert).toHaveBeenCalledWith( + `Error uploading temp file: ${testCase.status} - ${testCase.statusText}` + ) + + mockToastStore.addAlert.mockClear() + } + }) + + it('should handle malformed response JSON', async () => { + mockApi.fetchApi.mockResolvedValueOnce({ + status: 200, + json: () => Promise.reject(new Error('Invalid JSON')) + }) + + await expect( + service.convertBlobToFileAndSubmit(mockBlob) + ).rejects.toThrow('Invalid JSON') + }) + + it('should handle empty response', async () => { + mockApi.fetchApi.mockResolvedValueOnce({ + status: 200, + json: () => Promise.resolve({}) + }) + + const result = await service.convertBlobToFileAndSubmit(mockBlob) + + expect(result).toBe('audio/undefined [temp]') + }) + }) + + describe('error handling', () => { + it('should handle AudioRecordingError interface correctly', () => { + const error: AudioRecordingError = { + type: 'permission', + message: 'Microphone access denied', + originalError: new Error('Permission denied') + } + + expect(error.type).toBe('permission') + expect(error.message).toBe('Microphone access denied') + expect(error.originalError).toBeInstanceOf(Error) + }) + + it('should support all error types', () => { + const errorTypes = [ + 'permission', + 'not_supported', + 'encoder', + 'recording', + 'unknown' + ] as const + + errorTypes.forEach((type) => { + const error: AudioRecordingError = { + type, + message: `Test error for ${type}` + } + + expect(error.type).toBe(type) + }) + }) + }) + + describe('edge cases', () => { + it('should handle very large blobs', async () => { + const largeData = new Array(1000000).fill('a').join('') + const largeBlob = new Blob([largeData], { type: 'audio/wav' }) + + const result = await service.convertBlobToFileAndSubmit(largeBlob) + + expect(result).toBe('audio/test-audio-123.wav [temp]') + expect(mockApi.fetchApi).toHaveBeenCalledTimes(1) + }) + + it('should handle empty blob', async () => { + const emptyBlob = new Blob([], { type: 'audio/wav' }) + + const result = await service.convertBlobToFileAndSubmit(emptyBlob) + + expect(result).toBe('audio/test-audio-123.wav [temp]') + }) + }) +})