Add existing code

This commit is contained in:
Gennady Grishkovtsov
2018-07-23 12:00:58 +03:00
parent ff7cb590c5
commit 95a9700d50
22 changed files with 10295 additions and 2 deletions

321
src/audio-recorder.vue Normal file
View File

@@ -0,0 +1,321 @@
<style lang="scss">
.ar {
width: 420px;
font-family: 'Roboto', sans-serif;
border-radius: 16px;
background-color: #FAFAFA;
box-shadow: 0 4px 18px 0 rgba(0,0,0,0.17);
position: relative;
box-sizing: content-box;
&-content {
padding: 16px;
display: flex;
flex-direction: column;
align-items: center;
}
&-records {
height: 138px;
padding-top: 1px;
overflow-y: auto;
margin-bottom: 20px;
&__record {
width: 320px;
padding: 0 10px;
margin: 0 auto;
line-height: 45px;
display: flex;
justify-content: space-between;
border-bottom: 1px solid #E8E8E8;
&--selected {
border: 1px solid #E8E8E8;
border-radius: 24px;
background-color: #FFFFFF;
margin-top: -1px;
padding: 0 34px;
}
}
}
&-recorder {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
&__duration {
color: #AEAEAE;
font-size: 32px;
font-weight: 500;
margin-top: 20px;
margin-bottom: 16px;
}
&__stop {
position: absolute;
top: 10px;
right: -52px;
}
&__time-limit {
position: absolute;
color: #AEAEAE;
font-size: 12px;
top: 128px;
}
&__records-limit {
position: absolute;
color: #AEAEAE;
font-size: 12px;
top: 78px;
}
}
&-spinner {
display: flex;
height: 30px;
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
margin: auto;
width: 144px;
z-index: 10;
&__dot {
display: block;
margin: 0 8px;
border-radius: 50%;
width: 30px;
height: 30px;
background: #05CBCD;
animation-name: blink;
animation-duration: 1.4s;
animation-iteration-count: infinite;
animation-fill-mode: both;
&:nth-child(2) { animation-delay: .2s; }
&:nth-child(3) { animation-delay: .4s; }
@keyframes blink {
0% { opacity: .2; }
20% { opacity: 1; }
100% { opacity: .2; }
}
}
}
&__text {
color: rgba(84,84,84,0.5);
font-size: 16px;
}
&__blur {
filter: blur(2px);
opacity: 0.7;
}
&__overlay {
position: absolute;
width: 100%;
height: 100%;
z-index: 10;
}
&__upload-status {
text-align: center;
font-size: 10px;
padding: 2px;
letter-spacing: 1px;
position: absolute;
bottom: 0;
&--success {
color: green;
}
&--fail {
color: red;
}
}
}
</style>
<template>
<div class="ar">
<div class="ar__overlay" v-if="isUploading"></div>
<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>
<div class="ar-content" :class="{'ar__blur': isUploading}">
<div class="ar-recorder">
<icon-button
size="lg"
:name="iconButtonType"
:class="{
'ar-icon-button--rec': isRecording,
'ar-icon-button--pulse': isRecording && volume > 0.02
}"
@click.native="toggleRecorder"/>
<icon-button
class="ar-recorder__stop"
name="stop"
@click.native="stopRecorder"/>
</div>
<div class="ar-recorder__records-limit" v-if="attempts">Attempts: {{attemptsLeft}}/{{attempts}}</div>
<div class="ar-recorder__duration">{{recordedTime}}</div>
<div class="ar-recorder__time-limit" v-if="time">Record duration is limited: {{time}}m</div>
<div class="ar-records">
<div
class="ar-records__record"
:class="{'ar-records__record--selected': idx === selectedRecord.idx}"
v-for="(record, idx) in recordList"
@click="selectRecord(idx, record)">
<div class="ar__text">Record {{idx + 1}}</div>
<div class="ar__text">{{record.duration}}</div>
</div>
</div>
<record-player
:record="selectedRecord"
:upload-url="uploadUrl"
:start-upload="startUpload"
:successful-upload="successfulUpload"
:failed-upload="failedUpload"
@on-start-upload="onStartUpload"
@on-end-upload="onEndUpload"/>
<div :class="uploadStatusClasses" v-if="uploadStatus">{{message}}</div>
</div>
</div>
</template>
<script>
import IconButton from './components/icon-button.vue'
import Recorder from './lib/recorder.js'
import RecordPlayer from './components/record-player.vue'
import { convertTimeMMSS } from './lib/utils.js'
export default {
props: {
attempts: { type: Number },
time: { type: Number },
uploadUrl: { type: String },
attemptsLimit: { type: Function },
failedUpload: { type: Function },
micFailed: { type: Function },
startRecord: { type: Function },
startUpload: { type: Function },
stopRecord: { type: Function },
successfulUpload: { type: Function },
successfulUploadMsg: { type: String, default: 'Upload successful' },
failedUploadMsg: { type: String, default: 'Upload fail' }
},
data () {
return {
isUploading: false,
recorder: new Recorder({
afterStop: () => {
this.recordList = this.recorder.recordList()
if (this.stopRecord) {
this.stopRecord('stop record')
}
},
attempts: this.attempts,
time: this.time
}),
recordList: [],
selectedRecord: {},
uploadStatus: null
}
},
components: {
IconButton,
RecordPlayer
},
methods: {
toggleRecorder () {
if (this.attempts && this.recorder.records.length >= this.attempts) {
return
}
if (!this.isRecording || (this.isRecording && this.isPause)) {
this.recorder.start()
if (this.startRecord) {
this.startRecord('start record')
}
} else {
this.recorder.pause()
if (this.startRecord) {
this.startRecord('pause record')
}
}
},
stopRecorder () {
if (!this.isRecording) {
return
}
this.recorder.stop()
},
selectRecord (idx, record) {
this.selectedRecord = { idx: idx, url: record.url, blob: record.blob }
},
onStartUpload () {
this.isUploading = true
},
onEndUpload (status) {
this.isUploading = false
this.uploadStatus = status
setTimeout(() => {this.uploadStatus = null}, 1500)
}
},
computed: {
attemptsLeft () {
return this.attempts - this.recorder.records.length
},
iconButtonType () {
return this.isRecording && this.isPause ? 'mic' : this.isRecording ? 'pause' : 'mic'
},
isPause () {
return this.recorder.isPause
},
isRecording () {
return this.recorder.isRecording
},
message () {
return this.uploadStatus === 'success' ? this.successfulUploadMsg : this.failedUploadMsg
},
recordedTime () {
if (this.time && this.recorder.duration >= this.time * 60) {
this.stopRecorder()
}
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 () {
return parseFloat(this.recorder.volume)
}
}
}
</script>

View File

@@ -0,0 +1,84 @@
<style lang="scss">
.ar-icon-button {
fill: #747474;
border-radius: 50%;
border: 1px solid #05CBCD;
background-color: #FFFFFF;
padding: 5px;
cursor: pointer;
transition: .2s;
&--rec {
fill: white;
background-color: #FF6B64;
border-color: transparent;
}
&--pulse {
animation: ripple .5s linear infinite;
@keyframes ripple {
0% {
box-shadow:
0 0 0 0 rgba(red, 0.1),
0 0 0 1px rgba(red, 0.1),
0 0 0 5px rgba(red, 0.1);
}
100% {
box-shadow:
0 0 0 0 rgba(red, 0.1),
0 0 0 10px rgba(red, 0.1),
0 0 0 20px rgba(red, 0);
}
}
}
&--clicked {
fill: white;
background-color: #05CBCD;
}
&__sm {
width: 30px;
height: 30px;
}
&__lg {
width: 45px;
height: 45px;
box-shadow: 0 2px 5px 1px rgba(158,158,158,0.5);
}
}
</style>
<template>
<div :class="iconClasses" v-html="icons[name]"></div>
</template>
<script>
export default {
props: {
name: { type: String },
size: { type: String, default: 'sm' }
},
data: function () {
return {
icons: {
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>',
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>',
play: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/><path d="M0 0h24v24H0z" fill="none"/></svg>',
save: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M0 0h24v24H0z" fill="none"/><path d="M17 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V7l-4-4zm-5 16c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3zm3-10H5V5h10v4z"/></svg>',
stop: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M0 0h24v24H0z" fill="none"/><path d="M6 6h12v12H6z"/></svg>',
volume: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/><path d="M0 0h24v24H0z" fill="none"/></svg>'
}
}
},
computed: {
iconClasses () {
return ['ar-icon-button', `ar-icon-button__${this.size}`]
}
}
}
</script>

View File

@@ -0,0 +1,54 @@
<style lang="scss">
.ar-line-control {
position: relative;
&__head {
position: absolute;
height: inherit;
background-color: #616161;
border-radius: inherit;
}
}
</style>
<template>
<div :ref="refId" class="ar-line-control" @mousedown="onMouseDown">
<div class="ar-line-control__head" :style="{width: percentageWidth}"></div>
</div>
</template>
<script>
import { calculateLineHeadPosition } from '../lib/utils.js'
export default {
props: {
refId: { type: String },
eventName: { type: String },
percentage: { type: Number, default: 0 }
},
methods: {
onMouseDown (ev) {
let seekPos = calculateLineHeadPosition(ev, this.$refs[this.refId])
this.$emit('changeLineHead', seekPos)
document.addEventListener('mousemove', this.onMouseMove)
document.addEventListener('mouseup', this.onMouseUp)
},
onMouseUp (ev) {
document.removeEventListener('mouseup', this.onMouseUp)
document.removeEventListener('mousemove', this.onMouseMove)
let seekPos = calculateLineHeadPosition(ev, this.$refs[this.refId])
this.$emit('changeLineHead', seekPos)
},
onMouseMove (ev) {
let seekPos = calculateLineHeadPosition(ev, this.$refs[this.refId])
this.$emit('changeLineHead', seekPos)
}
},
computed: {
percentageWidth () {
let width = this.percentage < 1 ? this.percentage * 100 : this.percentage
return `${width}%`
}
}
}
</script>

View File

@@ -0,0 +1,227 @@
<style lang="scss">
.ar-player {
width: 100%;
height: 120px;
border: 1px solid #E8E8E8;
border-radius: 24px;
background-color: #FAFAFA;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
&-actions {
width: 55%;
display: flex;
align-items: center;
justify-content: space-around;
}
&-volume {
display: flex;
align-items: center;
line-height: 10px;
&-bar {
width: 50px;
height: 8px;
background: #E6E6E6;
border-radius: 4px;
position: relative;
}
&__icon {
fill: #747474;
width: 24px;
height: 24px;
border: 0;
border-radius: 0;
padding: 0;
background-color: unset;
margin-right: 3px;
}
}
&-bar {
display: flex;
align-items: center;
margin-bottom: 2px;
}
&--active {
background-color: white;
}
&__progress {
width: 160px;
height: 8px;
border-radius: 5px;
background-color: #E6E6E6;
margin: 0 8px;
}
&__time {
color: rgba(84,84,84,0.5);
font-size: 16px;
width: 41px;
}
}
</style>
<template>
<div class="ar-player" :class="{'ar-player--active': record.url}">
<div class="ar-player-bar">
<div class="ar-player__time">{{playedTime}}</div>
<line-control
class="ar-player__progress"
ref-id="progress"
:percentage="progress"
@changeLineHead="_onUpdateProgress"/>
<div class="ar-player__time">{{duration}}</div>
<div class="ar-player-volume">
<icon-button class="ar-player-volume__icon" name="volume"/>
<line-control
class="ar-player-volume-bar"
ref-id="volume"
:percentage="volume"
@changeLineHead="_onUpdateVolume"/>
</div>
</div>
<div class="ar-player-actions">
<icon-button name="download" @click.native="download"/>
<icon-button
size="lg"
:name="playBtnIcon"
:class="{'ar-icon-button--clicked': isPlaying}"
@click.native="playback"/>
<icon-button name="save" @click.native="upload"/>
</div>
<audio id="audio-recorder-player" :src="record.url"></audio>
</div>
</template>
<script>
import IconButton from './icon-button.vue'
import LineControl from './line-control.vue'
import { convertTimeMMSS } from '../lib/utils.js'
export default {
props: {
uploadUrl: { type: String },
record: { type: Object },
startUpload: { type: Function },
successfulUpload: { type: Function },
failedUpload: { type: Function }
},
data () {
return {
isPlaying: false,
duration: convertTimeMMSS(0),
playedTime: convertTimeMMSS(0),
progress: 0,
volume: 0.8
}
},
components: {
IconButton,
LineControl
},
mounted: function() {
this.player = document.getElementById('audio-recorder-player')
this.player.addEventListener('ended', () => {
this.isPlaying = false
})
this.player.addEventListener('loadeddata', (ev) => {
this._resetProgress()
this.duration = convertTimeMMSS(this.player.duration)
})
this.player.addEventListener('timeupdate', this._onTimeUpdate)
},
computed: {
playBtnIcon () {
return this.isPlaying ? 'pause' : 'play'
}
},
methods: {
playback () {
if (!this.record.url) {
return
}
if (this.isPlaying) {
this.player.pause()
} else {
setTimeout(() => { this.player.play() }, 0)
}
this.isPlaying = !this.isPlaying
},
upload () {
if (!this.record.url) {
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.record.url) {
return
}
let link = document.createElement('a')
link.href = this.record.url
link.download = 'record.wav'
link.click()
},
_resetProgress () {
this.isPlaying = false
this.progress = 0
},
_onTimeUpdate () {
this.playedTime = convertTimeMMSS(this.player.currentTime)
this.progress = (this.player.currentTime / this.player.duration) * 100
},
_onUpdateProgress (pos) {
if (pos) {
this.player.currentTime = pos * this.player.duration
}
},
_onUpdateVolume (val) {
if (val) {
this.player.volume = val
this.volume = val
}
}
}
}
</script>

13
src/index.js Normal file
View File

@@ -0,0 +1,13 @@
import AudioRecorder from './audio-recorder.vue'
export default {
install: function (Vue) {
if (this.installed) {
return
}
this.installed = true
Vue.component('audio-recorder', AudioRecorder)
}
}

110
src/lib/recorder.js Normal file
View File

@@ -0,0 +1,110 @@
import WavEncoder from './wav-encoder'
import { convertTimeMMSS } from './utils'
export default class {
constructor (options = {}) {
this.afterStop = options.afterStop
this.micFailed = options.micFailed
this.bufferSize = 4096
this.records = []
this.samples = []
this.isPause = false
this.isRecording = false
this.duration = 0
this.volume = 0
this._duration = 0
}
start () {
navigator.mediaDevices.getUserMedia({audio: true})
.then(this._micCaptured.bind(this))
.catch(this._micError.bind(this))
this.isPause = false
this.isRecording = true
}
stop () {
this.stream.getTracks().forEach((track) => track.stop())
this.input.disconnect()
this.processor.disconnect()
this.context.close()
let encoder = new WavEncoder({
bufferSize: this.bufferSize,
sampleRate: this.context.sampleRate,
samples: this.samples
})
let audioBlob = encoder.getData()
let audioUrl = URL.createObjectURL(audioBlob)
this.samples = []
this.records.push({
blob: audioBlob,
url: audioUrl,
duration: convertTimeMMSS(this.duration)
})
this.isPause = false
this.isRecording = false
this._duration = 0
this.duration = 0
if (this.afterStop) {
this.afterStop()
}
}
pause () {
this.stream.getTracks().forEach((track) => track.stop())
this.input.disconnect()
this.processor.disconnect()
this.context.close()
this._duration = this.duration
this.isPause = true
}
recordList () {
return this.records
}
lastRecord () {
return this.records.slice(-1)
}
_micCaptured (stream) {
this.context = new AudioContext()
this.input = this.context.createMediaStreamSource(stream)
this.processor = this.context.createScriptProcessor(this.bufferSize, 1, 1)
this.duration = this._duration
this.stream = stream
this.processor.onaudioprocess = (ev) => {
let sample = ev.inputBuffer.getChannelData(0)
let sum = 0.0
for (let i = 0; i < sample.length; ++i) {
sum += sample[i] * sample[i]
}
this.duration = parseFloat(this._duration) + parseFloat(this.context.currentTime.toFixed(2))
this.volume = Math.sqrt(sum / sample.length).toFixed(2)
this.samples.push(new Float32Array(sample))
}
this.input.connect(this.processor)
this.processor.connect(this.context.destination)
}
_micError (error) {
if (this.micFailed) {
this.micFailed(error)
}
}
}

22
src/lib/utils.js Normal file
View File

@@ -0,0 +1,22 @@
export function calculateLineHeadPosition (ev, element) {
let progressWidth = element.getBoundingClientRect().width
let leftPosition = ev.target.getBoundingClientRect().left
let pos = (ev.clientX - leftPosition) / progressWidth
try {
if (!ev.target.className.match(/^ar\-line\-control/)) {
return
}
} catch (err) {
return
}
pos = pos < 0 ? 0 : pos
pos = pos > 1 ? 1 : pos
return pos
}
export function convertTimeMMSS (seconds) {
return new Date(seconds * 1000).toISOString().substr(14, 5)
}

59
src/lib/wav-encoder.js Normal file
View File

@@ -0,0 +1,59 @@
export default class {
constructor (options) {
this.bufferSize = options.bufferSize || 4096
this.sampleRate = options.sampleRate
this.samples = options.samples
}
getData () {
this._joinSamples()
let buffer = new ArrayBuffer(44 + this.samples.length * 2)
let view = new DataView(buffer)
this._writeString(view, 0, 'RIFF') // RIFF identifier
view.setUint32(4, 36 + this.samples.length * 2, true) // RIFF chunk length
this._writeString(view, 8, 'WAVE') // RIFF type
this._writeString(view, 12, 'fmt ') // format chunk identifier
view.setUint32(16, 16, true) // format chunk length
view.setUint16(20, 1, true) // sample format (raw)
view.setUint16(22, 1, true) // channel count
view.setUint32(24, this.sampleRate, true) // sample rate
view.setUint32(28, this.sampleRate * 4, true) // byte rate (sample rate * block align)
view.setUint16(32, 4, true) // block align (channel count * bytes per sample)
view.setUint16(34, 16, true) // bits per sample
this._writeString(view, 36, 'data') // data chunk identifier
view.setUint32(40, this.samples.length * 2, true) // data chunk length
this._floatTo16BitPCM(view, 44, this.samples)
return new Blob([view], {type: 'audio/wav'})
}
_floatTo16BitPCM (output, offset, input) {
for (let i = 0; i < input.length; i++, offset += 2) {
let s = Math.max(-1, Math.min(1, input[i]))
output.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true)
}
}
_joinSamples () {
let recordLength = this.samples.length * this.bufferSize
let joinedSamples = new Float64Array(recordLength)
let offset = 0
for (let i = 0; i < this.samples.length; i++) {
let sample = this.samples[i]
joinedSamples.set(sample, offset)
offset += sample.length
}
this.samples = joinedSamples
}
_writeString (view, offset, string) {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i))
}
}
}