зеркало из https://github.com/mozilla/murmur.git
initial commit
This commit is contained in:
Родитель
d87fe54b25
Коммит
9339e7c43d
|
@ -31,3 +31,7 @@ node_modules
|
|||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Don't check in the config file or uploads dir
|
||||
sentences.txt
|
||||
uploads
|
||||
|
|
13
README.md
13
README.md
|
@ -1,2 +1,13 @@
|
|||
# speecher
|
||||
A webapp for collecting speech samples for voice recognition testing and training
|
||||
This is a simple webapp for collecting speech samples for voice
|
||||
recognition testing and training.
|
||||
|
||||
Running it should be as simple as issuing these commands on your
|
||||
server:
|
||||
|
||||
```
|
||||
> git clone git@github.com:mozilla/speecher.git
|
||||
> cd speecher
|
||||
> npm install
|
||||
> node speecher.js
|
||||
```
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"name": "speecher",
|
||||
"version": "1.0.0",
|
||||
"description": "collect recorded speech samples from users",
|
||||
"repository" : {
|
||||
"type" : "git",
|
||||
"url" : "https://github.com/mozilla/speecher"
|
||||
}
|
||||
"main": "speecher.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"author": "David Flanagan",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"body-parser": "^1.15.0",
|
||||
"express": "^4.13.4"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,105 @@
|
|||
//
|
||||
// This is a simple class for recording mono audio from a getUserMedia()
|
||||
// microphone stream and converting it to a WAV-format blob. To use it, get a
|
||||
// microphone stream with getUserMedia or, then pass that stream to the
|
||||
// AudioRecorder() constructor. To start recording call the start method. To
|
||||
// stop recording, call the stop() method. The stop method returns a blob in
|
||||
// WAV format. All the audio data is held in memory, in uncompressed form, and
|
||||
// requires about 192kb of memory for each second of audio, so this class is
|
||||
// not suitable for long recordings.
|
||||
//
|
||||
// By default, audio is collected in batches of 1024 samples (at about 40
|
||||
// batches per second, though this depends on the platform's sampling rate).
|
||||
// You can change the batch size by passing a different value as the optional
|
||||
// second argument to the constructor. Note, however, that the batch size must
|
||||
// be a power of two. If you set the onbatch property of an audiorecorder
|
||||
// object then each batch (a Float32Array) will be passed to that function
|
||||
// when it is collected.
|
||||
//
|
||||
// This code was inspired by, but simplified from this blog post
|
||||
// http://typedarray.org/from-microphone-to-wav-with-getusermedia-and-web-audio/
|
||||
//
|
||||
(function(exports) {
|
||||
'use strict';
|
||||
|
||||
function AudioRecorder(microphone, batchSize) {
|
||||
this.context = new AudioContext();
|
||||
this.source = this.context.createMediaStreamSource(microphone);
|
||||
this.batchSize = batchSize || 1024;
|
||||
// In Firefox we don't need the one output channel, but we need
|
||||
// it for Chrome, even though it is unused.
|
||||
this.processor = this.context.createScriptProcessor(this.batchSize, 1, 1);
|
||||
this.batches = []; // batches of sample data from the script processor
|
||||
|
||||
// Each time we get a batch of data, this function will be called
|
||||
// We just copy the typed array and save it. We end up with a long
|
||||
// array of typed arrays.
|
||||
this.processor.addEventListener('audioprocess', function(e) {
|
||||
var data = e.inputBuffer.getChannelData(0);
|
||||
var copy = new Float32Array(data);
|
||||
this.batches.push(copy);
|
||||
if (this.onbatch) { // If the user has defined a callback, call it
|
||||
this.onbatch(copy);
|
||||
}
|
||||
}.bind(this));
|
||||
}
|
||||
|
||||
// The microphone is live the entire time. To start recording we
|
||||
// connect the microphone stream to the processor node.
|
||||
AudioRecorder.prototype.start = function() {
|
||||
this.source.connect(this.processor);
|
||||
// For Chrome we also have to connect the processor to the
|
||||
// destination even though the processor does not produce any output
|
||||
this.processor.connect(this.context.destination);
|
||||
};
|
||||
|
||||
// To stop recording, disconnect the microphone.
|
||||
// Then take the data we stored and convert to a WAV format blob
|
||||
AudioRecorder.prototype.stop = function() {
|
||||
this.source.disconnect();
|
||||
this.processor.disconnect();
|
||||
var batches = this.batches;
|
||||
this.batches = [];
|
||||
return makeWAVBlob(batches, this.batchSize, this.context.sampleRate);
|
||||
};
|
||||
|
||||
// Convert the sound samples we've collected into a WAV file
|
||||
function makeWAVBlob(batches, batchSize, sampleRate) {
|
||||
var numSamples = batches.length * batchSize;
|
||||
// 44 byte WAV header plus two bytes per sample
|
||||
var blobSize = numSamples * 2 + 44;
|
||||
var bytes = new ArrayBuffer(blobSize);
|
||||
var view = new DataView(bytes);
|
||||
|
||||
// Create WAV file header
|
||||
view.setUint32(0, 0x46464952, true); // 'RIFF'
|
||||
view.setUint32(4, blobSize - 8, true); // Size of rest of file
|
||||
view.setUint32(8, 0x45564157, true); // 'WAVE'
|
||||
view.setUint32(12, 0x20746d66, true); // 'fmt '
|
||||
view.setUint32(16, 16, true); // 16 bytes of fmt view
|
||||
view.setUint16(20, 1, true); // Audio is in PCM format
|
||||
view.setUint16(22, 1, true); // One-channel (mono)
|
||||
view.setUint32(24, sampleRate, true); // Samples per second
|
||||
view.setUint32(28, 2*sampleRate, true); // Bytes per second
|
||||
view.setUint16(32, 2, true); // Block size
|
||||
view.setUint16(34, 16, true); // Bits per sample
|
||||
view.setUint32(36, 0x61746164, true); // 'data'
|
||||
view.setUint32(40, numSamples*2, true); // How many data bytes
|
||||
|
||||
// Copy the samples to the file now
|
||||
var offset = 44;
|
||||
for(var i = 0; i < batches.length; i++) {
|
||||
var batch = batches[i];
|
||||
for(var j = 0; j < batch.length; j++) {
|
||||
var floatSample = batch[j];
|
||||
var intSample = floatSample * 0x7FFF; // convert to 16-bit signed int
|
||||
view.setInt16(offset, intSample, true);
|
||||
offset += 2;
|
||||
}
|
||||
}
|
||||
|
||||
return new Blob([bytes], { type: 'audio/wav' });
|
||||
}
|
||||
|
||||
exports.AudioRecorder = AudioRecorder;
|
||||
}(window));
|
|
@ -0,0 +1,124 @@
|
|||
<html>
|
||||
<head>
|
||||
<!-- XXX: do I need a viewport meta tag here? -->
|
||||
<script defer src="audiorecorder.js"></script>
|
||||
<script defer src="index.js"></script>
|
||||
<style>
|
||||
body {
|
||||
font-size: 18px;
|
||||
font-family: sans-serif
|
||||
}
|
||||
|
||||
.screen p {
|
||||
margin: 8px;
|
||||
}
|
||||
|
||||
#consent-screen button {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
|
||||
#error-message {
|
||||
margin: 15px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
#instructions {
|
||||
margin: 10px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
#sentence {
|
||||
font-size: larger;
|
||||
font-weight: bold;
|
||||
margin: 20px;
|
||||
}
|
||||
|
||||
#canvas {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
}
|
||||
#canvas.disabled {
|
||||
background: center center no-repeat url(record.png);
|
||||
opacity: 0.5;
|
||||
}
|
||||
#canvas.stopped {
|
||||
background: center center no-repeat url(record.png);
|
||||
}
|
||||
#canvas.recording {
|
||||
background: center center no-repeat url(stop.png);
|
||||
}
|
||||
|
||||
#player {
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
#playback-screen button {
|
||||
font-size: 30px;
|
||||
margin: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
#playback-screen button span.small {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="consent-screen" class="screen" hidden>
|
||||
<p>
|
||||
This website is used by Mozilla engineering to collect speech
|
||||
samples to test and train our speech recognition engine. It
|
||||
collects only speech recordings and does not associate them with
|
||||
any personally identifying information.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
By clicking the "I agree" button below, you are agreeing to
|
||||
donate audio recordings of your voice and to
|
||||
<a href="https://creativecommons.org/publicdomain/zero/1.0/">
|
||||
place them in the public domain</a>. This means that you agree
|
||||
to <em>waive all rights to the recordings worldwide under copyright
|
||||
and database law, including moral and publicity rights and all
|
||||
related and neighboring rights</em>.
|
||||
</p>
|
||||
|
||||
<button id="disagree">I Disagree</button>
|
||||
<button id="agree">I Agree</button>
|
||||
</div>
|
||||
|
||||
<div id="record-screen" class="screen" hidden>
|
||||
<div id="instructions">
|
||||
Tap the microphone and read this sentence after the beep:
|
||||
</div>
|
||||
<div id="sentence"></div>
|
||||
<canvas id="canvas" class="stopped" width=300 height=300></canvas>
|
||||
</div>
|
||||
|
||||
<div id="playback-screen" class="screen" hidden>
|
||||
<audio id="player" controls autoplay></audio><br/>
|
||||
<button id="upload">
|
||||
Upload audio<br/>
|
||||
<span class="small">and place it in the public domain</span>
|
||||
</button>
|
||||
<br/>
|
||||
<button id="discard">
|
||||
Discard audio<br/>
|
||||
<span class="small">and record another sentence</span>
|
||||
</button>
|
||||
<br/>
|
||||
</div>
|
||||
|
||||
<div id="error-screen" class="screen" hidden>
|
||||
<p>
|
||||
This application cannot run because:
|
||||
</p>
|
||||
|
||||
<p id="error-message"><p>
|
||||
|
||||
<p>Reload if you'd like to try again</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,355 @@
|
|||
// The microphone stream we get from getUserMedia
|
||||
var microphone;
|
||||
|
||||
// The sentences we want the user to read and their corresponding
|
||||
// server-side directories that we upload them to. We fetch these
|
||||
// from the server. See getSentences() and parseSentences().
|
||||
var sentences = [], directories = [];
|
||||
|
||||
// The sentence we're currently recording, and its directory.
|
||||
// These are picked at random in recordingScreen.show()
|
||||
var currentSentence, currentDirectory;
|
||||
|
||||
// These are configurable constants:
|
||||
var SILENCE_THRESHOLD = 0.1; // How quiet does it have to be to stop recording?
|
||||
var SILENCE_DURATION = 1500; // For how many milliseconds?
|
||||
var LOUD_THRESHOLD = 0.75; // How loud shows as red in the levels
|
||||
var BATCHSIZE = 2048; // How many samples per recorded batch
|
||||
var RECORD_BEEP_HZ = 800; // Frequency and duration of beeps
|
||||
var RECORD_BEEP_MS = 200;
|
||||
var STOP_BEEP_HZ = 400;
|
||||
var STOP_BEEP_MS = 300;
|
||||
|
||||
// These are some things that can go wrong:
|
||||
var ERR_NO_CONSENT = 'You did not consent to recording. ' +
|
||||
'You must click the "I Agree" button in order to use this website.';
|
||||
var ERR_NO_GUM = 'Your browser does not support audio recording. ' +
|
||||
'Try using a recent version of Firefox or Chrome.';
|
||||
var ERR_NO_MIC = 'You did allow this website to use the microphone. ' +
|
||||
'The website needs the microphone to record your voice.';
|
||||
var ERR_UPLOAD_FAILED = 'Uploading your recording to the server failed. ' +
|
||||
'This may be a temporary problem. Please reload and try again.';
|
||||
|
||||
// This is the program startup sequence.
|
||||
getConsent()
|
||||
.then(getMicrophone)
|
||||
.then(rememberMicrophone)
|
||||
.then(getSentences)
|
||||
.then(parseSentences)
|
||||
.then(initializeAndRun)
|
||||
.catch(displayErrorMessage);
|
||||
|
||||
// Ask the user to agree to place the recordings in the public domain.
|
||||
// They only have to agree once, and we remember using localStorage
|
||||
function getConsent() {
|
||||
return new Promise(function(resolve, reject) {
|
||||
// If the user has already consented, then we're done
|
||||
if (localStorage.consentGiven) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
// Otherwise, display the consent screen and wait for a response
|
||||
var consentScreen = document.querySelector('#consent-screen');
|
||||
consentScreen.hidden = false;
|
||||
document.querySelector('#agree').onclick = function() {
|
||||
localStorage.consentGiven = true; // Remember this consent
|
||||
consentScreen.hidden = true;
|
||||
resolve();
|
||||
};
|
||||
document.querySelector('#disagree').onclick = function() {
|
||||
consentScreen.hidden = true;
|
||||
reject(ERR_NO_CONSENT);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Use getUserMedia() to get access to the user's microphone.
|
||||
// This can fail because the browser does not support it, or
|
||||
// because the user does not give permission.
|
||||
function getMicrophone() {
|
||||
return new Promise(function(resolve,reject) {
|
||||
// Reject the promise with a 'permission denied' error code
|
||||
function deny() { reject(ERR_NO_MIC); }
|
||||
|
||||
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
|
||||
navigator.mediaDevices.getUserMedia({audio: true}).then(resolve, deny);
|
||||
}
|
||||
else if (navigator.getUserMedia) {
|
||||
navigator.getUserMedia({audio:true}, resolve, deny);
|
||||
}
|
||||
else if (navigator.webkitGetUserMedia) {
|
||||
navigator.webkitGetUserMedia({audio:true}, resolve, deny);
|
||||
}
|
||||
else if (navigator.mozGetUserMedia) {
|
||||
navigator.mozGetUserMedia({audio:true}, resolve, deny);
|
||||
}
|
||||
else {
|
||||
reject(ERR_NO_GUM); // Browser does not support getUserMedia
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// When we get the microphone audio stream, remember it in a global variable.
|
||||
function rememberMicrophone(stream) {
|
||||
microphone = stream;
|
||||
}
|
||||
|
||||
// Fetch the sentences.json file that tell us what sentences
|
||||
// to ask the user to read
|
||||
function getSentences() {
|
||||
return fetch('sentences.json').then(function(r) { return r.json(); });
|
||||
}
|
||||
|
||||
// Once we get the json file, break the keys and values into two
|
||||
// parallel arrays.
|
||||
function parseSentences(directoryToSentenceMap) {
|
||||
for(var d in directoryToSentenceMap) {
|
||||
directories.push(d);
|
||||
sentences.push(directoryToSentenceMap[d]);
|
||||
}
|
||||
}
|
||||
|
||||
// If anything goes wrong in the app startup sequence, this function
|
||||
// is called to tell the user what went wrong
|
||||
function displayErrorMessage(error) {
|
||||
document.querySelector('#consent-screen').hidden = true;
|
||||
document.querySelector('#error-screen').hidden = false;
|
||||
document.querySelector('#error-message').textContent = error;
|
||||
}
|
||||
|
||||
// Once the async initialization is complete, this is where the
|
||||
// program really starts. It initializes the recording and playback
|
||||
// screens, and sets up event handlers to switch back and forth between
|
||||
// those screens until the user gets tired of making recordings.
|
||||
function initializeAndRun() {
|
||||
// Get the DOM elements for the recording and playback screens
|
||||
var recordingScreenElement = document.querySelector('#record-screen');
|
||||
var playbackScreenElement = document.querySelector('#playback-screen');
|
||||
|
||||
// Create objects that encapsulate their functionality
|
||||
// Then set up event handlers to coordinate the two screens
|
||||
var recordingScreen = new RecordingScreen(recordingScreenElement, microphone);
|
||||
var playbackScreen = new PlaybackScreen(playbackScreenElement);
|
||||
|
||||
// When a recording is complete, pass it to the playback screen
|
||||
recordingScreenElement.addEventListener('record', function(event) {
|
||||
recordingScreen.hide();
|
||||
playbackScreen.show(event.detail);
|
||||
});
|
||||
|
||||
// If the user clicks 'Upload' on the playback screen, do the upload
|
||||
// and switch back to the recording screen for a new sentence
|
||||
playbackScreenElement.addEventListener('upload', function(event) {
|
||||
upload(currentDirectory, event.detail);
|
||||
switchToRecordingScreen(true);
|
||||
});
|
||||
|
||||
// If the user clicks 'Discard', switch back to the recording screen
|
||||
// for another take of the same sentence
|
||||
playbackScreenElement.addEventListener('discard', function() {
|
||||
switchToRecordingScreen(false);
|
||||
});
|
||||
|
||||
// Here's how we switch to the recording screen
|
||||
function switchToRecordingScreen(needNewSentence) {
|
||||
// Pick a random sentence if we don't have one or need a new one
|
||||
if (needNewSentence || !currentSentence) {
|
||||
var n = Math.floor(Math.random() * sentences.length);
|
||||
currentSentence = sentences[n];
|
||||
currentDirectory = directories[n];
|
||||
}
|
||||
|
||||
// Hide the playback screen (and release its audio) if it was displayed
|
||||
// Show the recording screen
|
||||
playbackScreen.hide();
|
||||
recordingScreen.show(currentSentence);
|
||||
}
|
||||
|
||||
// Upload a recording using the fetch API to do an HTTP POST
|
||||
function upload(directory, recording) {
|
||||
fetch('/upload/' + directory, { method: 'POST', body: recording })
|
||||
.then(function(response) {
|
||||
if (response.status !== 200) {
|
||||
playbackScreen.hide();
|
||||
recordingScreen.hide();
|
||||
displayErrorMessage(ERR_UPLOAD_FAILED + ' ' + response.status + ' ' +
|
||||
response.statusText);
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
playbackScreen.hide();
|
||||
recordingScreen.hide();
|
||||
displayErrorMessage(ERR_UPLOAD_FAILED);
|
||||
});
|
||||
}
|
||||
|
||||
// Finally, we start the app off by displaying the recording screen
|
||||
switchToRecordingScreen(true);
|
||||
}
|
||||
|
||||
// The RecordingScreen object has show() and hide() methods and fires
|
||||
// a 'record' event on its DOM element when a recording has been made.
|
||||
function RecordingScreen(element, microphone) {
|
||||
this.element = element;
|
||||
|
||||
this.show = function(sentence) {
|
||||
this.element.querySelector('#sentence').textContent = sentence;
|
||||
this.element.hidden = false;
|
||||
};
|
||||
|
||||
this.hide = function() {
|
||||
this.element.hidden = true;
|
||||
};
|
||||
|
||||
// This allows us to record audio from the microphone stream.
|
||||
// See audiorecorder.js
|
||||
var recorder = new AudioRecorder(microphone, BATCHSIZE);
|
||||
|
||||
// Most of the state for this class is hidden away here in the constructor
|
||||
// and is not exposed outside of the class.
|
||||
|
||||
// The main part of the recording screen is this canvas object
|
||||
// that displays a microphone icon, acts as a recording level indicator
|
||||
// and responds to clicks to start and stop recording
|
||||
var canvas = element.querySelector('canvas');
|
||||
var context = canvas.getContext('2d');
|
||||
|
||||
var recording = false; // Are we currently recording?
|
||||
var lastSoundTime; // When was the last time we heard a sound?
|
||||
|
||||
// The canvas responds to clicks to start and stop recording
|
||||
canvas.addEventListener('click', function() {
|
||||
// Ignore clicks when we're not ready
|
||||
if (canvas.className === 'disabled')
|
||||
return;
|
||||
|
||||
if (recording) {
|
||||
stopRecording();
|
||||
}
|
||||
else {
|
||||
startRecording();
|
||||
}
|
||||
});
|
||||
|
||||
function startRecording() {
|
||||
if (!recording) {
|
||||
recording = true;
|
||||
canvas.className = 'disabled'; // disabled 'till after the beep
|
||||
beep(RECORD_BEEP_HZ, RECORD_BEEP_MS).then(function() {
|
||||
lastSoundTime = performance.now();
|
||||
recorder.start();
|
||||
canvas.className = 'recording';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function stopRecording() {
|
||||
if (recording) {
|
||||
recording = false;
|
||||
canvas.className = 'disabled'; // disabled 'till after the beep
|
||||
var blob = recorder.stop();
|
||||
// Beep to tell the user the recording is done
|
||||
beep(STOP_BEEP_HZ, STOP_BEEP_MS).then(function() {
|
||||
canvas.className = 'stopped';
|
||||
});
|
||||
// Erase the canvas
|
||||
displayLevel(0);
|
||||
// Broadcast an event containing the recorded blob
|
||||
element.dispatchEvent(new CustomEvent('record', {
|
||||
detail: blob
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// This function is called each time the recorder receives a batch of
|
||||
// audio data. We use this to display recording levels and also to
|
||||
// detect the silence that ends a recording
|
||||
recorder.onbatch = function batchHandler(batch) {
|
||||
// What's the highest amplitude for this batch? (Ignoring negative values)
|
||||
var max = batch.reduce(function(max, val) { return val > max ? val : max; },
|
||||
0.0);
|
||||
|
||||
// If we haven't heard anything in a while, it may be time to
|
||||
// stop recording
|
||||
var now = performance.now();
|
||||
if (max < SILENCE_THRESHOLD) {
|
||||
if (now - lastSoundTime > SILENCE_DURATION) {
|
||||
stopRecording();
|
||||
return;
|
||||
}
|
||||
}
|
||||
else {
|
||||
lastSoundTime = now;
|
||||
}
|
||||
|
||||
// Graphically display this recording level
|
||||
displayLevel(max);
|
||||
};
|
||||
|
||||
// A WebAudio utility to do simple beeps
|
||||
function beep(hertz, duration) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
var context = new AudioContext();
|
||||
var oscillator = context.createOscillator();
|
||||
oscillator.connect(context.destination);
|
||||
oscillator.frequency.value = hertz;
|
||||
oscillator.start();
|
||||
setTimeout(function() {
|
||||
oscillator.stop();
|
||||
oscillator.disconnect();
|
||||
context.close();
|
||||
resolve();
|
||||
}, duration);
|
||||
});
|
||||
}
|
||||
|
||||
// Graphically display the recording level
|
||||
function displayLevel(level) {
|
||||
requestAnimationFrame(function() {
|
||||
// Clear the canvas
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
// Do nothing if the level is low
|
||||
if (level < SILENCE_THRESHOLD) return;
|
||||
// Otherwise, draw a circle whose radius and color depends on volume.
|
||||
// The 100 is because we're using a microphone icon that is 95x95
|
||||
var radius = 50 + level * (canvas.width-100) / 2;
|
||||
context.lineWidth = radius/5;
|
||||
context.beginPath();
|
||||
context.arc(canvas.width/2, canvas.height/2, radius, 0, 2*Math.PI);
|
||||
context.strokeStyle = (level > LOUD_THRESHOLD) ? 'red' : 'green';
|
||||
context.stroke();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// This simple class encapsulates the playback screen. It has
|
||||
// show and hide methods, and fires 'upload' and 'discard' events
|
||||
// depending on which button is clicked.
|
||||
function PlaybackScreen(element) {
|
||||
this.element = element;
|
||||
this.player = element.querySelector('#player');
|
||||
|
||||
this.show = function(recording) {
|
||||
this.element.hidden = false;
|
||||
this.recording = recording;
|
||||
this.player.src = URL.createObjectURL(recording);
|
||||
};
|
||||
|
||||
this.hide = function() {
|
||||
this.element.hidden = true;
|
||||
this.recording = null;
|
||||
if (this.player.src) {
|
||||
URL.revokeObjectURL(this.player.src);
|
||||
delete this.player.src;
|
||||
this.player.load();
|
||||
}
|
||||
};
|
||||
|
||||
element.querySelector('#upload').addEventListener('click', function() {
|
||||
element.dispatchEvent(new CustomEvent('upload', {detail: this.recording}));
|
||||
}.bind(this));
|
||||
|
||||
element.querySelector('#discard').addEventListener('click', function() {
|
||||
element.dispatchEvent(new CustomEvent('discard'));
|
||||
});
|
||||
}
|
Двоичный файл не отображается.
После Ширина: | Высота: | Размер: 2.1 KiB |
Двоичный файл не отображается.
После Ширина: | Высота: | Размер: 2.4 KiB |
|
@ -0,0 +1,134 @@
|
|||
var fs = require('fs');
|
||||
var express = require('express');
|
||||
var bodyParser = require('body-parser');
|
||||
|
||||
var PORT = 80; // What port to listen on
|
||||
var uploaddir = __dirname + '/uploads'; // Upload directory
|
||||
var directoryToSentence = {}; // dirname to sentence
|
||||
var directoryToFileNumber = {}; // dirname to next file number to use
|
||||
var directories = []; // all the directories
|
||||
|
||||
// Here's the program:
|
||||
readConfigFile();
|
||||
startServer();
|
||||
|
||||
/*
|
||||
* Synchronous startup stuff before we start handling requests.
|
||||
* This reads the sentences.txt configuration file, creates directories
|
||||
* as needed, and figures out the next file number in each directory.
|
||||
*/
|
||||
function readConfigFile() {
|
||||
var configFile = __dirname + '/sentences.txt';
|
||||
|
||||
try {
|
||||
fs.readFileSync(configFile, 'utf8')
|
||||
.trim()
|
||||
.split('\n')
|
||||
.forEach(function(line) {
|
||||
var trimmed = line.trim();
|
||||
if (trimmed === '' || trimmed[0] === '#') {
|
||||
return; // ignore blanks and comments
|
||||
}
|
||||
var match = trimmed.match(/^(\w+)\s+(.*)$/);
|
||||
if (!match) {
|
||||
console.warn('Ignoring mis-formatted line in sentences.txt:',
|
||||
line);
|
||||
return;
|
||||
}
|
||||
var directory = match[1];
|
||||
var sentence = match[2];
|
||||
|
||||
if (directory in directoryToSentence) {
|
||||
console.warn('Ignoring line in sentences.txt because directory',
|
||||
'is already in use:', line);
|
||||
return;
|
||||
}
|
||||
|
||||
directoryToSentence[directory] = sentence;
|
||||
directories.push(directory);
|
||||
});
|
||||
}
|
||||
catch(e) {
|
||||
console.error('Error reading configuration file:', configFile,
|
||||
'\n', e);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (directories.length === 0) {
|
||||
console.error('No sentences defined in sentences.txt. Exiting.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
directories.forEach(function(directory) {
|
||||
try {
|
||||
var dirname = uploaddir + '/' + directory;
|
||||
if (fs.existsSync(dirname)) {
|
||||
// Directory exists. Go find out what the next filenumber is
|
||||
var filenumbers =
|
||||
fs.readdirSync(dirname) // all files
|
||||
.filter(function(f) { return f.match(/\d+\.wav/);}) // only .wav
|
||||
.map(function(f) { return parseInt(f); }) // to number
|
||||
.sort(function(a,b) { return b - a; }); // largest first
|
||||
directoryToFileNumber[directory] = (filenumbers[0] + 1) || 0;
|
||||
}
|
||||
else {
|
||||
// Directory does not exist. Create it and start with file 0
|
||||
fs.mkdirSync(dirname);
|
||||
directoryToFileNumber[directory] = 0;
|
||||
}
|
||||
}
|
||||
catch(e) {
|
||||
// This can happen, for example, if dirname is a file instead of
|
||||
// a directory or if there is a directory that is not readable
|
||||
console.warn('Error verifying directory', dirname,
|
||||
'Ignoring that directory', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function startServer() {
|
||||
var app = express();
|
||||
|
||||
// Serve static files in the public/ directory
|
||||
app.use(express.static('public'));
|
||||
|
||||
// When the client issues a GET request for the list of sentences
|
||||
// create that dynamically from the data we parsed from the config file
|
||||
app.get('/sentences.json', function(request, response) {
|
||||
response.send(directoryToSentence);
|
||||
});
|
||||
|
||||
// When we get POSTs, handle the body like this
|
||||
app.use(bodyParser.raw({
|
||||
type: 'audio/wav',
|
||||
limit: 2*1024*1024 // max file size 2mb
|
||||
}));
|
||||
|
||||
// This is how we handle WAV file uploads
|
||||
app.post('/upload/:dir', function(request, response) {
|
||||
var dir = request.params.dir;
|
||||
var filenumber = directoryToFileNumber[dir];
|
||||
if (filenumber !== undefined) { // Only if it is a known directory
|
||||
directoryToFileNumber[dir] = filenumber + 1;
|
||||
var filename = String(filenumber);
|
||||
while(filename.length < 4) filename = '0' + filename;
|
||||
var path = uploaddir + '/' + dir + '/' + filename + '.wav';
|
||||
fs.writeFile(path, request.body, {}, function(err) {
|
||||
response.send('Thanks for your contribution!');
|
||||
if (err) {
|
||||
console.warn(err);
|
||||
}
|
||||
else {
|
||||
console.log('wrote file:', path);
|
||||
}
|
||||
});
|
||||
}
|
||||
else {
|
||||
response.status(404).send('Bad directory');
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(PORT, function () {
|
||||
console.log('Listening on port', PORT);
|
||||
});
|
||||
}
|
Загрузка…
Ссылка в новой задаче