Voice detection + bugfixes
This commit is contained in:
parent
7265f36d4a
commit
43c67d8401
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
node_modules
|
||||||
|
bundle.js
|
11
README.md
11
README.md
@ -5,11 +5,20 @@ A simple timer for extracting expressos.
|
|||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
Just open `index.html` with your browser.
|
```
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, just open `index.html` with your browser.
|
||||||
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
Tick sound from https://soundbible.com/2044-Tick.html.
|
||||||
|
|
||||||
|
End sound from https://soundbible.com/1630-Computer-Magic.html.
|
||||||
|
|
||||||
Code published under an MIT license.
|
Code published under an MIT license.
|
||||||
|
|
||||||
```
|
```
|
||||||
|
227
index.html
227
index.html
@ -3,7 +3,6 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>Espresso timer</title>
|
<title>Espresso timer</title>
|
||||||
<script src="https://polyfill.io/v3/polyfill.min.js?features=default"></script>
|
|
||||||
<link href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900" rel="stylesheet">
|
||||||
<link href="https://cdn.jsdelivr.net/npm/@mdi/font@4.x/css/materialdesignicons.min.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/@mdi/font@4.x/css/materialdesignicons.min.css" rel="stylesheet">
|
||||||
<link href="https://cdn.jsdelivr.net/npm/vuetify@2.x/dist/vuetify.min.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/vuetify@2.x/dist/vuetify.min.css" rel="stylesheet">
|
||||||
@ -44,7 +43,7 @@
|
|||||||
v-model="preinfusionTimer"
|
v-model="preinfusionTimer"
|
||||||
thumb-color="primary"
|
thumb-color="primary"
|
||||||
thumb-label="always"
|
thumb-label="always"
|
||||||
:readonly="interval !== null"
|
:readonly="hasTimed || interval"
|
||||||
@change="storeValue('preinfusionTimer')"
|
@change="storeValue('preinfusionTimer')"
|
||||||
min="0"
|
min="0"
|
||||||
max="10"
|
max="10"
|
||||||
@ -61,12 +60,15 @@
|
|||||||
v-model="totalTimer"
|
v-model="totalTimer"
|
||||||
thumb-color="primary"
|
thumb-color="primary"
|
||||||
thumb-label="always"
|
thumb-label="always"
|
||||||
:readonly="interval !== null"
|
:readonly="hasTimed || interval"
|
||||||
@change="storeValue('totalTimer')"
|
@change="storeValue('totalTimer')"
|
||||||
min="0"
|
:min="preinfusionTimer"
|
||||||
max="40"
|
max="40"
|
||||||
></v-slider>
|
></v-slider>
|
||||||
</v-col>
|
</v-col>
|
||||||
|
<v-col cols="2" v-if="extraTotalTime > 0">
|
||||||
|
+ {{ extraTotalTime }}s
|
||||||
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
|
|
||||||
<v-row>
|
<v-row>
|
||||||
@ -78,17 +80,61 @@
|
|||||||
large
|
large
|
||||||
color="primary"
|
color="primary"
|
||||||
@click="buttonFunction"
|
@click="buttonFunction"
|
||||||
|
v-if="interval || voiceDetector || hasTimed"
|
||||||
>
|
>
|
||||||
<v-icon dark v-if="interval">
|
<v-icon dark v-if="interval || voiceDetector">
|
||||||
mdi-stop
|
mdi-stop
|
||||||
</v-icon>
|
</v-icon>
|
||||||
<v-icon dark v-else-if="hasTimed">
|
<v-icon dark v-else-if="hasTimed">
|
||||||
mdi-replay
|
mdi-replay
|
||||||
</v-icon>
|
</v-icon>
|
||||||
<v-icon dark v-else>
|
|
||||||
mdi-play
|
|
||||||
</v-icon>
|
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
<v-dialog
|
||||||
|
v-else
|
||||||
|
v-model="dialogAudio"
|
||||||
|
persistent
|
||||||
|
max-width="290"
|
||||||
|
>
|
||||||
|
<template v-slot:activator="{ on, attrs }">
|
||||||
|
<v-btn
|
||||||
|
class="mx-4"
|
||||||
|
fab
|
||||||
|
dark
|
||||||
|
large
|
||||||
|
color="primary"
|
||||||
|
v-bind="attrs"
|
||||||
|
v-on="on"
|
||||||
|
>
|
||||||
|
<v-icon dark>
|
||||||
|
mdi-play
|
||||||
|
</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<v-card>
|
||||||
|
<v-card-title class="headline">
|
||||||
|
Use audio detection?
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text>Trigger start/stop timer through voice level detection.</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
<v-btn
|
||||||
|
color="green darken-1"
|
||||||
|
text
|
||||||
|
@click="noAudio"
|
||||||
|
>
|
||||||
|
No
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
color="green darken-1"
|
||||||
|
text
|
||||||
|
@click="useAudio"
|
||||||
|
>
|
||||||
|
Yes
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col cols="12" class="text-center" v-if="!interval && hasTimed">
|
<v-col cols="12" class="text-center" v-if="!interval && hasTimed">
|
||||||
<v-simple-table class="text-center">
|
<v-simple-table class="text-center">
|
||||||
@ -136,156 +182,33 @@
|
|||||||
<v-divider class="my-8"></v-divider>
|
<v-divider class="my-8"></v-divider>
|
||||||
|
|
||||||
<h2 class="text-h6 text-center">History</h2>
|
<h2 class="text-h6 text-center">History</h2>
|
||||||
<v-simple-table class="text-center" fixed-header v-if="history.length > 0">
|
<v-dialog v-model="dialogDelete" max-width="290">
|
||||||
<template v-slot:default>
|
<v-card>
|
||||||
<thead>
|
<v-card-title class="headline">Are you sure you want to delete this item?</v-card-title>
|
||||||
<tr>
|
<v-card-actions>
|
||||||
<th>
|
<v-spacer></v-spacer>
|
||||||
Grind level
|
<v-btn color="blue darken-1" text @click="closeDelete">Cancel</v-btn>
|
||||||
</th>
|
<v-btn color="blue darken-1" text @click="deleteItemConfirm">OK</v-btn>
|
||||||
<th>
|
<v-spacer></v-spacer>
|
||||||
Pre-infusion
|
</v-card-actions>
|
||||||
</th>
|
</v-card>
|
||||||
<th>
|
</v-dialog>
|
||||||
Total time
|
<v-data-table :headers=historyHeaders :items="history">
|
||||||
</th>
|
<template v-slot:item.actions="{ item }">
|
||||||
</tr>
|
<v-icon
|
||||||
</thead>
|
small
|
||||||
<tbody>
|
@click="deleteItem(item)"
|
||||||
<tr v-for="point in history">
|
>
|
||||||
<td>{{ point.grindLevel }}</td>
|
mdi-delete
|
||||||
<td>{{ point.preinfusionTime }}</td>
|
</v-icon>
|
||||||
<td>{{ point.totalTime }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</template>
|
</template>
|
||||||
</v-simple-table>
|
</v-data-table>
|
||||||
<p class="text-center" v-else>/</p>
|
|
||||||
</v-container>
|
</v-container>
|
||||||
</v-main>
|
</v-main>
|
||||||
</v-app>
|
</v-app>
|
||||||
</div>
|
</div>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/vue@2.x/dist/vue.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/vue@2.x/dist/vue.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/vuetify@2.x/dist/vuetify.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/vuetify@2.x/dist/vuetify.js"></script>
|
||||||
<script type="text/javascript">
|
<script src="./bundle.js"></script>
|
||||||
// TODO: PWA
|
|
||||||
var ONE_SECOND = 1000;
|
|
||||||
|
|
||||||
function requestMic(handleSuccess, handleError) {
|
|
||||||
try {
|
|
||||||
window.AudioContext = window.AudioContext || window.webkitAudioContext;
|
|
||||||
audioContext = new AudioContext();
|
|
||||||
|
|
||||||
navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.msGetUserMedia;
|
|
||||||
navigator.getUserMedia({audio: true}, handleSuccess, handleError);
|
|
||||||
} catch (e) {
|
|
||||||
handleError();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var app = new Vue({
|
|
||||||
el: '#app',
|
|
||||||
vuetify: new Vuetify(),
|
|
||||||
data: {
|
|
||||||
//audioBeep: new Audio('audio_file.mp3'),
|
|
||||||
//audioEnd: new Audio('audio_file.mp3'),
|
|
||||||
hasTimed: false,
|
|
||||||
interval: null,
|
|
||||||
grindLevel: window.localStorage.getItem('grindLevel') || 5,
|
|
||||||
initialPreinfusionTimer: null,
|
|
||||||
preinfusionTimer: window.localStorage.getItem('preinfusionTimer') || 4,
|
|
||||||
initialTotalTimer: null,
|
|
||||||
totalTimer: window.localStorage.getItem('totalTimer') || 25,
|
|
||||||
extraTotalTime: 0,
|
|
||||||
history: window.localStorage.getItem('history') || [],
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
buttonFunction() {
|
|
||||||
if (this.interval) {
|
|
||||||
return this.stopTimer();
|
|
||||||
}
|
|
||||||
else if (this.hasTimed) {
|
|
||||||
return this.resetFunction();
|
|
||||||
}
|
|
||||||
return this.startTimer();
|
|
||||||
},
|
|
||||||
resetFunction() {
|
|
||||||
this.stopTimer();
|
|
||||||
this.hasTimed = false;
|
|
||||||
|
|
||||||
this.extraTotalTime = 0;
|
|
||||||
if (this.initialPreinfusionTimer) {
|
|
||||||
this.preinfusionTimer = this.initialPreinfusionTimer;
|
|
||||||
}
|
|
||||||
if (this.initialTotalTimer) {
|
|
||||||
this.totalTimer = this.initialTotalTimer;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
saveFunction() {
|
|
||||||
this.history.push({
|
|
||||||
grindLevel: this.grindLevel,
|
|
||||||
preinfusionTime: this.initialPreinfusionTimer - this.preinfusionTimer,
|
|
||||||
totalTime: this.initialTotalTimer - this.totalTimer + this.extraTotalTime,
|
|
||||||
});
|
|
||||||
this.storeValue('history');
|
|
||||||
this.resetFunction();
|
|
||||||
},
|
|
||||||
startTimer() {
|
|
||||||
var handleSuccess = (stream) => {
|
|
||||||
var options = {
|
|
||||||
onVoiceStart: function() {
|
|
||||||
console.log('voice start');
|
|
||||||
},
|
|
||||||
onVoiceStop: function() {
|
|
||||||
console.log('voice stop');
|
|
||||||
},
|
|
||||||
onUpdate: function(val) {
|
|
||||||
console.log('curr val:', val);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// TODO: vad(audioContext, stream, options);
|
|
||||||
};
|
|
||||||
requestMic(
|
|
||||||
handleSuccess,
|
|
||||||
() => {
|
|
||||||
console.warn('Could not connect microphone. Possibly rejected by the user or blocked by the browser.');
|
|
||||||
|
|
||||||
console.log('Starting timer without voice detection.')
|
|
||||||
this.hasTimed = true;
|
|
||||||
|
|
||||||
this.initialPreinfusionTimer = this.preinfusionTimer;
|
|
||||||
this.initialTotalTimer = this.totalTimer;
|
|
||||||
|
|
||||||
this.interval = window.setInterval(() => {
|
|
||||||
// TODO: this.audioBeep.play();
|
|
||||||
if (this.preinfusionTimer > 0) {
|
|
||||||
this.preinfusionTimer -= 1;
|
|
||||||
} else {
|
|
||||||
if (this.totalTimer > 0) {
|
|
||||||
this.totalTimer -= 1;
|
|
||||||
} else {
|
|
||||||
if (this.totalTimer == 0) {
|
|
||||||
// TODO: this.audioEnd.play();
|
|
||||||
}
|
|
||||||
this.extraTotalTime += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, ONE_SECOND);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
},
|
|
||||||
stopTimer() {
|
|
||||||
console.log('Timer stopped.')
|
|
||||||
if (this.interval) {
|
|
||||||
window.clearInterval(this.interval);
|
|
||||||
}
|
|
||||||
this.interval = null;
|
|
||||||
},
|
|
||||||
storeValue(item) {
|
|
||||||
window.localStorage.setItem(item, this[item]);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
191
index.js
Normal file
191
index.js
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
var vad = require('./vad.js');
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
// TODO: PWA
|
||||||
|
var ONE_SECOND = 1000;
|
||||||
|
var AUDIO_THRESHOLD = 0.5;
|
||||||
|
|
||||||
|
function requestMic(handleSuccess, handleError) {
|
||||||
|
try {
|
||||||
|
window.AudioContext = window.AudioContext || window.webkitAudioContext;
|
||||||
|
audioContext = new AudioContext();
|
||||||
|
|
||||||
|
navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.msGetUserMedia;
|
||||||
|
navigator.getUserMedia({audio: true}, handleSuccess, handleError);
|
||||||
|
} catch (e) {
|
||||||
|
handleError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var app = new Vue({
|
||||||
|
el: '#app',
|
||||||
|
vuetify: new Vuetify(),
|
||||||
|
data: {
|
||||||
|
audioTick: new Audio('./tick.mp3?cache-buster=' + Date.now()),
|
||||||
|
audioEnd: new Audio('./end.mp3?cache-buster=' + Date.now()),
|
||||||
|
dialogAudio: false,
|
||||||
|
dialogDelete: false,
|
||||||
|
hasTimed: false,
|
||||||
|
interval: null,
|
||||||
|
grindLevel: window.localStorage.getItem('grindLevel') || 5,
|
||||||
|
initialPreinfusionTimer: null,
|
||||||
|
preinfusionTimer: window.localStorage.getItem('preinfusionTimer') || 4,
|
||||||
|
initialTotalTimer: null,
|
||||||
|
totalTimer: window.localStorage.getItem('totalTimer') || 25,
|
||||||
|
extraTotalTime: 0,
|
||||||
|
shouldUseAudio: false,
|
||||||
|
voiceDetector: null,
|
||||||
|
voiceActivityStartDate: new Date(0),
|
||||||
|
history: JSON.parse(window.localStorage.getItem('history')) || [],
|
||||||
|
historyHeaders: [
|
||||||
|
{
|
||||||
|
text: 'Grind level',
|
||||||
|
value: 'grindLevel',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Pre-infusion time',
|
||||||
|
value: 'preinfusionTime',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Total time',
|
||||||
|
value: 'totalTime',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Actions',
|
||||||
|
value: 'actions',
|
||||||
|
sortable: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
buttonFunction() {
|
||||||
|
if (this.interval || this.voiceDetector) {
|
||||||
|
return this.stopTimer();
|
||||||
|
}
|
||||||
|
else if (this.hasTimed) {
|
||||||
|
return this.resetFunction();
|
||||||
|
}
|
||||||
|
return this.startTimer();
|
||||||
|
},
|
||||||
|
resetFunction() {
|
||||||
|
this.stopTimer();
|
||||||
|
this.hasTimed = false;
|
||||||
|
|
||||||
|
this.extraTotalTime = 0;
|
||||||
|
if (this.initialPreinfusionTimer) {
|
||||||
|
this.preinfusionTimer = this.initialPreinfusionTimer;
|
||||||
|
}
|
||||||
|
if (this.initialTotalTimer) {
|
||||||
|
this.totalTimer = this.initialTotalTimer;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
saveFunction() {
|
||||||
|
this.history.push({
|
||||||
|
grindLevel: this.grindLevel,
|
||||||
|
preinfusionTime: this.initialPreinfusionTimer - this.preinfusionTimer,
|
||||||
|
totalTime: this.initialTotalTimer - this.totalTimer + this.extraTotalTime,
|
||||||
|
});
|
||||||
|
this.storeValue('history');
|
||||||
|
this.resetFunction();
|
||||||
|
},
|
||||||
|
noAudio() {
|
||||||
|
this.shouldUseAudio = false;
|
||||||
|
this.dialogAudio = false;
|
||||||
|
this.startTimer();
|
||||||
|
},
|
||||||
|
useAudio() {
|
||||||
|
this.shouldUseAudio = true;
|
||||||
|
this.dialogAudio = false;
|
||||||
|
this.startTimer();
|
||||||
|
},
|
||||||
|
startTimer() {
|
||||||
|
let doTimer = () => {
|
||||||
|
this.hasTimed = true;
|
||||||
|
|
||||||
|
this.initialPreinfusionTimer = this.preinfusionTimer;
|
||||||
|
this.initialTotalTimer = this.totalTimer;
|
||||||
|
|
||||||
|
this.interval = window.setInterval(() => {
|
||||||
|
if (this.preinfusionTimer > 0) {
|
||||||
|
this.preinfusionTimer -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let previousTotalTimer = this.totalTimer;
|
||||||
|
if (this.totalTimer > 0) {
|
||||||
|
this.totalTimer -= 1;
|
||||||
|
} else {
|
||||||
|
this.extraTotalTime += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previousTotalTimer > 0 && this.totalTimer == 0) {
|
||||||
|
this.audioEnd.play();
|
||||||
|
} else {
|
||||||
|
this.audioTick.play();
|
||||||
|
}
|
||||||
|
}, ONE_SECOND);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.shouldUseAudio) {
|
||||||
|
console.log('Starting timer with voice detection.')
|
||||||
|
|
||||||
|
var handleSuccess = (stream) => {
|
||||||
|
var options = {
|
||||||
|
onUpdate: (val) => {
|
||||||
|
if (
|
||||||
|
val > AUDIO_THRESHOLD
|
||||||
|
&& (new Date()) - this.voiceActivityStartDate > ONE_SECOND
|
||||||
|
) {
|
||||||
|
this.voiceActivityStartDate = new Date();
|
||||||
|
if (this.hasTimed) {
|
||||||
|
console.log(`Stopping timer due to voice (value: ${val}).`)
|
||||||
|
this.stopTimer();
|
||||||
|
} else {
|
||||||
|
console.log(`Starting timer due to voice (value: ${val}).`)
|
||||||
|
doTimer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.voiceDetector = vad(audioContext, stream, options);
|
||||||
|
};
|
||||||
|
requestMic(
|
||||||
|
handleSuccess,
|
||||||
|
() => window.alert('Unable to access microphone...')
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log('Starting timer directly.')
|
||||||
|
doTimer();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
stopTimer() {
|
||||||
|
console.log('Timer stopped.')
|
||||||
|
if (this.interval) {
|
||||||
|
window.clearInterval(this.interval);
|
||||||
|
}
|
||||||
|
if (this.voiceDetector) {
|
||||||
|
this.voiceDetector.destroy();
|
||||||
|
this.voiceDetector = null;
|
||||||
|
}
|
||||||
|
this.interval = null;
|
||||||
|
},
|
||||||
|
deleteItem(item) {
|
||||||
|
this.deletedIndex = this.history.indexOf(item);
|
||||||
|
this.dialogDelete = true;
|
||||||
|
},
|
||||||
|
closeDelete() {
|
||||||
|
this.dialogDelete = false;
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.deletedIndex = -1;
|
||||||
|
})
|
||||||
|
},
|
||||||
|
deleteItemConfirm () {
|
||||||
|
this.history.splice(this.deletedIndex, 1);
|
||||||
|
this.storeValue('history');
|
||||||
|
this.closeDelete();
|
||||||
|
},
|
||||||
|
storeValue(item) {
|
||||||
|
window.localStorage.setItem(item, JSON.stringify(this[item]));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})();
|
1633
package-lock.json
generated
Normal file
1633
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
package.json
Normal file
22
package.json
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "expresso-timer",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Expresso timer ==============",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "rimraf bundle.js && browserify index.js > bundle.js"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "gitea@git.phyks.me:phyks/expresso-timer.git"
|
||||||
|
},
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"analyser-frequency-average": "^1.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"browserify": "^17.0.0",
|
||||||
|
"rimraf": "^3.0.2"
|
||||||
|
}
|
||||||
|
}
|
129
vad.js
Normal file
129
vad.js
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
'use strict';
|
||||||
|
// From https://github.com/johni0702/voice-activity-detection
|
||||||
|
var analyserFrequency = require('analyser-frequency-average');
|
||||||
|
|
||||||
|
module.exports = function(audioContext, stream, opts) {
|
||||||
|
|
||||||
|
opts = opts || {};
|
||||||
|
|
||||||
|
var defaults = {
|
||||||
|
fftSize: 1024,
|
||||||
|
bufferLen: 1024,
|
||||||
|
smoothingTimeConstant: 0.2,
|
||||||
|
minCaptureFreq: 85, // in Hz
|
||||||
|
maxCaptureFreq: 255, // in Hz
|
||||||
|
noiseCaptureDuration: 1000, // in ms
|
||||||
|
minNoiseLevel: 0.3, // from 0 to 1
|
||||||
|
maxNoiseLevel: 0.7, // from 0 to 1
|
||||||
|
avgNoiseMultiplier: 1.2,
|
||||||
|
onVoiceStart: function() {
|
||||||
|
},
|
||||||
|
onVoiceStop: function() {
|
||||||
|
},
|
||||||
|
onUpdate: function(val) {
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var options = {};
|
||||||
|
for (var key in defaults) {
|
||||||
|
options[key] = opts.hasOwnProperty(key) ? opts[key] : defaults[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
var baseLevel = 0;
|
||||||
|
var voiceScale = 1;
|
||||||
|
var activityCounter = 0;
|
||||||
|
var activityCounterMin = 0;
|
||||||
|
var activityCounterMax = 60;
|
||||||
|
var activityCounterThresh = 5;
|
||||||
|
|
||||||
|
var envFreqRange = [];
|
||||||
|
var isNoiseCapturing = true;
|
||||||
|
var prevVadState = undefined;
|
||||||
|
var vadState = false;
|
||||||
|
var captureTimeout = null;
|
||||||
|
|
||||||
|
var source = audioContext.createMediaStreamSource(stream);
|
||||||
|
var analyser = audioContext.createAnalyser();
|
||||||
|
analyser.smoothingTimeConstant = options.smoothingTimeConstant;
|
||||||
|
analyser.fftSize = options.fftSize;
|
||||||
|
|
||||||
|
var scriptProcessorNode = audioContext.createScriptProcessor(options.bufferLen, 1, 1);
|
||||||
|
connect();
|
||||||
|
scriptProcessorNode.onaudioprocess = monitor;
|
||||||
|
|
||||||
|
if (isNoiseCapturing) {
|
||||||
|
//console.log('VAD: start noise capturing');
|
||||||
|
captureTimeout = setTimeout(init, options.noiseCaptureDuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
//console.log('VAD: stop noise capturing');
|
||||||
|
isNoiseCapturing = false;
|
||||||
|
|
||||||
|
envFreqRange = envFreqRange.filter(function(val) {
|
||||||
|
return val;
|
||||||
|
}).sort();
|
||||||
|
var averageEnvFreq = envFreqRange.length ? envFreqRange.reduce(function (p, c) { return Math.min(p, c) }, 1) : (options.minNoiseLevel || 0.1);
|
||||||
|
|
||||||
|
baseLevel = averageEnvFreq * options.avgNoiseMultiplier;
|
||||||
|
if (options.minNoiseLevel && baseLevel < options.minNoiseLevel) baseLevel = options.minNoiseLevel;
|
||||||
|
if (options.maxNoiseLevel && baseLevel > options.maxNoiseLevel) baseLevel = options.maxNoiseLevel;
|
||||||
|
|
||||||
|
voiceScale = 1 - baseLevel;
|
||||||
|
|
||||||
|
//console.log('VAD: base level:', baseLevel);
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
source.connect(analyser);
|
||||||
|
analyser.connect(scriptProcessorNode);
|
||||||
|
scriptProcessorNode.connect(audioContext.destination);
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnect() {
|
||||||
|
scriptProcessorNode.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroy() {
|
||||||
|
captureTimeout && clearTimeout(captureTimeout);
|
||||||
|
disconnect();
|
||||||
|
analyser.disconnect();
|
||||||
|
source.disconnect();
|
||||||
|
source.mediaStream.getTracks().forEach(track => track.stop());
|
||||||
|
}
|
||||||
|
|
||||||
|
function monitor() {
|
||||||
|
var frequencies = new Uint8Array(analyser.frequencyBinCount);
|
||||||
|
analyser.getByteFrequencyData(frequencies);
|
||||||
|
|
||||||
|
var average = analyserFrequency(analyser, frequencies, options.minCaptureFreq, options.maxCaptureFreq);
|
||||||
|
if (isNoiseCapturing) {
|
||||||
|
envFreqRange.push(average);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (average >= baseLevel && activityCounter < activityCounterMax) {
|
||||||
|
activityCounter++;
|
||||||
|
} else if (average < baseLevel && activityCounter > activityCounterMin) {
|
||||||
|
activityCounter--;
|
||||||
|
}
|
||||||
|
vadState = activityCounter > activityCounterThresh;
|
||||||
|
|
||||||
|
if (prevVadState !== vadState) {
|
||||||
|
vadState ? onVoiceStart() : onVoiceStop();
|
||||||
|
prevVadState = vadState;
|
||||||
|
}
|
||||||
|
|
||||||
|
options.onUpdate(Math.max(0, average - baseLevel) / voiceScale);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onVoiceStart() {
|
||||||
|
options.onVoiceStart();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onVoiceStop() {
|
||||||
|
options.onVoiceStop();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {connect: connect, disconnect: disconnect, destroy: destroy};
|
||||||
|
};
|
Loading…
Reference in New Issue
Block a user