Browse Source

audio: new @uppy/audio plugin for recording with microphone (#2976)

* add Audio plugin

* add audio-oscilloscope to visualize the recording

* refactor: rename everything to Audio, use oscilloscope, re-init when appropriate, improved preview screen

* tweak styles

* add @uppy/audio to the Uppy bundle

* update Readme and package.json

* add docs, update locales, add website example

* webcam plugin also shouldn’t show recording length counter on video preview screen

* update package.json and yarn.lock

* update types

* update locale

* fix locale issues

* remove leftover webcam test

* Delete index.test-d.ts

* Revert "Delete index.test-d.ts"

This reverts commit f4ec431f6aee8cfb533eac6b2b00fecfc4286230.

* fix lint and type tests

* Update website/src/docs/audio.md

Co-authored-by: Antoine du Hamel <duhamelantoine1995@gmail.com>

* Update packages/@uppy/audio/src/audio-oscilloscope/index.js

Co-authored-by: Antoine du Hamel <duhamelantoine1995@gmail.com>

* Update packages/@uppy/audio/src/DiscardButton.js

Co-authored-by: Antoine du Hamel <duhamelantoine1995@gmail.com>

* Update packages/@uppy/audio/src/audio-oscilloscope/index.js

Co-authored-by: Antoine du Hamel <duhamelantoine1995@gmail.com>

* Update packages/@uppy/audio/src/supportsMediaRecorder.js

Co-authored-by: Antoine du Hamel <duhamelantoine1995@gmail.com>

* Update website/src/docs/audio.md

Co-authored-by: Antoine du Hamel <duhamelantoine1995@gmail.com>

* Update packages/@uppy/audio/types/index.d.ts

Co-authored-by: Antoine du Hamel <duhamelantoine1995@gmail.com>

* Update packages/@uppy/audio/src/index.js

Co-authored-by: Antoine du Hamel <duhamelantoine1995@gmail.com>

* Update packages/@uppy/audio/src/index.js

Co-authored-by: Antoine du Hamel <duhamelantoine1995@gmail.com>

* Update packages/@uppy/audio/src/index.js

Co-authored-by: Antoine du Hamel <duhamelantoine1995@gmail.com>

* remove unused method

* remove unused commented declarations

* make all methods private

* convert class component to hooks

* more private

* fix lint

Co-authored-by: Antoine du Hamel <duhamelantoine1995@gmail.com>
Artur Paikin 3 năm trước cách đây
mục cha
commit
d202a8e940
34 tập tin đã thay đổi với 1272 bổ sung5 xóa
  1. 5 0
      examples/dev/Dashboard.js
  2. 21 0
      packages/@uppy/audio/LICENSE
  3. 37 0
      packages/@uppy/audio/README.md
  4. 37 0
      packages/@uppy/audio/package.json
  5. 22 0
      packages/@uppy/audio/src/AudioSourceSelect.js
  6. 30 0
      packages/@uppy/audio/src/DiscardButton.js
  7. 12 0
      packages/@uppy/audio/src/PermissionsScreen.js
  8. 35 0
      packages/@uppy/audio/src/RecordButton.js
  9. 12 0
      packages/@uppy/audio/src/RecordingLength.js
  10. 116 0
      packages/@uppy/audio/src/RecordingScreen.js
  11. 28 0
      packages/@uppy/audio/src/SubmitButton.js
  12. 21 0
      packages/@uppy/audio/src/audio-oscilloscope/LICENCE
  13. 84 0
      packages/@uppy/audio/src/audio-oscilloscope/index.js
  14. 12 0
      packages/@uppy/audio/src/formatSeconds.js
  15. 11 0
      packages/@uppy/audio/src/formatSeconds.test.js
  16. 369 0
      packages/@uppy/audio/src/index.js
  17. 30 0
      packages/@uppy/audio/src/locale.js
  18. 193 0
      packages/@uppy/audio/src/style.scss
  19. 6 0
      packages/@uppy/audio/src/supportsMediaRecorder.js
  20. 23 0
      packages/@uppy/audio/src/supportsMediaRecorder.test.js
  21. 12 0
      packages/@uppy/audio/types/index.d.ts
  22. 10 0
      packages/@uppy/audio/types/index.test-d.ts
  23. 1 0
      packages/@uppy/core/src/_variables.scss
  24. 7 0
      packages/@uppy/locales/src/en_US.js
  25. 5 5
      packages/@uppy/webcam/src/CameraScreen.js
  26. 1 0
      packages/uppy/index.js
  27. 1 0
      packages/uppy/index.mjs
  28. 1 0
      packages/uppy/package.json
  29. 1 0
      packages/uppy/src/style.scss
  30. 102 0
      website/src/docs/audio.md
  31. 12 0
      website/src/examples/dashboard/app.es6
  32. 2 0
      website/src/examples/dashboard/app.html
  33. 1 0
      website/src/examples/dashboard/index.ejs
  34. 12 0
      yarn.lock

+ 5 - 0
examples/dev/Dashboard.js

@@ -22,6 +22,7 @@ const Transloadit = require('@uppy/transloadit/src')
 const Form = require('@uppy/form/src')
 const ImageEditor = require('@uppy/image-editor/src')
 const DropTarget = require('@uppy/drop-target/src')
+const Audio = require('@uppy/audio/src')
 /* eslint-enable import/no-extraneous-dependencies */
 
 // DEV CONFIG: pick an uploader
@@ -90,6 +91,10 @@ module.exports = () => {
       showVideoSourceDropdown: true,
       showRecordingLength: true,
     })
+    .use(Audio, {
+      target: Dashboard,
+      showRecordingLength: true,
+    })
     .use(ScreenCapture, { target: Dashboard })
     .use(Form, { target: '#upload-form' })
     .use(ImageEditor, { target: Dashboard })

+ 21 - 0
packages/@uppy/audio/LICENSE

@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2021 Transloadit
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 37 - 0
packages/@uppy/audio/README.md

@@ -0,0 +1,37 @@
+# @uppy/audio
+
+<img src="https://uppy.io/images/logos/uppy-dog-head-arrow.svg" width="120" alt="Uppy logo: a superman puppy in a pink suit" align="right">
+
+<a href="https://www.npmjs.com/package/@uppy/audio"><img src="https://img.shields.io/npm/v/@uppy/webcam.svg?style=flat-square"></a> <img src="https://github.com/transloadit/uppy/workflows/Tests/badge.svg" alt="CI status for Uppy tests"> <img src="https://github.com/transloadit/uppy/workflows/Companion/badge.svg" alt="CI status for Companion tests"> <img src="https://github.com/transloadit/uppy/workflows/End-to-end%20tests/badge.svg" alt="CI status for browser tests">
+
+The Audio plugin for Uppy lets you record audio using a built-in or external microphone, or any other audio device, on desktop and mobile.
+
+Uppy is being developed by the folks at [Transloadit](https://transloadit.com), a versatile file encoding service.
+
+## Example
+
+```js
+import Uppy from '@uppy/core'
+import Webcam from '@uppy/audio'
+
+const uppy = new Uppy()
+uppy.use(Audio)
+```
+
+## Installation
+
+```bash
+$ npm install @uppy/audio
+```
+
+We recommend installing from npm and then using a module bundler such as [Webpack](https://webpack.js.org/), [Browserify](http://browserify.org/) or [Rollup.js](http://rollupjs.org/).
+
+Alternatively, you can also use this plugin in a pre-built bundle from Transloadit’s CDN: Edgly. In that case `Uppy` will attach itself to the global `window.Uppy` object. See the [main Uppy documentation](https://uppy.io/docs/#Installation) for instructions.
+
+## Documentation
+
+Documentation for this plugin can be found on the [Uppy website](https://uppy.io/docs/webcam).
+
+## License
+
+[The MIT License](./LICENSE).

+ 37 - 0
packages/@uppy/audio/package.json

@@ -0,0 +1,37 @@
+{
+  "name": "@uppy/audio",
+  "description": "Uppy plugin that records audio using the device’s microphone.",
+  "version": "0.1.0",
+  "license": "MIT",
+  "main": "lib/index.js",
+  "style": "dist/style.min.css",
+  "types": "types/index.d.ts",
+  "keywords": [
+    "file uploader",
+    "uppy",
+    "uppy-plugin",
+    "audio",
+    "microphone",
+    "sound",
+    "record",
+    "mediarecorder"
+  ],
+  "homepage": "https://uppy.io",
+  "bugs": {
+    "url": "https://github.com/transloadit/uppy/issues"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/transloadit/uppy.git"
+  },
+  "dependencies": {
+    "@uppy/utils": "workspace:^",
+    "preact": "^10.5.13"
+  },
+  "peerDependencies": {
+    "@uppy/core": "workspace:^"
+  },
+  "publishConfig": {
+    "access": "public"
+  }
+}

+ 22 - 0
packages/@uppy/audio/src/AudioSourceSelect.js

@@ -0,0 +1,22 @@
+const { h } = require('preact')
+
+module.exports = ({ currentDeviceId, audioSources, onChangeSource }) => {
+  return (
+    <div className="uppy-Audio-videoSource">
+      <select
+        className="uppy-u-reset uppy-Audio-audioSource-select"
+        onChange={(event) => { onChangeSource(event.target.value) }}
+      >
+        {audioSources.map((audioSource) => (
+          <option
+            key={audioSource.deviceId}
+            value={audioSource.deviceId}
+            selected={audioSource.deviceId === currentDeviceId}
+          >
+            {audioSource.label}
+          </option>
+        ))}
+      </select>
+    </div>
+  )
+}

+ 30 - 0
packages/@uppy/audio/src/DiscardButton.js

@@ -0,0 +1,30 @@
+const { h } = require('preact')
+
+function DiscardButton ({ onDiscard, i18n }) {
+  return (
+    <button
+      className="uppy-u-reset uppy-c-btn uppy-Audio-button"
+      type="button"
+      title={i18n('discardRecordedFile')}
+      aria-label={i18n('discardRecordedFile')}
+      onClick={onDiscard}
+      data-uppy-super-focusable
+    >
+      <svg
+        width="13"
+        height="13"
+        viewBox="0 0 13 13"
+        xmlns="http://www.w3.org/2000/svg"
+        aria-hidden="true"
+        className="uppy-c-icon"
+      >
+        <g fill="#FFF" fillRule="evenodd">
+          <path d="M.496 11.367L11.103.76l1.414 1.414L1.911 12.781z" />
+          <path d="M11.104 12.782L.497 2.175 1.911.76l10.607 10.606z" />
+        </g>
+      </svg>
+    </button>
+  )
+}
+
+module.exports = DiscardButton

+ 12 - 0
packages/@uppy/audio/src/PermissionsScreen.js

@@ -0,0 +1,12 @@
+const { h } = require('preact')
+
+module.exports = (props) => {
+  const { icon, hasAudio, i18n } = props
+  return (
+    <div className="uppy-Audio-permissons">
+      <div className="uppy-Audio-permissonsIcon">{icon()}</div>
+      <h1 className="uppy-Audio-title">{hasAudio ? i18n('allowAudioAccessTitle') : i18n('noAudioTitle')}</h1>
+      <p>{hasAudio ? i18n('allowAudioAccessDescription') : i18n('noAudioDescription')}</p>
+    </div>
+  )
+}

+ 35 - 0
packages/@uppy/audio/src/RecordButton.js

@@ -0,0 +1,35 @@
+const { h } = require('preact')
+
+module.exports = function RecordButton ({ recording, onStartRecording, onStopRecording, i18n }) {
+  if (recording) {
+    return (
+      <button
+        className="uppy-u-reset uppy-c-btn uppy-Audio-button"
+        type="button"
+        title={i18n('stopAudioRecording')}
+        aria-label={i18n('stopAudioRecording')}
+        onClick={onStopRecording}
+        data-uppy-super-focusable
+      >
+        <svg aria-hidden="true" focusable="false" className="uppy-c-icon" width="100" height="100" viewBox="0 0 100 100">
+          <rect x="15" y="15" width="70" height="70" />
+        </svg>
+      </button>
+    )
+  }
+
+  return (
+    <button
+      className="uppy-u-reset uppy-c-btn uppy-Audio-button"
+      type="button"
+      title={i18n('startAudioRecording')}
+      aria-label={i18n('startAudioRecording')}
+      onClick={onStartRecording}
+      data-uppy-super-focusable
+    >
+      <svg aria-hidden="true" focusable="false" className="uppy-c-icon" width="14px" height="20px" viewBox="0 0 14 20">
+        <path d="M7 14c2.21 0 4-1.71 4-3.818V3.818C11 1.71 9.21 0 7 0S3 1.71 3 3.818v6.364C3 12.29 4.79 14 7 14zm6.364-7h-.637a.643.643 0 0 0-.636.65V9.6c0 3.039-2.565 5.477-5.6 5.175-2.645-.264-4.582-2.692-4.582-5.407V7.65c0-.36-.285-.65-.636-.65H.636A.643.643 0 0 0 0 7.65v1.631c0 3.642 2.544 6.888 6.045 7.382v1.387H3.818a.643.643 0 0 0-.636.65v.65c0 .36.285.65.636.65h6.364c.351 0 .636-.29.636-.65v-.65c0-.36-.285-.65-.636-.65H7.955v-1.372C11.363 16.2 14 13.212 14 9.6V7.65c0-.36-.285-.65-.636-.65z" fill="#FFF" fill-rule="nonzero" />
+      </svg>
+    </button>
+  )
+}

+ 12 - 0
packages/@uppy/audio/src/RecordingLength.js

@@ -0,0 +1,12 @@
+const { h } = require('preact')
+const formatSeconds = require('./formatSeconds')
+
+module.exports = function RecordingLength ({ recordingLengthSeconds, i18n }) {
+  const formattedRecordingLengthSeconds = formatSeconds(recordingLengthSeconds)
+
+  return (
+    <span aria-label={i18n('recordingLength', { recording_length: formattedRecordingLengthSeconds })}>
+      {formattedRecordingLengthSeconds}
+    </span>
+  )
+}

+ 116 - 0
packages/@uppy/audio/src/RecordingScreen.js

@@ -0,0 +1,116 @@
+/* eslint-disable jsx-a11y/media-has-caption */
+const { h } = require('preact')
+const { useEffect, useRef } = require('preact/hooks')
+const RecordButton = require('./RecordButton')
+const RecordingLength = require('./RecordingLength')
+const AudioSourceSelect = require('./AudioSourceSelect')
+const AudioOscilloscope = require('./audio-oscilloscope')
+const SubmitButton = require('./SubmitButton')
+const DiscardButton = require('./DiscardButton')
+
+module.exports = function RecordingScreen (props) {
+  const {
+    stream,
+    recordedAudio,
+    onStop,
+    recording,
+    supportsRecording,
+    audioSources,
+    showAudioSourceDropdown,
+    onSubmit,
+    i18n,
+    onStartRecording,
+    onStopRecording,
+    onDiscardRecordedAudio,
+    recordingLengthSeconds,
+  } = props
+
+  const canvasEl = useRef(null)
+  const oscilloscope = useRef(null)
+
+  // componentDidMount / componentDidUnmount
+  useEffect(() => {
+    return () => {
+      oscilloscope.current = null
+      onStop()
+    }
+  }, [onStop])
+
+  // componentDidUpdate
+  useEffect(() => {
+    if (!recordedAudio) {
+      oscilloscope.current = new AudioOscilloscope(canvasEl.current, {
+        canvas: {
+          width: 600,
+          height: 600,
+        },
+        canvasContext: {
+          lineWidth: 2,
+          fillStyle: 'rgb(0,0,0)',
+          strokeStyle: 'green',
+        },
+      })
+      oscilloscope.current.draw()
+
+      if (stream) {
+        const audioContext = new AudioContext()
+        const source = audioContext.createMediaStreamSource(stream)
+        oscilloscope.current.addSource(source)
+      }
+    }
+  }, [recordedAudio, stream])
+
+  const hasRecordedAudio = recordedAudio != null
+  const shouldShowRecordButton = !hasRecordedAudio && supportsRecording
+  const shouldShowAudioSourceDropdown = showAudioSourceDropdown
+    && !hasRecordedAudio
+    && audioSources
+    && audioSources.length > 1
+
+  return (
+    <div className="uppy-Audio-container">
+      <div className="uppy-Audio-audioContainer">
+        {hasRecordedAudio
+          ? (
+            <audio
+              className="uppy-Audio-player"
+              controls
+              src={recordedAudio}
+            />
+          ) : (
+            <canvas
+              ref={canvasEl}
+              className="uppy-Audio-canvas"
+            />
+          )}
+      </div>
+      <div className="uppy-Audio-footer">
+        <div className="uppy-Audio-audioSourceContainer">
+          {shouldShowAudioSourceDropdown
+            ? AudioSourceSelect(props)
+            : null}
+        </div>
+        <div className="uppy-Audio-buttonContainer">
+          {shouldShowRecordButton && (
+            <RecordButton
+              recording={recording}
+              onStartRecording={onStartRecording}
+              onStopRecording={onStopRecording}
+              i18n={i18n}
+            />
+          )}
+
+          {hasRecordedAudio && <SubmitButton onSubmit={onSubmit} i18n={i18n} />}
+
+          {hasRecordedAudio && <DiscardButton onDiscard={onDiscardRecordedAudio} i18n={i18n} />}
+        </div>
+
+        <div className="uppy-Audio-recordingLength">
+          {!hasRecordedAudio && (
+            <RecordingLength recordingLengthSeconds={recordingLengthSeconds} i18n={i18n} />
+          )}
+        </div>
+      </div>
+    </div>
+  )
+}

+ 28 - 0
packages/@uppy/audio/src/SubmitButton.js

@@ -0,0 +1,28 @@
+const { h } = require('preact')
+
+function SubmitButton ({ onSubmit, i18n }) {
+  return (
+    <button
+      className="uppy-u-reset uppy-c-btn uppy-Audio-button uppy-Audio-button--submit"
+      type="button"
+      title={i18n('submitRecordedFile')}
+      aria-label={i18n('submitRecordedFile')}
+      onClick={onSubmit}
+      data-uppy-super-focusable
+    >
+      <svg
+        width="12"
+        height="9"
+        viewBox="0 0 12 9"
+        xmlns="http://www.w3.org/2000/svg"
+        aria-hidden="true"
+        focusable="false"
+        className="uppy-c-icon"
+      >
+        <path fill="#fff" fillRule="nonzero" d="M10.66 0L12 1.31 4.136 9 0 4.956l1.34-1.31L4.136 6.38z" />
+      </svg>
+    </button>
+  )
+}
+
+module.exports = SubmitButton

+ 21 - 0
packages/@uppy/audio/src/audio-oscilloscope/LICENCE

@@ -0,0 +1,21 @@
+MIT license
+
+Copyright (C) 2015 Miguel Mota
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+of the Software, and to permit persons to whom the Software is furnished to do
+so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 84 - 0
packages/@uppy/audio/src/audio-oscilloscope/index.js

@@ -0,0 +1,84 @@
+function isFunction (v) {
+  return typeof v === 'function'
+}
+
+function result (v) {
+  return isFunction(v) ? v() : v
+}
+
+/* Audio Oscilloscope
+  https://github.com/miguelmota/audio-oscilloscope
+*/
+module.exports = class AudioOscilloscope {
+  constructor (canvas, options = {}) {
+    const canvasOptions = options.canvas || {}
+    const canvasContextOptions = options.canvasContext || {}
+    this.analyser = null
+    this.bufferLength = 0
+    this.dataArray = []
+    this.canvas = canvas
+    this.width = result(canvasOptions.width) || this.canvas.width
+    this.height = result(canvasOptions.height) || this.canvas.height
+    this.canvas.width = this.width
+    this.canvas.height = this.height
+    this.canvasContext = this.canvas.getContext('2d')
+    this.canvasContext.fillStyle = result(canvasContextOptions.fillStyle) || 'rgb(255, 255, 255)'
+    this.canvasContext.strokeStyle = result(canvasContextOptions.strokeStyle) || 'rgb(0, 0, 0)'
+    this.canvasContext.lineWidth = result(canvasContextOptions.lineWidth) || 1
+    this.onDrawFrame = isFunction(options.onDrawFrame) ? options.onDrawFrame : () => {}
+  }
+
+  addSource (streamSource) {
+    this.streamSource = streamSource
+    this.audioContext = this.streamSource.context
+    this.analyser = this.audioContext.createAnalyser()
+    this.analyser.fftSize = 2048
+    this.bufferLength = this.analyser.frequencyBinCount
+    this.source = this.audioContext.createBufferSource()
+    this.dataArray = new Uint8Array(this.bufferLength)
+    this.analyser.getByteTimeDomainData(this.dataArray)
+    this.streamSource.connect(this.analyser)
+  }
+
+  draw () {
+    const { analyser, dataArray, bufferLength } = this
+    const ctx = this.canvasContext
+    const w = this.width
+    const h = this.height
+
+    if (analyser) {
+      analyser.getByteTimeDomainData(dataArray)
+    }
+
+    ctx.fillRect(0, 0, w, h)
+    ctx.beginPath()
+
+    const sliceWidth = (w * 1.0) / bufferLength
+    let x = 0
+
+    if (!bufferLength) {
+      ctx.moveTo(0, this.height / 2)
+    }
+
+    for (let i = 0; i < bufferLength; i++) {
+      const v = dataArray[i] / 128.0
+      const y = v * (h / 2)
+
+      if (i === 0) {
+        ctx.moveTo(x, y)
+      } else {
+        ctx.lineTo(x, y)
+      }
+
+      x += sliceWidth
+    }
+
+    ctx.lineTo(w, h / 2)
+    ctx.stroke()
+
+    this.onDrawFrame(this)
+    requestAnimationFrame(this.#draw)
+  }
+
+  #draw = () => this.draw()
+}

+ 12 - 0
packages/@uppy/audio/src/formatSeconds.js

@@ -0,0 +1,12 @@
+/**
+ * Takes an Integer value of seconds (e.g. 83) and converts it into a human-readable formatted string (e.g. '1:23').
+ *
+ * @param {Integer} seconds
+ * @returns {string} the formatted seconds (e.g. '1:23' for 1 minute and 23 seconds)
+ *
+ */
+module.exports = function formatSeconds (seconds) {
+  return `${Math.floor(
+    seconds / 60,
+  )}:${String(seconds % 60).padStart(2, 0)}`
+}

+ 11 - 0
packages/@uppy/audio/src/formatSeconds.test.js

@@ -0,0 +1,11 @@
+const formatSeconds = require('./formatSeconds')
+
+describe('formatSeconds', () => {
+  it('should return a value of \'0:43\' when an argument of 43 seconds is supplied', () => {
+    expect(formatSeconds(43)).toEqual('0:43')
+  })
+
+  it('should return a value of \'1:43\' when an argument of 103 seconds is supplied', () => {
+    expect(formatSeconds(103)).toEqual('1:43')
+  })
+})

+ 369 - 0
packages/@uppy/audio/src/index.js

@@ -0,0 +1,369 @@
+const { h } = require('preact')
+const { UIPlugin } = require('@uppy/core')
+const getFileTypeExtension = require('@uppy/utils/lib/getFileTypeExtension')
+const supportsMediaRecorder = require('./supportsMediaRecorder')
+const RecordingScreen = require('./RecordingScreen')
+const PermissionsScreen = require('./PermissionsScreen')
+const locale = require('./locale.js')
+
+/**
+ * Audio recording plugin
+ */
+module.exports = class Audio extends UIPlugin {
+  static VERSION = require('../package.json').version
+
+  #stream = null
+
+  #audioActive = false
+
+  #recordingChunks = null
+
+  #recorder = null
+
+  #capturedMediaFile = null
+
+  #mediaDevices = null
+
+  #supportsUserMedia = null
+
+  constructor (uppy, opts) {
+    super(uppy, opts)
+    this.#mediaDevices = navigator.mediaDevices
+    this.#supportsUserMedia = this.#mediaDevices != null
+    this.id = this.opts.id || 'Audio'
+    this.type = 'acquirer'
+    this.icon = () => (
+      <svg aria-hidden="true" focusable="false" width="32px" height="32px" viewBox="0 0 32 32">
+        <g fill="none" fill-rule="evenodd">
+          <rect fill="#9B59B6" width="32" height="32" rx="16" />
+          <path d="M16 20c-2.21 0-4-1.71-4-3.818V9.818C12 7.71 13.79 6 16 6s4 1.71 4 3.818v6.364C20 18.29 18.21 20 16 20zm-6.364-7h.637c.351 0 .636.29.636.65v1.95c0 3.039 2.565 5.477 5.6 5.175 2.645-.264 4.582-2.692 4.582-5.407V13.65c0-.36.285-.65.636-.65h.637c.351 0 .636.29.636.65v1.631c0 3.642-2.544 6.888-6.045 7.382v1.387h2.227c.351 0 .636.29.636.65v.65c0 .36-.285.65-.636.65h-6.364a.643.643 0 0 1-.636-.65v-.65c0-.36.285-.65.636-.65h2.227v-1.372C11.637 22.2 9 19.212 9 15.6v-1.95c0-.36.285-.65.636-.65z" fill="#FFF" fill-rule="nonzero" />
+        </g>
+      </svg>
+    )
+
+    this.defaultLocale = locale
+
+    this.opts = { ...opts }
+
+    this.i18nInit()
+    this.title = this.i18n('pluginNameAudio')
+
+    this.setPluginState({
+      hasAudio: false,
+      audioReady: false,
+      cameraError: null,
+      recordingLengthSeconds: 0,
+      audioSources: [],
+      currentDeviceId: null,
+    })
+  }
+
+  #hasAudioCheck () {
+    if (!this.#mediaDevices) {
+      return Promise.resolve(false)
+    }
+
+    return this.#mediaDevices.enumerateDevices().then(devices => {
+      return devices.some(device => device.kind === 'audioinput')
+    })
+  }
+
+  // eslint-disable-next-line consistent-return
+  #start = (options = null) => {
+    if (!this.#supportsUserMedia) {
+      return Promise.reject(new Error('Microphone access not supported'))
+    }
+
+    this.#audioActive = true
+
+    this.#hasAudioCheck().then(hasAudio => {
+      this.setPluginState({
+        hasAudio,
+      })
+
+      // ask user for access to their camera
+      return this.#mediaDevices.getUserMedia({ audio: true })
+        .then((stream) => {
+          this.#stream = stream
+
+          let currentDeviceId = null
+          const tracks = stream.getAudioTracks()
+
+          if (!options || !options.deviceId) {
+            currentDeviceId = tracks[0].getSettings().deviceId
+          } else {
+            tracks.forEach((track) => {
+              if (track.getSettings().deviceId === options.deviceId) {
+                currentDeviceId = track.getSettings().deviceId
+              }
+            })
+          }
+
+          // Update the sources now, so we can access the names.
+          this.#updateSources()
+
+          this.setPluginState({
+            currentDeviceId,
+            audioReady: true,
+          })
+        })
+        .catch((err) => {
+          this.setPluginState({
+            audioReady: false,
+            cameraError: err,
+          })
+          this.uppy.info(err.message, 'error')
+        })
+    })
+  }
+
+  #startRecording = () => {
+    // only used if supportsMediaRecorder() returned true
+    // eslint-disable-next-line compat/compat
+    this.#recorder = new MediaRecorder(this.#stream)
+    this.#recordingChunks = []
+    let stoppingBecauseOfMaxSize = false
+    this.#recorder.addEventListener('dataavailable', (event) => {
+      this.#recordingChunks.push(event.data)
+
+      const { restrictions } = this.uppy.opts
+      if (this.#recordingChunks.length > 1
+          && restrictions.maxFileSize != null
+          && !stoppingBecauseOfMaxSize) {
+        const totalSize = this.#recordingChunks.reduce((acc, chunk) => acc + chunk.size, 0)
+        // Exclude the initial chunk from the average size calculation because it is likely to be a very small outlier
+        const averageChunkSize = (totalSize - this.#recordingChunks[0].size) / (this.#recordingChunks.length - 1)
+        const expectedEndChunkSize = averageChunkSize * 3
+        const maxSize = Math.max(0, restrictions.maxFileSize - expectedEndChunkSize)
+
+        if (totalSize > maxSize) {
+          stoppingBecauseOfMaxSize = true
+          this.uppy.info(this.i18n('recordingStoppedMaxSize'), 'warning', 4000)
+          this.#stopRecording()
+        }
+      }
+    })
+
+    // use a "time slice" of 500ms: ondataavailable will be called each 500ms
+    // smaller time slices mean we can more accurately check the max file size restriction
+    this.#recorder.start(500)
+
+    // Start the recordingLengthTimer if we are showing the recording length.
+    this.recordingLengthTimer = setInterval(() => {
+      const currentRecordingLength = this.getPluginState().recordingLengthSeconds
+      this.setPluginState({ recordingLengthSeconds: currentRecordingLength + 1 })
+    }, 1000)
+
+    this.setPluginState({
+      isRecording: true,
+    })
+  }
+
+  #stopRecording = () => {
+    const stopped = new Promise((resolve) => {
+      this.#recorder.addEventListener('stop', () => {
+        resolve()
+      })
+      this.#recorder.stop()
+
+      if (this.opts.showRecordingLength) {
+        // Stop the recordingLengthTimer if we are showing the recording length.
+        clearInterval(this.recordingLengthTimer)
+        this.setPluginState({ recordingLengthSeconds: 0 })
+      }
+    })
+
+    return stopped.then(() => {
+      this.setPluginState({
+        isRecording: false,
+      })
+      return this.#getAudio()
+    }).then((file) => {
+      try {
+        this.#capturedMediaFile = file
+        // create object url for capture result preview
+        this.setPluginState({
+          recordedAudio: URL.createObjectURL(file.data),
+        })
+      } catch (err) {
+        // Logging the error, exept restrictions, which is handled in Core
+        if (!err.isRestriction) {
+          this.uppy.log(err)
+        }
+      }
+    }).then(() => {
+      this.#recordingChunks = null
+      this.#recorder = null
+    }, (error) => {
+      this.#recordingChunks = null
+      this.#recorder = null
+      throw error
+    })
+  }
+
+  #discardRecordedAudio = () => {
+    this.setPluginState({ recordedAudio: null })
+    this.#capturedMediaFile = null
+  }
+
+  #submit = () => {
+    try {
+      if (this.#capturedMediaFile) {
+        this.uppy.addFile(this.#capturedMediaFile)
+      }
+    } catch (err) {
+      // Logging the error, exept restrictions, which is handled in Core
+      if (!err.isRestriction) {
+        this.uppy.log(err, 'error')
+      }
+    }
+  }
+
+  #stop = async () => {
+    if (this.#stream) {
+      const audioTracks = this.#stream.getAudioTracks()
+      audioTracks.forEach((track) => track.stop())
+    }
+
+    if (this.#recorder) {
+      await new Promise((resolve) => {
+        this.#recorder.addEventListener('stop', resolve, { once: true })
+        this.#recorder.stop()
+
+        if (this.opts.showRecordingLength) {
+          clearInterval(this.recordingLengthTimer)
+        }
+      })
+    }
+
+    this.#recordingChunks = null
+    this.#recorder = null
+    this.#audioActive = false
+    this.#stream = null
+
+    this.setPluginState({
+      recordedAudio: null,
+      isRecording: false,
+      recordingLengthSeconds: 0,
+    })
+  }
+
+  #getAudio () {
+    // Sometimes in iOS Safari, Blobs (especially the first Blob in the recordingChunks Array)
+    // have empty 'type' attributes (e.g. '') so we need to find a Blob that has a defined 'type'
+    // attribute in order to determine the correct MIME type.
+    const mimeType = this.#recordingChunks.find(blob => blob.type?.length > 0).type
+
+    const fileExtension = getFileTypeExtension(mimeType)
+
+    if (!fileExtension) {
+      return Promise.reject(new Error(`Could not retrieve recording: Unsupported media type "${mimeType}"`))
+    }
+
+    const name = `audio-${Date.now()}.${fileExtension}`
+    const blob = new Blob(this.#recordingChunks, { type: mimeType })
+    const file = {
+      source: this.id,
+      name,
+      data: new Blob([blob], { type: mimeType }),
+      type: mimeType,
+    }
+
+    return Promise.resolve(file)
+  }
+
+  #changeSource = (deviceId) => {
+    this.#stop()
+    this.#start({ deviceId })
+  }
+
+  #updateSources = () => {
+    this.#mediaDevices.enumerateDevices().then(devices => {
+      this.setPluginState({
+        audioSources: devices.filter((device) => device.kind === 'audioinput'),
+      })
+    })
+  }
+
+  render () {
+    if (!this.#audioActive) {
+      this.#start()
+    }
+
+    const audioState = this.getPluginState()
+
+    if (!audioState.audioReady || !audioState.hasAudio) {
+      return (
+        <PermissionsScreen
+          icon={this.icon}
+          i18n={this.i18n}
+          hasAudio={audioState.hasAudio}
+        />
+      )
+    }
+
+    return (
+      <RecordingScreen
+        // eslint-disable-next-line react/jsx-props-no-spreading
+        {...audioState}
+        audioActive={this.#audioActive}
+        onChangeSource={this.#changeSource}
+        onStartRecording={this.#startRecording}
+        onStopRecording={this.#stopRecording}
+        onDiscardRecordedAudio={this.#discardRecordedAudio}
+        onSubmit={this.#submit}
+        onStop={this.#stop}
+        i18n={this.i18n}
+        showAudioSourceDropdown={this.opts.showAudioSourceDropdown}
+        supportsRecording={supportsMediaRecorder()}
+        recording={audioState.isRecording}
+        stream={this.#stream}
+      />
+    )
+  }
+
+  install () {
+    this.setPluginState({
+      audioReady: false,
+      recordingLengthSeconds: 0,
+    })
+
+    const { target } = this.opts
+    if (target) {
+      this.mount(target, this)
+    }
+
+    if (this.#mediaDevices) {
+      this.#updateSources()
+
+      this.#mediaDevices.ondevicechange = () => {
+        this.#updateSources()
+
+        if (this.#stream) {
+          let restartStream = true
+
+          const { audioSources, currentDeviceId } = this.getPluginState()
+
+          audioSources.forEach((audioSource) => {
+            if (currentDeviceId === audioSource.deviceId) {
+              restartStream = false
+            }
+          })
+
+          if (restartStream) {
+            this.#stop()
+            this.#start()
+          }
+        }
+      }
+    }
+  }
+
+  uninstall () {
+    if (this.#stream) {
+      this.#stop()
+    }
+
+    this.unmount()
+  }
+}

+ 30 - 0
packages/@uppy/audio/src/locale.js

@@ -0,0 +1,30 @@
+module.exports = {
+  strings: {
+    pluginNameAudio: 'Audio',
+    // Used as the label for the button that starts an audio recording.
+    // This is not visibly rendered but is picked up by screen readers.
+    startAudioRecording: 'Begin audio recording',
+    // Used as the label for the button that stops an audio recording.
+    // This is not visibly rendered but is picked up by screen readers.
+    stopAudioRecording: 'Stop audio recording',
+    // Title on the “allow access” screen
+    allowAudioAccessTitle: 'Please allow access to your microphone',
+    // Description on the “allow access” screen
+    allowAudioAccessDescription: 'In order to record audio, please allow microphone access for this site.',
+    // Title on the “device not available” screen
+    noAudioTitle: 'Microphone Not Available',
+    // Description on the “device not available” screen
+    noAudioDescription: 'In order to record audio, please connect a microphone or another audio input device',
+    // Message about file size will be shown in an Informer bubble
+    recordingStoppedMaxSize: 'Recording stopped because the file size is about to exceed the limit',
+    // Used as the label for the counter that shows recording length (`1:25`).
+    // This is not visibly rendered but is picked up by screen readers.
+    recordingLength: 'Recording length %{recording_length}',
+    // Used as the label for the submit checkmark button.
+    // This is not visibly rendered but is picked up by screen readers.
+    submitRecordedFile: 'Submit recorded file',
+    // Used as the label for the discard cross button.
+    // This is not visibly rendered but is picked up by screen readers.
+    discardRecordedFile: 'Discard recorded file',
+  },
+}

+ 193 - 0
packages/@uppy/audio/src/style.scss

@@ -0,0 +1,193 @@
+@import '@uppy/core/src/_utils.scss';
+@import '@uppy/core/src/_variables.scss';
+
+.uppy-Audio-container {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  flex-direction: column;
+}
+
+.uppy-Audio-audioContainer {
+  display: flex;
+  width: 100%;
+  height: 100%;
+  background-color: $gray-300;
+  position: relative;
+  justify-content: center;
+  align-items: center;
+}
+
+.uppy-Audio-player {
+  width: 85%;
+  border-radius: 12px;
+}
+
+.uppy-Audio-canvas {
+  width: 100%;
+  height: 100%;
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+}
+
+.uppy-Audio-footer {
+  width: 100%;
+  // min-height: 75px;
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  justify-content: space-between;
+  padding: 20px 20px;
+}
+
+.uppy-Audio-audioSourceContainer {
+  width: 100%;
+  flex-grow: 0;
+}
+
+.uppy-size--lg .uppy-Audio-audioSourceContainer {
+  width: 33%;
+  margin: 0; // vertical alignment handled by the flexbox wrapper
+}
+
+.uppy-Audio-audioSource-select {
+  display: block;
+  font-size: 16px;
+  line-height: 1.2;
+  padding: .4em 1em .3em .4em;
+  width: 100%;
+  max-width: 90%;
+  border: 1px solid $gray-600;
+  background-image: url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%23757575%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E');
+  background-repeat: no-repeat;
+  background-position: right .4em top 50%, 0 0;
+  background-size: .65em auto, 100%;
+  margin: auto;
+  margin-bottom: 10px;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+
+  .uppy-size--lg & {
+    font-size: 14px;
+    margin-bottom: 0;
+  }
+}
+
+  .uppy-Audio-audioSource-select::-ms-expand {
+    display: none;
+  }
+
+.uppy-Audio-buttonContainer {
+  width: 50%;
+  margin-left: 25%;
+  text-align: center;
+  flex: 1;
+}
+
+.uppy-size--lg .uppy-Audio-buttonContainer {
+  width: 34%;
+  margin-left: 0;
+}
+
+.uppy-Audio-recordingLength {
+  width: 25%;
+  flex-grow: 0;
+  color: $gray-600;
+  font-family: $font-family-mono;
+  text-align: right;
+}
+
+.uppy-size--lg .uppy-Audio-recordingLength {
+  width: 33%;
+}
+
+.uppy-Audio-button {
+  @include blue-border-focus;
+  width: 45px;
+  height: 45px;
+  border-radius: 50%;
+  background-color: $red;
+  color: $white;
+  cursor: pointer;
+  transition: all 0.3s;
+
+  &:hover {
+    background-color: darken($red, 5%);
+  }
+
+  [data-uppy-theme="dark"] & {
+    @include blue-border-focus--dark;
+  }
+}
+
+.uppy-Audio-button--submit {
+  background-color: $blue;
+  margin: 0 12px;
+
+  &:hover {
+    background-color: darken($blue, 5%);
+  }
+}
+
+.uppy-Audio-button svg {
+  width: 26px;
+  height: 26px;
+  max-width: 100%;
+  max-height: 100%;
+  display: inline-block;
+  vertical-align: text-top;
+  overflow: hidden;
+  fill: currentColor;
+}
+
+.uppy-size--md .uppy-Audio-button {
+  width: 60px;
+  height: 60px;
+}
+
+.uppy-Audio-permissons {
+  padding: 15px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-flow: column wrap;
+  height: 100%;
+  flex: 1;
+}
+
+.uppy-Audio-permissons p {
+  max-width: 450px;
+  line-height: 1.3;
+  text-align: center;
+  line-height: 1.45;
+  color: $gray-500;
+  margin: 0;
+}
+
+.uppy-Audio-permissonsIcon svg {
+  width: 100px;
+  height: 75px;
+  color: $gray-400;
+  margin-bottom: 30px;
+}
+
+.uppy-Audio-title {
+  font-size: 22px;
+  line-height: 1.35;
+  font-weight: 400;
+  margin: 0;
+  margin-bottom: 5px;
+  padding: 0 15px;
+  max-width: 500px;
+  text-align: center;
+  color: $gray-800;
+
+  [data-uppy-theme="dark"] & {
+    color: $gray-200;
+  }
+}

+ 6 - 0
packages/@uppy/audio/src/supportsMediaRecorder.js

@@ -0,0 +1,6 @@
+module.exports = function supportsMediaRecorder () {
+  /* eslint-disable compat/compat */
+  return typeof MediaRecorder === 'function'
+    && typeof MediaRecorder.prototype?.start === 'function'
+  /* eslint-enable compat/compat */
+}

+ 23 - 0
packages/@uppy/audio/src/supportsMediaRecorder.test.js

@@ -0,0 +1,23 @@
+const supportsMediaRecorder = require('./supportsMediaRecorder')
+
+describe('supportsMediaRecorder', () => {
+  it('should return true if MediaRecorder is supported', () => {
+    global.MediaRecorder = class MediaRecorder {
+      start () {} // eslint-disable-line
+    }
+    expect(supportsMediaRecorder()).toEqual(true)
+  })
+
+  it('should return false if MediaRecorder is not supported', () => {
+    global.MediaRecorder = undefined
+    expect(supportsMediaRecorder()).toEqual(false)
+
+    global.MediaRecorder = class MediaRecorder {}
+    expect(supportsMediaRecorder()).toEqual(false)
+
+    global.MediaRecorder = class MediaRecorder {
+      foo () {} // eslint-disable-line
+    }
+    expect(supportsMediaRecorder()).toEqual(false)
+  })
+})

+ 12 - 0
packages/@uppy/audio/types/index.d.ts

@@ -0,0 +1,12 @@
+import type { PluginOptions, UIPlugin, PluginTarget } from '@uppy/core'
+import type AudioLocale from './generatedLocale'
+
+export interface AudioOptions extends PluginOptions {
+  target?: PluginTarget
+  showVideoSourceDropdown?: boolean
+  locale?: AudioLocale
+}
+
+declare class Audio extends UIPlugin<AudioOptions> {}
+
+export default Audio

+ 10 - 0
packages/@uppy/audio/types/index.test-d.ts

@@ -0,0 +1,10 @@
+import Uppy from '@uppy/core'
+import Audio from '..'
+
+{
+  const uppy = new Uppy()
+
+  uppy.use(Audio, {
+    target: 'body',
+  })
+}

+ 1 - 0
packages/@uppy/core/src/_variables.scss

@@ -1,5 +1,6 @@
 // Fonts
 $font-family-base: -apple-system, blinkmacsystemfont, 'Segoe UI', helvetica, arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol' !default;
+$font-family-mono: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !default;
 
 // Colors
 

+ 7 - 0
packages/@uppy/locales/src/en_US.js

@@ -11,6 +11,8 @@ en_US.strings = {
   allFilesFromFolderNamed: 'All files from folder %{name}',
   allowAccessDescription: 'In order to take pictures or record video with your camera, please allow camera access for this site.',
   allowAccessTitle: 'Please allow access to your camera',
+  allowAudioAccessDescription: 'In order to record audio, please allow microphone access for this site.',
+  allowAudioAccessTitle: 'Please allow access to your microphone',
   aspectRatioLandscape: 'Crop landscape (16:9)',
   aspectRatioPortrait: 'Crop portrait (9:16)',
   aspectRatioSquare: 'Crop square',
@@ -86,6 +88,8 @@ en_US.strings = {
     '1': 'Missing required meta fields: %{fields}.',
   },
   myDevice: 'My Device',
+  noAudioDescription: 'In order to record audio, please connect a microphone or another audio input device',
+  noAudioTitle: 'Microphone Not Available',
   noCameraDescription: 'In order to take pictures or record video, please connect a camera device',
   noCameraTitle: 'Camera Not Available',
   noDuplicates: 'Cannot add the duplicate file \'%{fileName}\', it already exists',
@@ -96,6 +100,7 @@ en_US.strings = {
   pause: 'Pause',
   paused: 'Paused',
   pauseUpload: 'Pause upload',
+  pluginNameAudio: 'Audio',
   pluginNameBox: 'Box',
   pluginNameCamera: 'Camera',
   pluginNameDropbox: 'Dropbox',
@@ -136,8 +141,10 @@ en_US.strings = {
   sessionRestored: 'Session restored',
   signInWithGoogle: 'Sign in with Google',
   smile: 'Smile!',
+  startAudioRecording: 'Begin audio recording',
   startCapturing: 'Begin screen capturing',
   startRecording: 'Begin video recording',
+  stopAudioRecording: 'Stop audio recording',
   stopCapturing: 'Stop screen capturing',
   stopRecording: 'Stop video recording',
   streamActive: 'Stream active',

+ 5 - 5
packages/@uppy/webcam/src/CameraScreen.js

@@ -49,7 +49,7 @@ class CameraScreen extends Component {
       || isModeAvailable(modes, 'video-audio')
     )
     const shouldShowSnapshotButton = !hasRecordedVideo && isModeAvailable(modes, 'picture')
-    const shouldShowRecordingLength = supportsRecording && showRecordingLength
+    const shouldShowRecordingLength = supportsRecording && showRecordingLength && !hasRecordedVideo
     const shouldShowVideoSourceDropdown = showVideoSourceDropdown && videoSources && videoSources.length > 1
 
     const videoProps = {
@@ -105,11 +105,11 @@ class CameraScreen extends Component {
             {hasRecordedVideo && <DiscardButton onDiscard={onDiscardRecordedVideo} i18n={i18n} />}
           </div>
 
-          {shouldShowRecordingLength && (
-            <div className="uppy-Webcam-recordingLength">
+          <div className="uppy-Webcam-recordingLength">
+            {shouldShowRecordingLength && (
               <RecordingLength recordingLengthSeconds={recordingLengthSeconds} i18n={i18n} />
-            </div>
-          )}
+            )}
+          </div>
         </div>
       </div>
     )

+ 1 - 0
packages/uppy/index.js

@@ -35,6 +35,7 @@ exports.Unsplash = require('@uppy/unsplash')
 exports.Url = require('@uppy/url')
 exports.Webcam = require('@uppy/webcam')
 exports.ScreenCapture = require('@uppy/screen-capture')
+exports.Audio = require('@uppy/audio')
 
 // Uploaders
 exports.AwsS3 = require('@uppy/aws-s3')

+ 1 - 0
packages/uppy/index.mjs

@@ -31,6 +31,7 @@ export { default as Unsplash } from '@uppy/unsplash'
 export { default as Url } from '@uppy/url'
 export { default as Webcam } from '@uppy/webcam'
 export { default as ScreenCapture } from '@uppy/screen-capture'
+export { default as Audio } from '@uppy/audio'
 
 // Uploaders
 export { default as AwsS3 } from '@uppy/aws-s3'

+ 1 - 0
packages/uppy/package.json

@@ -31,6 +31,7 @@
     "url": "git+https://github.com/transloadit/uppy.git"
   },
   "dependencies": {
+    "@uppy/audio": "workspace:^",
     "@uppy/aws-s3": "workspace:^",
     "@uppy/aws-s3-multipart": "workspace:^",
     "@uppy/box": "workspace:^",

+ 1 - 0
packages/uppy/src/style.scss

@@ -8,6 +8,7 @@
 @import '@uppy/status-bar/src/style.scss';
 @import '@uppy/url/src/style.scss';
 @import '@uppy/webcam/src/style.scss';
+@import '@uppy/audio/src/style.scss';
 @import '@uppy/screen-capture/src/style.scss';
 @import '@uppy/image-editor/src/style.scss';
 @import '@uppy/drop-target/src/style.scss';

+ 102 - 0
website/src/docs/audio.md

@@ -0,0 +1,102 @@
+---
+type: docs
+order: 3
+title: "Audio"
+module: "@uppy/audio"
+permalink: docs/audio/
+category: "Sources"
+tagline: "upload audio recordings"
+---
+
+The `@uppy/audio` plugin lets you record audio using a built-in or external microphone, or any other audio device, on desktop and mobile.
+
+```js
+import Audio from '@uppy/audio'
+
+uppy.use(Audio, {
+  // Options
+})
+```
+
+<a class="TryButton" href="/examples/dashboard/">Try it live</a>
+
+## Installation
+
+This plugin is published as the `@uppy/audio` package.
+
+Install from NPM:
+
+```shell
+npm install @uppy/audio
+```
+
+In the [CDN package](/docs/#With-a-script-tag), the plugin class is available on the `Uppy` global object:
+
+```js
+const { Audio } = Uppy
+```
+
+## CSS
+
+The `@uppy/audio` plugin requires the following CSS for styling:
+
+```js
+import '@uppy/core/dist/style.css'
+import '@uppy/audio/dist/style.css'
+```
+
+Import general Core styles from `@uppy/core/dist/style.css` first, then add the Webcam styles from `@uppy/audio/dist/style.css`. A minified version is also available as `style.min.css` at the same path. The way to do import depends on your build system.
+
+## Options
+
+The `@uppy/audio` plugin has the following configurable options:
+
+### `id: 'Audio'`
+
+A unique identifier for this plugin. It defaults to `'Audio'`.
+
+### `target: null`
+
+DOM element, CSS selector, or plugin to mount Audio into.
+
+### `showAudioSourceDropdown: false`
+
+Configures whether to show a dropdown which enables to choose the audio device to use. The default is `false`.
+
+### `locale: {}`
+
+<!-- eslint-disable no-restricted-globals, no-multiple-empty-lines -->
+
+```js
+module.exports = {
+  strings: {
+    pluginNameAudio: 'Audio',
+    // Used as the label for the button that starts an audio recording.
+    // This is not visibly rendered but is picked up by screen readers.
+    startAudioRecording: 'Begin audio recording',
+    // Used as the label for the button that stops an audio recording.
+    // This is not visibly rendered but is picked up by screen readers.
+    stopAudioRecording: 'Stop audio recording',
+    // Title on the “allow access” screen
+    allowAudioAccessTitle: 'Please allow access to your microphone',
+    // Description on the “allow access” screen
+    allowAudioAccessDescription: 'In order to record audio, please allow microphone access for this site.',
+    // Title on the “device not available” screen
+    noAudioTitle: 'Microphone Not Available',
+    // Description on the “device not available” screen
+    noAudioDescription: 'In order to record audio, please connect a microphone or another audio input device',
+    // Message about file size will be shown in an Informer bubble
+    recordingStoppedMaxSize: 'Recording stopped because the file size is about to exceed the limit',
+    // Used as the label for the counter that shows recording length (`1:25`).
+    // This is not visibly rendered but is picked up by screen readers.
+    recordingLength: 'Recording length %{recording_length}',
+    // Used as the label for the submit checkmark button.
+    // This is not visibly rendered but is picked up by screen readers.
+    submitRecordedFile: 'Submit recorded file',
+    // Used as the label for the discard cross button.
+    // This is not visibly rendered but is picked up by screen readers.
+    discardRecordedFile: 'Discard recorded file',
+  },
+}
+
+```

+ 12 - 0
website/src/examples/dashboard/app.es6

@@ -10,6 +10,7 @@ const Zoom = require('@uppy/zoom')
 const ImageEditor = require('@uppy/image-editor')
 const Url = require('@uppy/url')
 const Webcam = require('@uppy/webcam')
+const Audio = require('@uppy/audio')
 const ScreenCapture = require('@uppy/screen-capture')
 const Tus = require('@uppy/tus')
 const DropTarget = require('@uppy/drop-target')
@@ -166,6 +167,17 @@ function uppySetOptions () {
     window.uppy.removePlugin(webcamInstance)
   }
 
+  const audioInstance = window.uppy.getPlugin('Audio')
+  if (opts.Audio && !audioInstance) {
+    window.uppy.use(Audio, {
+      target: Dashboard,
+      showAudioSourceDropdown: true,
+    })
+  }
+  if (!opts.Audio && audioInstance) {
+    window.uppy.removePlugin(audioInstance)
+  }
+
   const screenCaptureInstance = window.uppy.getPlugin('ScreenCapture')
   if (opts.ScreenCapture && !screenCaptureInstance) {
     window.uppy.use(ScreenCapture, { target: Dashboard })

+ 2 - 0
website/src/examples/dashboard/app.html

@@ -13,6 +13,7 @@
   </ul>
   <ul>
     <li><label for="opts-Webcam"><input type="checkbox" id="opts-Webcam" checked/> Webcam</label></li>
+    <li><label for="opts-Audio"><input type="checkbox" id="opts-Audio" checked/> Audio</label></li>
     <li><label for="opts-ScreenCapture"><input type="checkbox" id="opts-ScreenCapture" checked/> Screen Capture</label></li>
     <li><label for="opts-GoogleDrive"><input type="checkbox" id="opts-GoogleDrive" checked/> Google Drive</label></li>
     <li><label for="opts-Dropbox"><input type="checkbox" id="opts-Dropbox" checked/> Dropbox</label></li>
@@ -44,6 +45,7 @@
   var optionInputs = {
     DashboardInline: document.querySelector('#opts-DashboardInline'),
     Webcam: document.querySelector('#opts-Webcam'),
+    Audio: document.querySelector('#opts-Audio'),
     ScreenCapture: document.querySelector('#opts-ScreenCapture'),
     GoogleDrive: document.querySelector('#opts-GoogleDrive'),
     Dropbox: document.querySelector('#opts-Dropbox'),

+ 1 - 0
website/src/examples/dashboard/index.ejs

@@ -69,6 +69,7 @@ const uppy = new Uppy({
 .use(Facebook, { target: Dashboard, companionUrl: 'https://companion.uppy.io' })
 .use(OneDrive, { target: Dashboard, companionUrl: 'https://companion.uppy.io' })
 .use(Webcam, { target: Dashboard })
+.use(Audio, { target: Dashboard })
 .use(ScreenCapture, { target: Dashboard })
 .use(ImageEditor, { target: Dashboard })
 .use(Tus, { endpoint: 'https://tusd.tusdemo.net/files/' })

+ 12 - 0
yarn.lock

@@ -8572,6 +8572,17 @@ __metadata:
   languageName: unknown
   linkType: soft
 
+"@uppy/audio@workspace:^, @uppy/audio@workspace:packages/@uppy/audio":
+  version: 0.0.0-use.local
+  resolution: "@uppy/audio@workspace:packages/@uppy/audio"
+  dependencies:
+    "@uppy/utils": "workspace:^"
+    preact: ^10.5.13
+  peerDependencies:
+    "@uppy/core": "workspace:^"
+  languageName: unknown
+  linkType: soft
+
 "@uppy/aws-s3-multipart@workspace:^, @uppy/aws-s3-multipart@workspace:packages/@uppy/aws-s3-multipart":
   version: 0.0.0-use.local
   resolution: "@uppy/aws-s3-multipart@workspace:packages/@uppy/aws-s3-multipart"
@@ -42405,6 +42416,7 @@ hexo-filter-github-emojis@arturi/hexo-filter-github-emojis:
   version: 0.0.0-use.local
   resolution: "uppy@workspace:packages/uppy"
   dependencies:
+    "@uppy/audio": "workspace:^"
     "@uppy/aws-s3": "workspace:^"
     "@uppy/aws-s3-multipart": "workspace:^"
     "@uppy/box": "workspace:^"