35 Commits

Author SHA1 Message Date
e6d6260b1b Build version 4.0.1 2020-12-25 07:13:50 +00:00
adf3fba1b3 Changing icon
- Add icon for upload (HeroIcon)
- Actually call the button with the new icon
2020-12-25 06:47:07 +00:00
eb674f00f0 Added icons style 2020-09-09 20:28:08 +00:00
b1ff2cd677 Build 2020-09-09 20:19:04 +00:00
f680d5f47d Change the button to an icon 2020-09-09 20:17:57 +00:00
4cde0ea657 Build 2020-09-09 19:35:32 +00:00
d836371eaa Trying to add a button with a custom function 2020-09-09 19:34:04 +00:00
Kevin ANATOLE
84fb2fd6f6 Built with format 2020-09-07 22:25:30 +01:00
Kevin ANATOLE
2b12f06640 Added the format prop 2020-09-07 22:20:24 +01:00
Gennady Grishkovtsov
cf7df625ca Merge pull request #33 from yousef-soliman/master
Fix return last record as record not array
2020-04-03 18:06:54 +03:00
Yousef Soliman
a51fa4fdf2 fix return last record as record not array 2020-04-02 12:11:20 +02:00
Gennady Grishkovtsov
94fc582eef Add WAV support 2019-05-03 01:17:56 +03:00
Gennady Grishkovtsov
1f8aad013a Remove context closing from "pause" action & minor improvoments 2019-05-02 23:31:54 +03:00
Gennady Grishkovtsov
22d1b524a4 Merge branch 'master' of github.com:grishkovelli/vue-audio-recorder 2019-05-02 23:09:53 +03:00
Gennady Grishkovtsov
a9a98d2e33 Merge pull request #17 from antoniohof/master
Fix final recording broken by pause button
2019-02-23 10:50:10 +03:00
Gennady Grishkovtsov
e482c3bea7 Merge pull request #17 from antoniohof/master
Fix final recording broken by pause button
2019-02-23 10:49:16 +03:00
Antonio Hofmeister
0c4c0cd091 fix final recording broken by pause button 2019-02-22 17:29:13 -03:00
Gennady Grishkovtsov
77f3460825 Update README.md 2019-02-09 20:14:26 +03:00
Gennady Grishkovtsov
7fdf3f745b Add bitRate & sampleRate 2018-12-31 12:48:07 +03:00
Gennady Grishkovtsov
87f9703529 Add hover effect 2018-12-31 12:47:35 +03:00
Gennady Grishkovtsov
f4bf0c503a Update version to 3.0.1 2018-12-19 01:55:08 +03:00
Gennady Grishkovtsov
4cfac6de85 Update readme 2018-12-19 01:54:47 +03:00
Gennady Grishkovtsov
685a4b76e6 Add devtool 2018-12-19 01:54:01 +03:00
Gennady Grishkovtsov
d8d3d1491e Fix HTTP headers default value 2018-12-19 01:41:36 +03:00
Gennady Grishkovtsov
db90e87dff Update version to 3.0.0
- use MP3 instead of WAV
 - new callbacks & properties
 - refactoring
2018-12-17 00:00:41 +03:00
Gennady Grishkovtsov
7e89d8a33a Update version to 2.2.0 2018-10-05 22:52:39 +03:00
Gennady Grishkovtsov
420cf2e194 Update readme 2018-10-05 22:51:55 +03:00
Gennady Grishkovtsov
52b7dfe958 Add custom header & minor refactoring 2018-10-05 22:51:41 +03:00
Gennady Grishkovtsov
5d8139d674 Update package.json & webpack 2018-10-05 22:51:20 +03:00
Gennady Grishkovtsov
206216643a Update version to 2.1.0 2018-09-30 16:01:51 +03:00
Gennady Grishkovtsov
fc0c9c824a Add record removing feature 2018-09-30 15:54:50 +03:00
Gennady Grishkovtsov
261d7a80ec Add decorator for all player buttons 2018-09-30 15:54:50 +03:00
Gennady Grishkovtsov
860f7e6158 Add random ID for each record 2018-09-30 15:54:42 +03:00
Gennady Grishkovtsov
83ccce2374 Merge pull request #1 from Tomotoes/var-fs
Add :key attribute for "v-for" to recorder.vue
2018-08-20 11:59:20 +03:00
JinmaQAQ
cb7a410ae5 Modify recorder.vue 2018-08-20 11:14:14 +08:00
24 changed files with 596 additions and 351 deletions

View File

@@ -14,8 +14,9 @@
- Records limit - Records limit
- A lot of callbacks - A lot of callbacks
- Individual an audio player - Individual an audio player
- MP3/WAV support
### Tested in ### Tested in (desktop)
- Chrome - Chrome
- Firefox - Firefox
@@ -29,21 +30,28 @@ npm i vue-audio-recorder --save
## AudioRecorder props ## AudioRecorder props
| Prop | Type | Description | | Prop | Type | Description |
| --------------------- | -------- | --------------------------------------------------------------- | | --------------------- | -------- | ------------------------------------------------------------------------ |
| attempts | Number | Number of recording attempts | | attempts | Number | Number of recording attempts |
| compact | Boolean | Hide the download and upload buttons | | headers | Object | HTTP headers |
| time | Number | Time limit for the record (minutes) | | time | Number | Time limit for the record (minutes) |
| upload-url | String | URL for uploading | | bit-rate | Number | Default: 128 (only for MP3) |
| start-record | Function | Fires after click the record button | | sample-rate | Number | Default: 44100 |
| stop-record | Function | Fires after click the stop button or exceeding the time limit | | filename | String | Download/Upload filename |
| start-upload | Function | Fires after start uploading | | format | String | WAV/MP3. Default: mp3 |
| attempts-limit | Function | Fires after exceeding the attempts | | upload-url | String | URL for uploading |
| failed-upload | Function | Fires after a failure uploading | | show-download-button | Boolean | If it is true show a download button. Default: true |
| mic-failed | Function | Fires if your microphone doesn't work | | show-upload-button | Boolean | If it is true show an upload button. Default: true |
| successful-upload | Function | Fires after a successful uploading | | show-custom-button | Boolean | If true show another button linked to specific action. Default: true |
| successful-upload-msg | String | Displays the message after a successful uploading | | before-upload | Function | Callback fires before uploading |
| failed-upload-msg | String | Displays the message after a failure uploading | | successful-upload | Function | Callback fires after successful uploading |
| failed-upload | Function | Callback fires after failure uploading |
| mic-failed | Function | Callback fires if your microphone doesn't work |
| before-recording | Function | Callback fires after click the record button |
| pause-recording | Function | Callback fires after pause recording |
| after-recording | Function | Callback fires after click the stop button or exceeding the time limit |
| select-record | Function | Callback fires after choise a record. Returns the record |
| custom-callback | Function | Callback fires when clicking on the custom button |
## AudioPlayer props ## AudioPlayer props
| Prop | Type | Description | | Prop | Type | Description |
@@ -52,31 +60,31 @@ npm i vue-audio-recorder --save
## Usage ## Usage
The most common use case is to register the component globally
```js
import {AudioRecorder, AudioPlayer} from 'vue-audio-recorder'
Vue.component(AudioPlayer)
Vue.component(AudioRecorder)
```
Alternatively you can do this to register the components
```js ```js
import AudioRecorder from 'vue-audio-recorder' import AudioRecorder from 'vue-audio-recorder'
Vue.use(AudioRecorder) Vue.use(AudioRecorder)
``` ```
```js
methods: {
callback (data) {
console.debug(data)
}
}
```
```html ```html
<audio-recorder <audio-recorder
upload-url="YOUR_API_URL" upload-url="YOUR_API_URL"
:attempts="3" :attempts="3"
:time="2" :time="2"
:start-record="callback" :headers="headers"
:stop-record="callback" :before-recording="callback"
:start-upload="callback" :pause-recording="callback"
:after-recording="callback"
:select-record="callback"
:before-upload="callback"
:successful-upload="callback" :successful-upload="callback"
:failed-upload="callback"/> :failed-upload="callback"/>
``` ```
@@ -98,6 +106,11 @@ npm run dev
npm run build npm run build
``` ```
## TODO
- Clear record list
- Responsive design
## Authors ## Authors
[Gennady Grishkovtsov](https://www.linkedin.com/in/grishkovtsov/) - Developer [Gennady Grishkovtsov](https://www.linkedin.com/in/grishkovtsov/) - Developer

View File

@@ -11,32 +11,34 @@
<audio-recorder v-if="showRecorder" <audio-recorder v-if="showRecorder"
upload-url="some url" upload-url="some url"
filename="ninja"
format="wav"
:attempts="3" :attempts="3"
:time="2" :time="2"
:start-record="callback" :headers="headers"
:stop-record="callback" :before-recording="callback"
:start-upload="callback" :pause-recording="callback"
:after-recording="callback"
:select-record="callback"
:before-upload="callback"
:successful-upload="callback" :successful-upload="callback"
:failed-upload="callback"/> :failed-upload="callback"
:bit-rate="192"/>
<audio-player :src="mp3" v-if="!showRecorder"/> <audio-player :src="mp3" v-if="!showRecorder"/>
</div> </div>
</template> </template>
<script> <script>
import AudioPlayer from '../src/components/player'
import AudioRecorder from '../src/components/recorder'
export default { export default {
name: 'app', name: 'app',
components: {
AudioPlayer,
AudioRecorder
},
data () { data () {
return { return {
mp3: '/demo/example.mp3', mp3: '/demo/example.mp3',
showRecorder: true showRecorder: true,
headers: {
'X-Custom-Header': 'some data'
}
} }
}, },
methods: { methods: {

View File

@@ -2,8 +2,12 @@ import Vue from 'vue'
import axios from 'axios' import axios from 'axios'
import app from './app' import app from './app'
import AudioRecorder from '@/index'
Vue.prototype.$http = axios Vue.prototype.$http = axios
Vue.use(AudioRecorder)
new Vue({ new Vue({
el: '#app', el: '#app',
render: h => h(app) render: h => h(app)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,14 +1,16 @@
{ {
"name": "vue-audio-recorder", "name": "vue-audio-recorder",
"description": "Audio recorder for Vue.js. It allows to create, play, download and store records on a server", "description": "Audio recorder for Vue.js. It allows to create, play, download and store records on a server",
"version": "2.0.0", "version": "4.0.1",
"author": "Gennady Grishkovtsov <grishkovelli@gmail.com>", "author": "Gennady Grishkovtsov <grishkovelli@gmail.com>",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"dev": "cross-env NODE_ENV=development webpack-dev-server --open --hot --https", "dev": "webpack-dev-server --env.NODE_ENV=development --mode development --open --hot --https",
"build": "cross-env NODE_ENV=production webpack --progress --hide-modules" "build": "webpack --env.NODE_ENV=production --mode production --progress --hide-modules"
},
"dependencies": {
"lamejs": "^1.2.0"
}, },
"dependencies": {},
"browserslist": [ "browserslist": [
"> 1%", "> 1%",
"last 2 versions", "last 2 versions",
@@ -26,11 +28,13 @@
"html-webpack-plugin": "^3.2.0", "html-webpack-plugin": "^3.2.0",
"node-sass": "^4.5.3", "node-sass": "^4.5.3",
"sass-loader": "^6.0.6", "sass-loader": "^6.0.6",
"uglifyjs-webpack-plugin": "^2.0.1",
"vue": "^2.5.16", "vue": "^2.5.16",
"vue-loader": "^13.0.5", "vue-loader": "^14.2.2",
"vue-template-compiler": "^2.4.4", "vue-template-compiler": "^2.4.4",
"webpack": "^3.6.0", "webpack": "^4.17.1",
"webpack-dev-server": "^2.9.1", "webpack-cli": "^3.1.2",
"webpack-dev-server": "^3.1.9",
"webpack-merge": "^4.1.3" "webpack-merge": "^4.1.3"
}, },
"main": "dist/vue-audio-recorder.min.js", "main": "dist/vue-audio-recorder.min.js",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -0,0 +1,38 @@
<style lang="scss">
@import '../scss/icons';
</style>
<template>
<icon-button
id="download"
class="ar-icon ar-icon__xs ar-icon--no-border"
name="download"
@click.native="download"/>
</template>
<script>
import IconButton from './icon-button'
export default {
props: {
record : { type: Object },
filename : { type: String }
},
components: {
IconButton
},
methods: {
download () {
if (!this.record.url) {
return
}
const type = this.record.blob.type.split('/')[1]
const link = document.createElement('a')
link.href = this.record.url
link.download = `${this.filename}.${type}`
link.click()
}
}
}
</script>

View File

@@ -10,6 +10,9 @@
data: function () { data: function () {
return { return {
icons: { icons: {
upload: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">\n' +
' <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />\n' +
'</svg>',
download: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 12v7H5v-7H3v7c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2v-7h-2zm-6 .67l2.59-2.58L17 11.5l-5 5-5-5 1.41-1.41L11 12.67V3h2z"/><path fill="none" d="M0 0h24v24H0z"/></svg>', download: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 12v7H5v-7H3v7c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2v-7h-2zm-6 .67l2.59-2.58L17 11.5l-5 5-5-5 1.41-1.41L11 12.67V3h2z"/><path fill="none" d="M0 0h24v24H0z"/></svg>',
mic: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 14c1.66 0 2.99-1.34 2.99-3L15 5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.3-3c0 3-2.54 5.1-5.3 5.1S6.7 14 6.7 11H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c3.28-.48 6-3.3 6-6.72h-1.7z"/><path d="M0 0h24v24H0z" fill="none"/></svg>', mic: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 14c1.66 0 2.99-1.34 2.99-3L15 5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.3-3c0 3-2.54 5.1-5.3 5.1S6.7 14 6.7 11H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c3.28-.48 6-3.3 6-6.72h-1.7z"/><path d="M0 0h24v24H0z" fill="none"/></svg>',
pause: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/><path d="M0 0h24v24H0z" fill="none"/></svg>', pause: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/><path d="M0 0h24v24H0z" fill="none"/></svg>',

View File

@@ -24,7 +24,7 @@
</template> </template>
<script> <script>
import { calculateLineHeadPosition } from '@/lib/utils.js' import { calculateLineHeadPosition } from '@/lib/utils'
export default { export default {
props: { props: {
@@ -35,7 +35,7 @@
}, },
methods: { methods: {
onMouseDown (ev) { onMouseDown (ev) {
let seekPos = calculateLineHeadPosition(ev, this.$refs[this.refId]) const seekPos = calculateLineHeadPosition(ev, this.$refs[this.refId])
this.$emit('change-linehead', seekPos) this.$emit('change-linehead', seekPos)
document.addEventListener('mousemove', this.onMouseMove) document.addEventListener('mousemove', this.onMouseMove)
document.addEventListener('mouseup', this.onMouseUp) document.addEventListener('mouseup', this.onMouseUp)
@@ -43,17 +43,17 @@
onMouseUp (ev) { onMouseUp (ev) {
document.removeEventListener('mouseup', this.onMouseUp) document.removeEventListener('mouseup', this.onMouseUp)
document.removeEventListener('mousemove', this.onMouseMove) document.removeEventListener('mousemove', this.onMouseMove)
let seekPos = calculateLineHeadPosition(ev, this.$refs[this.refId]) const seekPos = calculateLineHeadPosition(ev, this.$refs[this.refId])
this.$emit('change-linehead', seekPos) this.$emit('change-linehead', seekPos)
}, },
onMouseMove (ev) { onMouseMove (ev) {
let seekPos = calculateLineHeadPosition(ev, this.$refs[this.refId]) const seekPos = calculateLineHeadPosition(ev, this.$refs[this.refId])
this.$emit('change-linehead', seekPos) this.$emit('change-linehead', seekPos)
} }
}, },
computed: { computed: {
calculateSize () { calculateSize () {
let value = this.percentage < 1 ? this.percentage * 100 : this.percentage const value = this.percentage < 1 ? this.percentage * 100 : this.percentage
return `${this.rowDirection ? 'width' : 'height'}: ${value}%` return `${this.rowDirection ? 'width' : 'height'}: ${value}%`
} }
} }

View File

@@ -1,16 +1,26 @@
<style lang="scss"> <style lang="scss">
.ar-player { .ar-player {
width: 380px; width: 380px;
height: 120px; height: unset;
border: 1px solid #E8E8E8; border: 0;
border-radius: 24px; border-radius: 0;
display: flex; display: flex;
flex-direction: column-reverse; flex-direction: row;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background-color: #FAFAFA; background-color: unset;
font-family: 'Roboto', sans-serif; font-family: 'Roboto', sans-serif;
& > .ar-player-bar {
border: 1px solid #E8E8E8;
border-radius: 24px;
margin: 0 0 0 5px;
& > .ar-player__progress {
width: 125px;
}
}
&-bar { &-bar {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -26,33 +36,6 @@
justify-content: space-around; justify-content: space-around;
} }
&--compact {
height: unset;
flex-direction: row;
border: 0;
border-radius: 0;
background-color: unset;
& > .ar-player-actions {
width: unset;
& > #download,
& > #upload {
display: none;
}
}
& > .ar-player-bar {
border: 1px solid #E8E8E8;
border-radius: 24px;
margin: 0 0 0 5px;
& > .ar-player__progress {
width: 125px;
}
}
}
&__progress { &__progress {
width: 160px; width: 160px;
margin: 0 8px; margin: 0 8px;
@@ -73,6 +56,10 @@
&--active { &--active {
fill: white !important; fill: white !important;
background-color: #05CBCD !important; background-color: #05CBCD !important;
&:hover {
fill: #505050 !important;
}
} }
} }
} }
@@ -81,27 +68,14 @@
</style> </style>
<template> <template>
<div class="ar-player" :class="{'ar-player--compact': compact}"> <div class="ar-player">
<div class="ar-player-actions"> <div class="ar-player-actions">
<icon-button
id="download"
class="ar-icon ar-icon__sm"
name="download"
@click.native="download"/>
<icon-button <icon-button
id="play" id="play"
class="ar-icon ar-icon__lg ar-player__play" class="ar-icon ar-icon__lg ar-player__play"
:name="playBtnIcon" :name="playBtnIcon"
:class="{'ar-player__play--active': isPlaying}" :class="{'ar-player__play--active': isPlaying}"
@click.native="playback"/> @click.native="playback"/>
<icon-button
id="upload"
class="ar-icon ar-icon__sm"
name="save"
@click.native="upload"/>
</div> </div>
<div class="ar-player-bar"> <div class="ar-player-bar">
@@ -120,27 +94,23 @@
</template> </template>
<script> <script>
import IconButton from './icon-button' import IconButton from './icon-button'
import LineControl from './line-control' import LineControl from './line-control'
import VolumeControl from './volume-control' import VolumeControl from './volume-control'
import { convertTimeMMSS } from '@/lib/utils.js' import { convertTimeMMSS } from '@/lib/utils'
export default { export default {
props: { props: {
src : { type: String }, src : { type: String },
uploadUrl : { type: String }, record : { type: Object },
record : { type: Object }, filename : { type: String }
compact : { type: Boolean, default: true },
startUpload : { type: Function },
successfulUpload : { type: Function },
failedUpload : { type: Function }
}, },
data () { data () {
return { return {
isPlaying: false, isPlaying : false,
duration: convertTimeMMSS(0), duration : convertTimeMMSS(0),
playedTime: convertTimeMMSS(0), playedTime : convertTimeMMSS(0),
progress: 0 progress : 0
} }
}, },
components: { components: {
@@ -161,14 +131,23 @@
}) })
this.player.addEventListener('timeupdate', this._onTimeUpdate) this.player.addEventListener('timeupdate', this._onTimeUpdate)
this.$eventBus.$on('remove-record', () => {
this._resetProgress()
})
}, },
computed: { computed: {
audioSource () {
const url = this.src || this.record.url
if (url) {
return url
} else {
this._resetProgress()
}
},
playBtnIcon () { playBtnIcon () {
return this.isPlaying ? 'pause' : 'play' return this.isPlaying ? 'pause' : 'play'
}, },
audioSource () {
return this.src || this.record.url
},
playerUniqId () { playerUniqId () {
return `audio-player${this._uid}` return `audio-player${this._uid}`
} }
@@ -187,47 +166,15 @@
this.isPlaying = !this.isPlaying this.isPlaying = !this.isPlaying
}, },
upload () {
if (!this.audioSource) {
return
}
if (this.startUpload) {
this.startUpload()
}
this.$emit('on-start-upload')
let data = new FormData()
data.append('audio', this.record.blob, 'my-record')
this.$http.post(this.uploadUrl, data, {
headers: {'Content-Type': `multipart/form-data; boundary=${data._boundary}`}
}).then(resp => {
this.$emit('on-end-upload', 'success')
if (this.successfulUpload) {
this.successfulUpload(resp)
}
}).catch(error => {
this.$emit('on-end-upload', 'fail')
if (this.failedUpload) {
this.failedUpload(error)
}
})
},
download () {
if (!this.audioSource) {
return
}
let link = document.createElement('a')
link.href = this.record.url
link.download = 'record.wav'
link.click()
},
_resetProgress () { _resetProgress () {
this.isPlaying = false if (this.isPlaying) {
this.progress = 0 this.player.pause()
}
this.duration = convertTimeMMSS(0)
this.playedTime = convertTimeMMSS(0)
this.progress = 0
this.isPlaying = false
}, },
_onTimeUpdate () { _onTimeUpdate () {
this.playedTime = convertTimeMMSS(this.player.currentTime) this.playedTime = convertTimeMMSS(this.player.currentTime)

View File

@@ -1,4 +1,5 @@
<style lang="scss"> <style lang="scss">
@import '../scss/icons';
.ar { .ar {
width: 420px; width: 420px;
font-family: 'Roboto', sans-serif; font-family: 'Roboto', sans-serif;
@@ -23,12 +24,14 @@
&__record { &__record {
width: 320px; width: 320px;
height: 45px;
padding: 0 10px; padding: 0 10px;
margin: 0 auto; margin: 0 auto;
line-height: 45px; line-height: 45px;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
border-bottom: 1px solid #E8E8E8; border-bottom: 1px solid #E8E8E8;
position: relative;
&--selected { &--selected {
border: 1px solid #E8E8E8; border: 1px solid #E8E8E8;
@@ -70,7 +73,7 @@
&__records-limit { &__records-limit {
position: absolute; position: absolute;
color: #AEAEAE; color: #AEAEAE;
font-size: 12px; font-size: 13px;
top: 78px; top: 78px;
} }
} }
@@ -105,7 +108,7 @@
@keyframes blink { @keyframes blink {
0% { opacity: .2; } 0% { opacity: .2; }
20% { opacity: 1; } 20% { opacity: 1; }
100% { opacity: .2; } 100% { opacity: .2; }
} }
} }
@@ -144,6 +147,36 @@
color: red; color: red;
} }
} }
&__rm {
cursor: pointer;
position: absolute;
width: 6px;
height: 6px;
padding: 6px;
line-height: 6px;
margin: auto;
left: 10px;
bottom: 0;
top: 0;
color: rgb(244, 120, 90);
}
&__downloader,
&__uploader {
position: absolute;
top: 0;
bottom: 0;
margin: auto;
}
&__downloader {
right: 115px;
}
&__uploader {
right: 85px;
}
} }
@import '../scss/icons'; @import '../scss/icons';
@@ -152,7 +185,6 @@
<template> <template>
<div class="ar"> <div class="ar">
<div class="ar__overlay" v-if="isUploading"></div> <div class="ar__overlay" v-if="isUploading"></div>
<div class="ar-spinner" v-if="isUploading"> <div class="ar-spinner" v-if="isUploading">
<div class="ar-spinner__dot"></div> <div class="ar-spinner__dot"></div>
<div class="ar-spinner__dot"></div> <div class="ar-spinner__dot"></div>
@@ -182,75 +214,105 @@
<div class="ar-records"> <div class="ar-records">
<div <div
class="ar-records__record" class="ar-records__record"
:class="{'ar-records__record--selected': idx === selectedRecord.idx}" :class="{'ar-records__record--selected': record.id === selected.id}"
:key="record.id"
v-for="(record, idx) in recordList" v-for="(record, idx) in recordList"
@click="selectRecord(idx, record)"> @click="choiceRecord(record)">
<div
class="ar__rm"
v-if="record.id === selected.id"
@click="removeRecord(idx)">&times;</div>
<div class="ar__text">Record {{idx + 1}}</div> <div class="ar__text">Record {{idx + 1}}</div>
<div class="ar__text">{{record.duration}}</div> <div class="ar__text">{{record.duration}}</div>
<downloader
v-if="record.id === selected.id && showDownloadButton"
class="ar__downloader"
:record="record"
:filename="filename"/>
<uploader
v-if="record.id === selected.id && showUploadButton"
class="ar__uploader"
:record="record"
:filename="filename"
:headers="headers"
:upload-url="uploadUrl"/>
<icon-button name="upload" class="ar__uploader ar-icon ar-icon__xs" v-if="showCustomButton" @click.native="customAction(record)" />
</div> </div>
</div> </div>
<audio-player <audio-player :record="selected"/>
:compact="compact"
:record="selectedRecord"
:upload-url="uploadUrl"
:start-upload="startUpload"
:successful-upload="successfulUpload"
:failed-upload="failedUpload"
@start-upload="onStartUpload"
@end-upload="onEndUpload"/>
<div :class="uploadStatusClasses" v-if="uploadStatus">{{message}}</div>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import AudioPlayer from './player.vue' import AudioPlayer from './player'
import IconButton from './icon-button.vue' import Downloader from './downloader'
import Recorder from '@/lib/recorder.js' import IconButton from './icon-button'
import { convertTimeMMSS } from '@/lib/utils.js' import Recorder from '@/lib/recorder'
import Uploader from './uploader'
import UploaderPropsMixin from '@/mixins/uploader-props'
import { convertTimeMMSS } from '@/lib/utils'
export default { export default {
mixins: [UploaderPropsMixin],
props: { props: {
attempts : { type: Number }, attempts : { type: Number },
compact : { type: Boolean, default: false }, time : { type: Number },
time : { type: Number },
uploadUrl : { type: String }, bitRate : { type: Number, default: 128 },
sampleRate : { type: Number, default: 44100 },
showDownloadButton : { type: Boolean, default: true },
showUploadButton : { type: Boolean, default: true },
showCustomButton : {type: Boolean, default: false},
attemptsLimit : { type: Function },
failedUpload : { type: Function },
micFailed : { type: Function }, micFailed : { type: Function },
startRecord : { type: Function }, beforeRecording : { type: Function },
startUpload : { type: Function }, pauseRecording : { type: Function },
stopRecord : { type: Function }, afterRecording : { type: Function },
failedUpload : { type: Function },
beforeUpload : { type: Function },
successfulUpload : { type: Function }, successfulUpload : { type: Function },
selectRecord : { type: Function },
successfulUploadMsg : { type: String, default: 'Upload successful' }, customCallback : { type: Function },
failedUploadMsg : { type: String, default: 'Upload fail' } format : { type: String }
}, },
data () { data () {
return { return {
isUploading: false, isUploading : false,
recorder: new Recorder({ recorder : this._initRecorder(),
afterStop: () => { recordList : [],
this.recordList = this.recorder.recordList() selected : {},
uploadStatus : null,
if (this.stopRecord) {
this.stopRecord('stop record')
}
},
attempts: this.attempts,
time: this.time
}),
recordList: [],
selectedRecord: {},
uploadStatus: null
} }
}, },
components: { components: {
AudioPlayer, AudioPlayer,
IconButton Downloader,
IconButton,
Uploader
},
mounted () {
this.$eventBus.$on('start-upload', () => {
this.isUploading = true
this.beforeUpload && this.beforeUpload('before upload')
})
this.$eventBus.$on('end-upload', (msg) => {
this.isUploading = false
if (msg.status === 'success') {
this.successfulUpload && this.successfulUpload(msg.response)
} else {
this.failedUpload && this.failedUpload(msg.response)
}
})
},
beforeDestroy () {
this.stopRecorder()
}, },
methods: { methods: {
toggleRecorder () { toggleRecorder () {
@@ -260,14 +322,8 @@
if (!this.isRecording || (this.isRecording && this.isPause)) { if (!this.isRecording || (this.isRecording && this.isPause)) {
this.recorder.start() this.recorder.start()
if (this.startRecord) {
this.startRecord('start record')
}
} else { } else {
this.recorder.pause() this.recorder.pause()
if (this.startRecord) {
this.startRecord('pause record')
}
} }
}, },
stopRecorder () { stopRecorder () {
@@ -276,22 +332,42 @@
} }
this.recorder.stop() this.recorder.stop()
this.recordList = this.recorder.recordList()
}, },
selectRecord (idx, record) { removeRecord (idx) {
this.selectedRecord = { idx: idx, url: record.url, blob: record.blob } this.recordList.splice(idx, 1)
this.$set(this.selected, 'url', null)
this.$eventBus.$emit('remove-record')
}, },
onStartUpload () { choiceRecord (record) {
this.isUploading = true if (this.selected === record) {
return
}
this.selected = record
this.selectRecord && this.selectRecord(record)
}, },
onEndUpload (status) { _initRecorder () {
this.isUploading = false return new Recorder({
this.uploadStatus = status beforeRecording : this.beforeRecording,
setTimeout(() => {this.uploadStatus = null}, 1500) afterRecording : this.afterRecording,
pauseRecording : this.pauseRecording,
micFailed : this.micFailed,
bitRate : this.bitRate,
sampleRate : this.sampleRate,
format : this.format
})
},
customAction(record) {
if (this.customCallback) {
this.customCallback(record)
} else {
console.log(record)
}
} }
}, },
computed: { computed: {
attemptsLeft () { attemptsLeft () {
return this.attempts - this.recorder.records.length return this.attempts - this.recordList.length
}, },
iconButtonType () { iconButtonType () {
return this.isRecording && this.isPause ? 'mic' : this.isRecording ? 'pause' : 'mic' return this.isRecording && this.isPause ? 'mic' : this.isRecording ? 'pause' : 'mic'
@@ -302,20 +378,12 @@
isRecording () { isRecording () {
return this.recorder.isRecording return this.recorder.isRecording
}, },
message () {
return this.uploadStatus === 'success' ? this.successfulUploadMsg : this.failedUploadMsg
},
recordedTime () { recordedTime () {
if (this.time && this.recorder.duration >= this.time * 60) { if (this.time && this.recorder.duration >= this.time * 60) {
this.stopRecorder() this.stopRecorder()
} }
return convertTimeMMSS(this.recorder.duration) return convertTimeMMSS(this.recorder.duration)
}, },
uploadStatusClasses () {
let classes = ['ar__upload-status']
classes.push(this.uploadStatus === 'success' ? 'ar__upload-status--success' : 'ar__upload-status--fail')
return classes.join(' ')
},
volume () { volume () {
return parseFloat(this.recorder.volume) return parseFloat(this.recorder.volume)
} }

View File

@@ -0,0 +1,43 @@
<style lang="scss">
@import '../scss/icons';
</style>
<template>
<icon-button name="save" class="ar-icon ar-icon__xs ar-icon--no-border" @click.native="upload"/>
</template>
<script>
import IconButton from './icon-button'
import UploaderPropsMixin from '@/mixins/uploader-props'
export default {
mixins: [UploaderPropsMixin],
props: {
record: { type: Object }
},
components: {
IconButton
},
methods: {
upload () {
if (!this.record.url) {
return
}
this.$eventBus.$emit('start-upload')
const data = new FormData()
data.append('audio', this.record.blob, `${this.filename}.mp3`)
const headers = Object.assign(this.headers, {})
headers['Content-Type'] = `multipart/form-data; boundary=${data._boundary}`
this.$http.post(this.uploadUrl, data, { headers: headers }).then(resp => {
this.$eventBus.$emit('end-upload', { status: 'success', response: resp })
}).catch(error => {
this.$eventBus.$emit('end-upload', { status: 'fail', response: error })
})
}
}
}
</script>

View File

@@ -37,8 +37,8 @@
</template> </template>
<script> <script>
import IconButton from './icon-button.vue' import IconButton from './icon-button'
import LineControl from './line-control.vue' import LineControl from './line-control'
export default { export default {
data () { data () {

View File

@@ -1,5 +1,5 @@
import AudioPlayer from './components/player.vue' import AudioPlayer from '@/components/player.vue'
import AudioRecorder from './components/recorder.vue' import AudioRecorder from '@/components/recorder.vue'
const components = { const components = {
AudioPlayer, AudioPlayer,
@@ -12,6 +12,8 @@ const components = {
this.installed = true this.installed = true
Vue.prototype.$eventBus = Vue.prototype.$eventBus || new Vue
Vue.component('audio-player', AudioPlayer) Vue.component('audio-player', AudioPlayer)
Vue.component('audio-recorder', AudioRecorder) Vue.component('audio-recorder', AudioRecorder)
} }

49
src/lib/mp3-encoder.js Normal file
View File

@@ -0,0 +1,49 @@
import { Mp3Encoder } from 'lamejs'
export default class {
constructor(config) {
this.bitRate = config.bitRate
this.sampleRate = config.sampleRate
this.dataBuffer = []
this.encoder = new Mp3Encoder(1, this.sampleRate, this.bitRate)
}
encode(arrayBuffer) {
const maxSamples = 1152
const samples = this._convertBuffer(arrayBuffer)
let remaining = samples.length
for (let i = 0; remaining >= 0; i += maxSamples) {
const left = samples.subarray(i, i + maxSamples)
const buffer = this.encoder.encodeBuffer(left)
this.dataBuffer.push(new Int8Array(buffer))
remaining -= maxSamples
}
}
finish() {
this.dataBuffer.push(this.encoder.flush())
const blob = new Blob(this.dataBuffer, { type: 'audio/mp3' })
this.dataBuffer = []
return {
id : Date.now(),
blob : blob,
url : URL.createObjectURL(blob)
}
}
_floatTo16BitPCM(input, output) {
for (let i = 0; i < input.length; i++) {
const s = Math.max(-1, Math.min(1, input[i]))
output[i] = (s < 0 ? s * 0x8000 : s * 0x7FFF)
}
}
_convertBuffer(arrayBuffer) {
const data = new Float32Array(arrayBuffer)
const out = new Int16Array(arrayBuffer.length)
this._floatTo16BitPCM(data, out)
return out
}
}

View File

@@ -1,30 +1,56 @@
import Mp3Encoder from './mp3-encoder'
import WavEncoder from './wav-encoder' import WavEncoder from './wav-encoder'
import { convertTimeMMSS } from './utils' import { convertTimeMMSS } from './utils'
export default class { export default class {
constructor (options = {}) { constructor (options = {}) {
this.afterStop = options.afterStop this.beforeRecording = options.beforeRecording
this.micFailed = options.micFailed this.pauseRecording = options.pauseRecording
this.afterRecording = options.afterRecording
this.micFailed = options.micFailed
this.format = options.format
this.encoderOptions = {
bitRate : options.bitRate,
sampleRate : options.sampleRate
}
this.bufferSize = 4096 this.bufferSize = 4096
this.records = [] this.records = []
this.samples = []
this.isPause = false this.isPause = false
this.isRecording = false this.isRecording = false
this.duration = 0 this.duration = 0
this.volume = 0 this.volume = 0
this.wavSamples = []
this._duration = 0 this._duration = 0
} }
start () { start () {
navigator.mediaDevices.getUserMedia({audio: true}) const constraints = {
.then(this._micCaptured.bind(this)) video: false,
.catch(this._micError.bind(this)) audio: {
this.isPause = false channelCount: 1,
echoCancellation: false
}
}
this.beforeRecording && this.beforeRecording('start recording')
navigator.mediaDevices
.getUserMedia(constraints)
.then(this._micCaptured.bind(this))
.catch(this._micError.bind(this))
this.isPause = false
this.isRecording = true this.isRecording = true
if (this._isMp3() && !this.lameEncoder) {
this.lameEncoder = new Mp3Encoder(this.encoderOptions)
}
} }
stop () { stop () {
@@ -33,41 +59,41 @@ export default class {
this.processor.disconnect() this.processor.disconnect()
this.context.close() this.context.close()
let encoder = new WavEncoder({ let record = null
bufferSize: this.bufferSize,
sampleRate: this.context.sampleRate,
samples: this.samples
})
let audioBlob = encoder.getData() if (this._isMp3()) {
let audioUrl = URL.createObjectURL(audioBlob) record = this.lameEncoder.finish()
} else {
this.samples = [] let wavEncoder = new WavEncoder({
bufferSize : this.bufferSize,
this.records.push({ sampleRate : this.encoderOptions.sampleRate,
blob: audioBlob, samples : this.wavSamples
url: audioUrl, })
duration: convertTimeMMSS(this.duration) record = wavEncoder.finish()
}) this.wavSamples = []
this.isPause = false
this.isRecording = false
this._duration = 0
this.duration = 0
if (this.afterStop) {
this.afterStop()
} }
record.duration = convertTimeMMSS(this.duration)
this.records.push(record)
this._duration = 0
this.duration = 0
this.isPause = false
this.isRecording = false
this.afterRecording && this.afterRecording(record)
} }
pause () { pause () {
this.stream.getTracks().forEach((track) => track.stop()) this.stream.getTracks().forEach((track) => track.stop())
this.input.disconnect() this.input.disconnect()
this.processor.disconnect() this.processor.disconnect()
this.context.close()
this._duration = this.duration this._duration = this.duration
this.isPause = true this.isPause = true
this.pauseRecording && this.pauseRecording('pause recording')
} }
recordList () { recordList () {
@@ -75,19 +101,25 @@ export default class {
} }
lastRecord () { lastRecord () {
return this.records.slice(-1) return this.records.slice(-1).pop()
} }
_micCaptured (stream) { _micCaptured (stream) {
this.context = new(window.AudioContext || window.webkitAudioContext)() this.context = new(window.AudioContext || window.webkitAudioContext)()
this.input = this.context.createMediaStreamSource(stream) this.duration = this._duration
this.processor = this.context.createScriptProcessor(this.bufferSize, 1, 1) this.input = this.context.createMediaStreamSource(stream)
this.duration = this._duration this.processor = this.context.createScriptProcessor(this.bufferSize, 1, 1)
this.stream = stream this.stream = stream
this.processor.onaudioprocess = (ev) => { this.processor.onaudioprocess = (ev) => {
let sample = ev.inputBuffer.getChannelData(0) const sample = ev.inputBuffer.getChannelData(0)
let sum = 0.0 let sum = 0.0
if (this._isMp3()) {
this.lameEncoder.encode(sample)
} else {
this.wavSamples.push(new Float32Array(sample))
}
for (let i = 0; i < sample.length; ++i) { for (let i = 0; i < sample.length; ++i) {
sum += sample[i] * sample[i] sum += sample[i] * sample[i]
@@ -95,7 +127,6 @@ export default class {
this.duration = parseFloat(this._duration) + parseFloat(this.context.currentTime.toFixed(2)) this.duration = parseFloat(this._duration) + parseFloat(this.context.currentTime.toFixed(2))
this.volume = Math.sqrt(sum / sample.length).toFixed(2) this.volume = Math.sqrt(sum / sample.length).toFixed(2)
this.samples.push(new Float32Array(sample))
} }
this.input.connect(this.processor) this.input.connect(this.processor)
@@ -103,8 +134,10 @@ export default class {
} }
_micError (error) { _micError (error) {
if (this.micFailed) { this.micFailed && this.micFailed(error)
this.micFailed(error) }
}
_isMp3 () {
return this.format.toLowerCase() === 'mp3'
} }
} }

View File

@@ -1,6 +1,6 @@
export function calculateLineHeadPosition (ev, element) { export function calculateLineHeadPosition (ev, element) {
let progressWidth = element.getBoundingClientRect().width const progressWidth = element.getBoundingClientRect().width
let leftPosition = ev.target.getBoundingClientRect().left const leftPosition = ev.target.getBoundingClientRect().left
let pos = (ev.clientX - leftPosition) / progressWidth let pos = (ev.clientX - leftPosition) / progressWidth
try { try {

View File

@@ -5,7 +5,7 @@ export default class {
this.samples = options.samples this.samples = options.samples
} }
getData () { finish () {
this._joinSamples() this._joinSamples()
let buffer = new ArrayBuffer(44 + this.samples.length * 2) let buffer = new ArrayBuffer(44 + this.samples.length * 2)
@@ -27,7 +27,13 @@ export default class {
this._floatTo16BitPCM(view, 44, this.samples) this._floatTo16BitPCM(view, 44, this.samples)
return new Blob([view], {type: 'audio/wav'}) const blob = new Blob([view], {type: 'audio/wav'})
return {
id : Date.now(),
blob : blob,
url : URL.createObjectURL(blob)
}
} }
_floatTo16BitPCM (output, offset, input) { _floatTo16BitPCM (output, offset, input) {

View File

@@ -0,0 +1,8 @@
export default {
props: {
filename : { type: String, default: 'record' },
format : { type: String, default: 'mp3' },
headers : { type: Object, default: () => ({}) },
uploadUrl : { type: String }
}
}

View File

@@ -7,6 +7,16 @@
cursor: pointer; cursor: pointer;
transition: .2s; transition: .2s;
&:hover {
fill: #505050;
}
&--no-border {
border: 0;
border-radius: 0;
padding: 0;
}
&--rec { &--rec {
fill: white; fill: white;
background-color: #FF6B64; background-color: #FF6B64;
@@ -15,7 +25,6 @@
&--pulse { &--pulse {
animation: ripple .5s linear infinite; animation: ripple .5s linear infinite;
@keyframes ripple { @keyframes ripple {
0% { 0% {
box-shadow: box-shadow:
@@ -32,14 +41,22 @@
} }
} }
&__xs {
width: 18px;
height: 18px;
line-height: 18px;
}
&__sm { &__sm {
width: 30px; width: 30px;
height: 30px; height: 30px;
line-height: 30px;
} }
&__lg { &__lg {
width: 45px; width: 45px;
height: 45px; height: 45px;
line-height: 45px;
box-shadow: 0 2px 5px 1px rgba(158,158,158,0.5); box-shadow: 0 2px 5px 1px rgba(158,158,158,0.5);
} }
} }

View File

@@ -1,67 +1,70 @@
const webpack = require('webpack') const webpack = require('webpack')
const merge = require('webpack-merge') const merge = require('webpack-merge')
const env = `./webpack.${process.env.NODE_ENV === 'production' ? 'prod' : 'dev'}.js`
const path = require('path') const path = require('path')
module.exports = merge(require(env), { module.exports = (env, args) => {
module: { let conf = `./webpack.${env.NODE_ENV === 'production' ? 'prod' : 'dev'}.js`
rules: [
{ return merge(require(conf), {
test: /\.scss$/, module: {
use: [ rules: [
'vue-style-loader', {
'css-loader', test: /\.scss$/,
'sass-loader' use: [
], 'vue-style-loader',
}, 'css-loader',
{ 'sass-loader'
test: /\.vue$/, ],
loader: 'vue-loader', },
options: { {
loaders: { test: /\.vue$/,
'scss': [ loader: 'vue-loader',
'vue-style-loader', options: {
'css-loader', loaders: {
'sass-loader' 'scss': [
] 'vue-style-loader',
'css-loader',
'sass-loader'
]
}
}
},
{
test: /\.js$/,
loader: 'babel-loader',
exclude: /node_modules/
},
{
test: /\.(png|jpg|gif|svg)$/,
loader: 'file-loader',
options: {
name: '[name].[ext]?[hash]'
} }
} }
}, ]
{
test: /\.js$/,
loader: 'babel-loader',
exclude: /node_modules/
},
{
test: /\.(png|jpg|gif|svg)$/,
loader: 'file-loader',
options: {
name: '[name].[ext]?[hash]'
}
}
]
},
resolve: {
alias: {
'vue$': 'vue/dist/vue.esm.js',
'@': path.resolve(__dirname, 'src')
}, },
extensions: ['*', '.js', '.vue', '.json'] resolve: {
}, alias: {
plugins: [ 'vue$': 'vue/dist/vue.esm.js',
new webpack.DefinePlugin({ '@': path.resolve(__dirname, 'src')
'process.env': {
NODE_ENV: `"${process.env.NODE_ENV}"`
}, },
VERSION: JSON.stringify(require("./package.json").version) extensions: ['*', '.js', '.vue', '.json']
}), },
], plugins: [
devServer: { new webpack.DefinePlugin({
historyApiFallback: true, 'process.env': {
noInfo: true, NODE_ENV: `"${process.env.NODE_ENV}"`
overlay: true },
}, VERSION: JSON.stringify(require("./package.json").version)
performance: { }),
hints: false ],
} devServer: {
}) historyApiFallback: true,
noInfo: true,
overlay: true
},
performance: {
hints: false
}
})
}

View File

@@ -2,7 +2,6 @@ const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin') const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = { module.exports = {
devtool: '#eval-source-map',
entry: './demo/index.js', entry: './demo/index.js',
output: { output: {
path: path.resolve(__dirname, './demo') path: path.resolve(__dirname, './demo')

View File

@@ -1,9 +1,12 @@
const path = require('path') const path = require('path')
const webpack = require('webpack') const webpack = require('webpack')
const UglifyJsPlugin = require('uglifyjs-webpack-plugin')
module.exports = { module.exports = {
devtool: '#source-map', devtool: '#source-map',
entry: './src/index.js', entry: {
main: './src/index.js'
},
output: { output: {
path: path.resolve(__dirname, 'dist'), path: path.resolve(__dirname, 'dist'),
filename: 'vue-audio-recorder.min.js', filename: 'vue-audio-recorder.min.js',
@@ -12,18 +15,21 @@ module.exports = {
libraryExport: 'default', libraryExport: 'default',
umdNamedDefine: true umdNamedDefine: true
}, },
optimization: {
minimizer: [
new UglifyJsPlugin({
cache: true,
parallel: true,
sourceMap: true
})
]
},
plugins: [ plugins: [
new webpack.DefinePlugin({ new webpack.DefinePlugin({
'process.env': { 'process.env': {
NODE_ENV: '"production"' NODE_ENV: '"production"'
} }
}), }),
new webpack.optimize.UglifyJsPlugin({
sourceMap: true,
compress: {
warnings: true
}
}),
new webpack.LoaderOptionsPlugin({ new webpack.LoaderOptionsPlugin({
minimize: false minimize: false
}) })