From a88673b322f29e27fa5de31700f2cb782206218c Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Tue, 17 Sep 2024 12:09:04 +0200 Subject: [PATCH] switch audio recorder to extendable-media-recorder Signed-off-by: Julien Veyssier --- package-lock.json | 204 +++++++++++- package.json | 1 + .../fields/AudioRecorderWrapper.vue | 296 +++++++++--------- vite.config.ts | 3 + 4 files changed, 354 insertions(+), 150 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0f0314e1..08fbad42 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@nextcloud/moment": "^1.3.1", "@nextcloud/router": "^3.0.0", "@nextcloud/vue": "^8.1.0", + "extendable-media-recorder": "^9.2.11", "moment": "^2.30.1", "v-click-outside": "^3.2.0", "vue": "^2.7.12", @@ -1873,9 +1874,9 @@ "peer": true }, "node_modules/@babel/runtime": { - "version": "7.25.4", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.4.tgz", - "integrity": "sha512-DSgLeL/FNcpXuzav5wfYvHCGvynXkJbn3Zvc3823AEe9nPwW9IK4UoCSS5yGymmQzN0pCPvivtgS6/8U2kkm1w==", + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.6.tgz", + "integrity": "sha512-VBj9MYyDb9tuLq7yzqjgzt6Q+IBQLrGZfdjOekyEirZPHxXWoTSGUTMrpsfi58Up73d13NfYLv8HT9vmznjzhQ==", "license": "MIT", "dependencies": { "regenerator-runtime": "^0.14.0" @@ -5048,6 +5049,19 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, + "node_modules/automation-events": { + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/automation-events/-/automation-events-7.0.9.tgz", + "integrity": "sha512-BvN5ynKILdG5UoONshTQu+9W1LXXtBR//OHvAjOe1XfQ1Y4muFyApjcG71alVIyVwsJLBjbh1jqbTrU22FuZEA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.6", + "tslib": "^2.7.0" + }, + "engines": { + "node": ">=18.2.0" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -5213,6 +5227,18 @@ "node": ">=8" } }, + "node_modules/broker-factory": { + "version": "3.0.102", + "resolved": "https://registry.npmjs.org/broker-factory/-/broker-factory-3.0.102.tgz", + "integrity": "sha512-nVJJRSba3otuE7PjsWvtEqdKaKScTD7P5z4EQICB93XNl9jgJXjkCGb+5f1WE4S+g1amBXVVE7vmPDPowpN2Qw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.6", + "fast-unique-numbers": "^9.0.9", + "tslib": "^2.7.0", + "worker-factory": "^7.0.29" + } + }, "node_modules/brorand": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", @@ -7584,6 +7610,44 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/extendable-media-recorder": { + "version": "9.2.11", + "resolved": "https://registry.npmjs.org/extendable-media-recorder/-/extendable-media-recorder-9.2.11.tgz", + "integrity": "sha512-gSvl+hel7Ze/d+RYZKr1tFJEViwfTm4qWCj9m3rgX/mtcarLcFRIbUhKjKWMDewKSxvAELaK3FnwiGZz2GBNjg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.6", + "media-encoder-host": "^9.0.6", + "multi-buffer-data-view": "^6.0.10", + "recorder-audio-worklet": "^6.0.33", + "standardized-audio-context": "^25.3.77", + "subscribable-things": "^2.1.40", + "tslib": "^2.7.0" + } + }, + "node_modules/extendable-media-recorder-wav-encoder-broker": { + "version": "7.0.106", + "resolved": "https://registry.npmjs.org/extendable-media-recorder-wav-encoder-broker/-/extendable-media-recorder-wav-encoder-broker-7.0.106.tgz", + "integrity": "sha512-kLtXD3rebhBc/IW8IBnr0SzCBFnZWk8dWqEsPt10+PC6BlYGJ1wKvEP5/q3MW6YDBBGMmN+VYb/qOLqeOhxsIQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.6", + "broker-factory": "^3.0.102", + "extendable-media-recorder-wav-encoder-worker": "^8.0.103", + "tslib": "^2.7.0" + } + }, + "node_modules/extendable-media-recorder-wav-encoder-worker": { + "version": "8.0.103", + "resolved": "https://registry.npmjs.org/extendable-media-recorder-wav-encoder-worker/-/extendable-media-recorder-wav-encoder-worker-8.0.103.tgz", + "integrity": "sha512-p2+yUArjGQCIbqaDtDDDeC/IGDeUsRYC2twRjp/0pA3mEuXX6jVVAhm6RFSeKtxkCwjkWWJFnO1Oe0eGKt6rnA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.6", + "tslib": "^2.7.0", + "worker-factory": "^7.0.29" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -7638,6 +7702,19 @@ "license": "MIT", "peer": true }, + "node_modules/fast-unique-numbers": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-9.0.9.tgz", + "integrity": "sha512-XVGu/UFAjpclSMX6LD/GGiyRu+weUFb8kUxP09yiDmWA1ORxiO5gpOl13ZBqjeb+gg05JA2H7d37UvbSHfW+7w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.6", + "tslib": "^2.7.0" + }, + "engines": { + "node": ">=18.2.0" + } + }, "node_modules/fast-uri": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.1.tgz", @@ -9713,6 +9790,43 @@ "license": "MIT", "peer": true }, + "node_modules/media-encoder-host": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/media-encoder-host/-/media-encoder-host-9.0.6.tgz", + "integrity": "sha512-6rBKWh9g6LpLIYzL+qqCelgx03sbrwYvrprVLCCcAsKj6k/XdS2xIHlzLNGZfmDHZu2+5LjFwqeNEffzN4vl7w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.6", + "media-encoder-host-broker": "^8.0.6", + "media-encoder-host-worker": "^10.0.6", + "tslib": "^2.7.0" + } + }, + "node_modules/media-encoder-host-broker": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/media-encoder-host-broker/-/media-encoder-host-broker-8.0.6.tgz", + "integrity": "sha512-R1Jct20aE48yPsVO5WQ/Gqg2nDRtlVzGMxQ6GQpCsDIXJp8WT995LCLbGgTZsYTrjF72tc3OHBbYAvbYI9pvdw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.6", + "broker-factory": "^3.0.102", + "fast-unique-numbers": "^9.0.9", + "media-encoder-host-worker": "^10.0.6", + "tslib": "^2.7.0" + } + }, + "node_modules/media-encoder-host-worker": { + "version": "10.0.6", + "resolved": "https://registry.npmjs.org/media-encoder-host-worker/-/media-encoder-host-worker-10.0.6.tgz", + "integrity": "sha512-qc5bcf2idMD6Nn7w+FVF3AQAewGML/1e3Sc1wdJSFSxqNN/4KGAfWJoevLvfoxl6R8qe0f3G05S/ASUoup1YlQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.6", + "extendable-media-recorder-wav-encoder-broker": "^7.0.106", + "tslib": "^2.7.0", + "worker-factory": "^7.0.29" + } + }, "node_modules/meow": { "version": "13.2.0", "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", @@ -10419,6 +10533,19 @@ "dev": true, "license": "MIT" }, + "node_modules/multi-buffer-data-view": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/multi-buffer-data-view/-/multi-buffer-data-view-6.0.10.tgz", + "integrity": "sha512-+PfVpjGuFGsMH9Kl1NcuFG37XPUIZwq/w77d/j+vNwU23zAEDuV9f7FXvgzGXuSU4aiW/cfbJyj/5JQ9n3d87w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.6", + "tslib": "^2.7.0" + }, + "engines": { + "node": ">=18.2.0" + } + }, "node_modules/nanoid": { "version": "3.3.7", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", @@ -11317,6 +11444,32 @@ "node": ">=8.10.0" } }, + "node_modules/recorder-audio-worklet": { + "version": "6.0.33", + "resolved": "https://registry.npmjs.org/recorder-audio-worklet/-/recorder-audio-worklet-6.0.33.tgz", + "integrity": "sha512-r4/f7VuexVAmh3T1ePCXw/V1DAZ3uk4PESagnRNo0NQIiKaKiyDkUbVp1AEl+8H8EQ7XjU0utJXpsXCj7jc6jw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.6", + "broker-factory": "^3.0.102", + "fast-unique-numbers": "^9.0.9", + "recorder-audio-worklet-processor": "^5.0.24", + "standardized-audio-context": "^25.3.77", + "subscribable-things": "^2.1.40", + "tslib": "^2.7.0", + "worker-factory": "^7.0.29" + } + }, + "node_modules/recorder-audio-worklet-processor": { + "version": "5.0.24", + "resolved": "https://registry.npmjs.org/recorder-audio-worklet-processor/-/recorder-audio-worklet-processor-5.0.24.tgz", + "integrity": "sha512-NZDfroNlE4cwyOZub4j0cxg8Bbp5HgG1sbI2LoCO5zRztXuwZzXuOjKGP/I9XnVbG8f1drwt9hMK9Z2nfTidFg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.6", + "tslib": "^2.7.0" + } + }, "node_modules/regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -11932,6 +12085,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/safe-array-concat": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.2.tgz", @@ -12364,6 +12523,17 @@ "dev": true, "license": "BSD-3-Clause" }, + "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/stream-browserify": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz", @@ -12910,6 +13080,17 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/subscribable-things": { + "version": "2.1.40", + "resolved": "https://registry.npmjs.org/subscribable-things/-/subscribable-things-2.1.40.tgz", + "integrity": "sha512-nWw3aCsTsF4b1HY3vU2iweWGleLpM7hjJsmR1SFabVLcxizWo+zHDVYG/P3nEcdOkYgjCMC4vIaX7vFOrT7RGw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.6", + "rxjs-interop": "^2.0.0", + "tslib": "^2.7.0" + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -13198,6 +13379,12 @@ "json5": "lib/cli.js" } }, + "node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "license": "0BSD" + }, "node_modules/tty-browserify": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.1.tgz", @@ -14687,6 +14874,17 @@ "node": ">=0.10.0" } }, + "node_modules/worker-factory": { + "version": "7.0.29", + "resolved": "https://registry.npmjs.org/worker-factory/-/worker-factory-7.0.29.tgz", + "integrity": "sha512-Nuv/6/Nr70aeBKtiukggJp+o+ewGvgqfjdlJjzlrSi7faPh+5kJ3hFyP3Px5/oEeTUt2ZZ/hgYg1jJFRXi4hAw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.6", + "fast-unique-numbers": "^9.0.9", + "tslib": "^2.7.0" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/package.json b/package.json index 776d4bd7..332c5f6e 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "@nextcloud/moment": "^1.3.1", "@nextcloud/router": "^3.0.0", "@nextcloud/vue": "^8.1.0", + "extendable-media-recorder": "^9.2.11", "moment": "^2.30.1", "v-click-outside": "^3.2.0", "vue": "^2.7.12", diff --git a/src/components/fields/AudioRecorderWrapper.vue b/src/components/fields/AudioRecorderWrapper.vue index 57afb7b3..3b614d32 100644 --- a/src/components/fields/AudioRecorderWrapper.vue +++ b/src/components/fields/AudioRecorderWrapper.vue @@ -3,44 +3,30 @@ + @click="start"> {{ t('assistant', 'Start recording') }} - + @click="abortRecording"> -
- +
+
+ + {{ parsedRecordTime }} + +
+ @click="stop"> @@ -54,11 +40,14 @@ import CloseIcon from 'vue-material-design-icons/Close.vue' import MicrophoneIcon from 'vue-material-design-icons/Microphone.vue' import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' -// import VueAudioRecorder from 'vue2-audio-recorder' -// import Vue from 'vue' -// Vue.use(VueAudioRecorder) +import { showError } from '@nextcloud/dialogs' + +import { MediaRecorder } from 'extendable-media-recorder' +/** + * Slightly simpler than the talk NewMessageAudioRecorder + */ export default { name: 'AudioRecorderWrapper', @@ -86,67 +75,135 @@ export default { data() { return { - // isRecording: false, - resettingRecorder: false, - ignoreNextRecording: false, + // The audio stream object + audioStream: null, + // The media recorder which generate the recorded chunks + mediaRecorder: null, + // The chunks array + chunks: [], + // The final audio file blob + blob: null, + // Switched to true if the recording is aborted + aborted: false, + // recordTimer + recordTimer: null, + // the record timer + recordTime: { + minutes: 0, + seconds: 0, + }, } }, + computed: { + parsedRecordTime() { + const seconds = this.recordTime.seconds.toString().length === 2 ? this.recordTime.seconds : `0${this.recordTime.seconds}` + const minutes = this.recordTime.minutes.toString().length === 2 ? this.recordTime.minutes : `0${this.recordTime.minutes}` + return `${minutes}:${seconds}` + }, + }, + + watch: { + isRecording(newValue) { + console.debug('isRecording', newValue) + }, + }, + mounted() { - // const recordButton = this.$refs.startRecordingButton - // recordButton?.$el?.focus() + }, + + beforeDestroy() { + this.killStreams() }, methods: { - resetRecording() { - this.ignoreNextRecording = false - // trick to remove the recorder and re-render it so the data is gone and its state is fresh - this.resettingRecorder = true - this.$nextTick(() => { - this.resettingRecorder = false - /* - this.$nextTick(() => { - const recordButton = this.$refs.startRecordingButton - recordButton?.$el?.focus() - }) - */ - }) - }, + async start() { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) + this.mediaRecorder = new MediaRecorder(stream) - startRecording() { - this.$refs.recorder.$el.querySelector('.ar-recorder .ar-icon').click() + // Add event handler to onstop + this.mediaRecorder.onstop = this.generateFile + + // Add event handler to ondataavailable + this.mediaRecorder.ondataavailable = (e) => { + this.chunks.push(e.data) + } + + try { + // Start the recording + this.mediaRecorder.start() + } catch (exception) { + console.debug(exception) + this.aborted = true + this.stop() + this.killStreams() + this.resetComponentData() + showError(t('assistant', 'Error while recording audio')) + return + } + + console.debug(this.mediaRecorder.state) + + // Start the timer + this.recordTimer = setInterval(() => { + if (this.recordTime.seconds === 59) { + this.recordTime.minutes++ + this.recordTime.seconds = 0 + } + this.recordTime.seconds++ + }, 1000) + // Forward an event to let the parent NewMessage component + // that there's an undergoing recording operation + this.$emit('update:is-recording', true) }, - stopRecording() { - this.$refs.recorder.$el.querySelector('.ar-recorder .ar-icon').click() + stop() { + this.mediaRecorder.stop() + clearInterval(this.recordTimer) + this.$emit('update:is-recording', false) }, - cancelRecording() { - this.ignoreNextRecording = true - this.stopRecording() + /** + * Generate the file + */ + generateFile() { + this.killStreams() + if (!this.aborted) { + this.blob = new Blob(this.chunks, { type: 'audio/mp3' }) + this.$emit('new-recording', this.blob) + this.$emit('update:is-recording', false) + } + this.resetComponentData() }, - async onRecordStarts(e) { - // this.isRecording = true - this.$emit('update:is-recording', true) - this.$nextTick(() => { - const stopButton = this.$refs.stopRecordingButton - stopButton?.$el?.focus() - }) + /** + * Aborts the recording operation. + */ + abortRecording() { + this.aborted = true + this.stop() }, - async onRecordEnds(e) { - // this.isRecording = false - this.$emit('update:is-recording', false) - if (!this.ignoreNextRecording) { - try { - this.$emit('new-recording', e.blob) - } catch (error) { - console.error('Recording error:', error) - this.$emit('new-recording', null) - } + /** + * Resets this component to its initial state + */ + resetComponentData() { + this.audioStream = null + this.mediaRecorder = null + this.chunks = [] + this.blob = null + this.aborted = false + this.recordTime = { + minutes: 0, + seconds: 0, } - this.resetRecording() + }, + + /** + * Stop the audio streams + */ + killStreams() { + this.audioStream?.getTracks().forEach(track => track.stop()) }, }, } @@ -158,88 +215,33 @@ export default { align-items: center; gap: 10px; - .recording-indicator { - width: 16px; - height: 16px; - flex: 0 0 16px; - border-radius: 8px; - background-color: var(--color-error); - } - - @keyframes fadeOutIn { - 0% { opacity:1; } - 50% { opacity:.3; } - 100% { opacity:1; } - } - .fadeOutIn { - animation: fadeOutIn 3s infinite; - } - - :deep(.recorder) { - max-width: 150px; - height: 34px; - width: unset; - background-color: var(--color-main-background) !important; - box-shadow: unset !important; - - .ar-recorder { - display: none; - } - .ar-content { - padding: 0; - height: 100%; - display: flex; - align-items: center; - justify-content: center; - } - .ar-content * { - color: var(--color-main-text) !important; + .recording { + display: flex; + align-items: center; + gap: 8px; + + &--indicator { + width: 16px; + height: 16px; + flex: 0 0 16px; + border-radius: 8px; + background-color: var(--color-error); } - .ar-icon { - background-color: var(--color-main-background) !important; - fill: var(--color-main-text) !important; - border: 1px solid var(--color-border) !important; - } - .ar-recorder__duration { - margin: 0 0 0 0; - font-size: 20px; - } - .ar-recorder__time-limit { - position: unset !important; - } - .ar-player { - display: none; - &-bar { - border: 1px solid var(--color-border) !important; + + @keyframes fadeOutIn { + 0% { + opacity: 1; } - .ar-line-control { - background-color: var(--color-background-dark) !important; - &__head { - background-color: var(--color-main-text) !important; - } + 50% { + opacity: .3; } - &__time { - font-size: 14px; - } - .ar-volume { - &__icon { - background-color: var(--color-main-background) !important; - fill: var(--color-main-text) !important; - } + 100% { + opacity: 1; } } - .ar-records { - height: unset !important; - &__record { - border-bottom: 1px solid var(--color-border) !important; - } - &__record--selected { - background-color: var(--color-background-dark) !important; - border: 1px solid var(--color-border) !important; - .ar-icon { - background-color: var(--color-background-dark) !important; - } - } + + .fadeOutIn { + animation: fadeOutIn 3s infinite; } } } diff --git a/vite.config.ts b/vite.config.ts index b9799d3d..342ca20d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,6 +6,8 @@ import { createAppConfig } from '@nextcloud/vite-config' import eslint from 'vite-plugin-eslint' import stylelint from 'vite-plugin-stylelint' +const isProduction = process.env.NODE_ENV === 'production' + export default createAppConfig({ main: 'src/main.js', personalSettings: 'src/personalSettings.js', @@ -24,4 +26,5 @@ export default createAppConfig({ plugins: [eslint(), stylelint()], }, inlineCSS: { relativeCSSInjection: true }, + minify: isProduction, })