record audio node support (#4289)

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
This commit is contained in:
Terry Jia
2025-07-24 03:22:16 -04:00
committed by GitHub
parent bb5aef9275
commit 906bc42f7f
21 changed files with 891 additions and 11 deletions

217
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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<HTMLAudioElement, string> =
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<void> | 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()
}
})

View File

@@ -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"
},

View File

@@ -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",

View File

@@ -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"
},

View File

@@ -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",

View File

@@ -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é"
},

View File

@@ -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 lenregistrement",
"status": "Statut",
"stopRecording": "Arrêter lenregistrement",
"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",

View File

@@ -158,6 +158,12 @@
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "プログレスダイアログの切り替え"
},
"Comfy_MaskEditor_BrushSize_Decrease": {
"label": "マスクエディタでブラシサイズを縮小"
},
"Comfy_MaskEditor_BrushSize_Increase": {
"label": "マスクエディタでブラシサイズを大きくする"
},
"Comfy_MaskEditor_OpenMaskEditor": {
"label": "選択したノードのマスクエディタを開く"
},

View File

@@ -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": "グループノードを管理",

View File

@@ -158,6 +158,12 @@
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "진행 상황 대화 상자 전환"
},
"Comfy_MaskEditor_BrushSize_Decrease": {
"label": "MaskEditor에서 브러시 크기 줄이기"
},
"Comfy_MaskEditor_BrushSize_Increase": {
"label": "MaskEditor에서 브러시 크기 늘리기"
},
"Comfy_MaskEditor_OpenMaskEditor": {
"label": "선택한 노드 마스크 편집기 열기"
},

View File

@@ -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": "그룹 노드 관리",

View File

@@ -158,6 +158,12 @@
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "Переключить диалоговое окно прогресса"
},
"Comfy_MaskEditor_BrushSize_Decrease": {
"label": "Уменьшить размер кисти в MaskEditor"
},
"Comfy_MaskEditor_BrushSize_Increase": {
"label": "Увеличить размер кисти в MaskEditor"
},
"Comfy_MaskEditor_OpenMaskEditor": {
"label": "Открыть редактор масок для выбранной ноды"
},

View File

@@ -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": "Управление групповыми нодами",

View File

@@ -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 編輯器"
},

View File

@@ -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": "管理群組節點",

View File

@@ -158,6 +158,12 @@
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "切换进度对话框"
},
"Comfy_MaskEditor_BrushSize_Decrease": {
"label": "減小 MaskEditor 中的筆刷大小"
},
"Comfy_MaskEditor_BrushSize_Increase": {
"label": "增加 MaskEditor 畫筆大小"
},
"Comfy_MaskEditor_OpenMaskEditor": {
"label": "打开选中节点的遮罩编辑器"
},

View File

@@ -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": "管理组节点",

View File

@@ -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<void> => {
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<string> => {
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
}
}

View File

@@ -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<typeof useAudioService>
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]')
})
})
})