Voice detection + bugfixes

This commit is contained in:
Lucas Verney 2021-03-04 22:44:05 +01:00
parent 7265f36d4a
commit 43c67d8401
9 changed files with 2062 additions and 153 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules
bundle.js

View File

@ -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.
``` ```

BIN
end.mp3 Normal file

Binary file not shown.

View File

@ -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> </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 mdi-play
</v-icon> </v-icon>
</v-btn> </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
View 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

File diff suppressed because it is too large Load Diff

22
package.json Normal file
View 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"
}
}

BIN
tick.mp3 Normal file

Binary file not shown.

129
vad.js Normal file
View 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};
};