mirror of
https://github.com/parabirb/freeremote-client.git
synced 2025-06-15 21:12:30 -04:00
328 lines
8.8 KiB
JavaScript
328 lines
8.8 KiB
JavaScript
"use strict";
|
|
// Code courtesy of Symbl, see https://github.com/symblai/opus-encdec for license
|
|
|
|
var AudioContext = globalThis.AudioContext || globalThis.webkitAudioContext;
|
|
|
|
|
|
// Constructor
|
|
var Recorder = function( config = {} ){
|
|
|
|
if ( !Recorder.isRecordingSupported() ) {
|
|
throw new Error("Recording is not supported in this browser");
|
|
}
|
|
|
|
this.state = "inactive";
|
|
|
|
this.config = Object.assign({
|
|
bufferLength: 4096,
|
|
encoderApplication: 2049,
|
|
encoderFrameSize: 20,
|
|
encoderPath: 'encoderWorker.js',
|
|
encoderSampleRate: 48000,
|
|
maxFramesPerPage: 40,
|
|
mediaTrackConstraints: true,
|
|
monitorGain: 0,
|
|
numberOfChannels: 1,
|
|
recordingGain: 1,
|
|
resampleQuality: 3,
|
|
streamPages: false,
|
|
wavBitDepth: 16,
|
|
sourceNode: { context: null },
|
|
}, config );
|
|
|
|
this.encodedSamplePosition = 0;
|
|
this.initAudioContext();
|
|
this.initialize = this.initWorklet().then(() => this.initEncoder());
|
|
};
|
|
|
|
|
|
// Static Methods
|
|
Recorder.isRecordingSupported = function(){
|
|
const getUserMediaSupported = globalThis.navigator && globalThis.navigator.mediaDevices && globalThis.navigator.mediaDevices.getUserMedia;
|
|
return AudioContext && getUserMediaSupported && globalThis.WebAssembly;
|
|
};
|
|
|
|
Recorder.version = '0.1.1';
|
|
|
|
|
|
// Instance Methods
|
|
Recorder.prototype.clearStream = function(){
|
|
if ( this.stream ){
|
|
|
|
if ( this.stream.getTracks ) {
|
|
this.stream.getTracks().forEach(track => track.stop());
|
|
}
|
|
|
|
else {
|
|
this.stream.stop();
|
|
}
|
|
}
|
|
};
|
|
|
|
Recorder.prototype.close = function() {
|
|
this.monitorGainNode.disconnect();
|
|
this.recordingGainNode.disconnect();
|
|
|
|
if (this.sourceNode) {
|
|
this.sourceNode.disconnect();
|
|
}
|
|
|
|
this.clearStream();
|
|
|
|
if (this.encoder) {
|
|
this.encoderNode.disconnect();
|
|
this.encoder.postMessage({ command: "close" });
|
|
}
|
|
|
|
if ( !this.config.sourceNode.context ){
|
|
return this.audioContext.close();
|
|
}
|
|
|
|
return Promise.resolve();
|
|
}
|
|
|
|
Recorder.prototype.encodeBuffers = function( inputBuffer ){
|
|
if ( this.state === "recording" ) {
|
|
var buffers = [];
|
|
for ( var i = 0; i < inputBuffer.numberOfChannels; i++ ) {
|
|
buffers[i] = inputBuffer.getChannelData(i);
|
|
}
|
|
|
|
this.encoder.postMessage({
|
|
command: "encode",
|
|
buffers: buffers
|
|
});
|
|
}
|
|
};
|
|
|
|
Recorder.prototype.initAudioContext = function(){
|
|
this.audioContext = this.config.sourceNode.context ? this.config.sourceNode.context : new AudioContext();
|
|
|
|
this.monitorGainNode = this.audioContext.createGain();
|
|
this.setMonitorGain( this.config.monitorGain );
|
|
|
|
this.recordingGainNode = this.audioContext.createGain();
|
|
this.setRecordingGain( this.config.recordingGain );
|
|
};
|
|
|
|
Recorder.prototype.initEncoder = function() {
|
|
console.log('audioWorklet support not detected. Falling back to scriptProcessor');
|
|
|
|
// Skip the first buffer
|
|
this.encodeBuffers = () => delete this.encodeBuffers;
|
|
|
|
this.encoderNode = this.audioContext.createScriptProcessor( this.config.bufferLength, this.config.numberOfChannels, this.config.numberOfChannels );
|
|
this.encoderNode.onaudioprocess = ({ inputBuffer }) => this.encodeBuffers( inputBuffer );
|
|
this.encoderNode.connect( this.audioContext.destination ); // Requires connection to destination to process audio
|
|
this.encoder = new globalThis.Worker(this.config.encoderPath);
|
|
};
|
|
|
|
Recorder.prototype.initSourceNode = function(){
|
|
if ( this.config.sourceNode.context ) {
|
|
this.sourceNode = this.config.sourceNode;
|
|
return Promise.resolve();
|
|
}
|
|
|
|
return globalThis.navigator.mediaDevices.getUserMedia({ audio : this.config.mediaTrackConstraints }).then( stream => {
|
|
this.stream = stream;
|
|
this.sourceNode = this.audioContext.createMediaStreamSource( stream );
|
|
});
|
|
};
|
|
|
|
Recorder.prototype.initWorker = function(){
|
|
var onPage = (this.config.streamPages ? this.streamPage : this.storePage).bind(this);
|
|
|
|
this.recordedPages = [];
|
|
this.totalLength = 0;
|
|
|
|
return new Promise(resolve => {
|
|
var callback = ({ data }) => {
|
|
switch( data['message'] ){
|
|
case 'ready':
|
|
resolve();
|
|
break;
|
|
case 'page':
|
|
this.encodedSamplePosition = data['samplePosition'];
|
|
onPage(data['page']);
|
|
break;
|
|
case 'done':
|
|
this.encoder.removeEventListener( "message", callback );
|
|
this.finish();
|
|
break;
|
|
default:
|
|
if (data["page"]) {
|
|
onPage(data["page"]);
|
|
}
|
|
}
|
|
};
|
|
|
|
this.encoder.addEventListener( "message", callback );
|
|
|
|
// must call start for messagePort messages
|
|
if( this.encoder.start ) {
|
|
this.encoder.start()
|
|
}
|
|
|
|
// exclude sourceNode
|
|
const {sourceNode, ...config} = this.config;
|
|
|
|
this.encoder.postMessage( Object.assign({
|
|
command: 'init',
|
|
originalSampleRate: this.audioContext.sampleRate,
|
|
wavSampleRate: this.audioContext.sampleRate
|
|
}, config));
|
|
});
|
|
};
|
|
|
|
Recorder.prototype.initWorklet = function() {
|
|
if (this.audioContext.audioWorklet) {
|
|
return this.audioContext.audioWorklet.addModule(this.config.encoderPath);
|
|
}
|
|
|
|
return Promise.resolve();
|
|
}
|
|
|
|
Recorder.prototype.pause = function( flush ) {
|
|
if ( this.state === "recording" ) {
|
|
|
|
this.state = "paused";
|
|
this.recordingGainNode.disconnect();
|
|
|
|
if ( flush && this.config.streamPages ) {
|
|
return new Promise(resolve => {
|
|
|
|
var callback = ({ data }) => {
|
|
if ( data["message"] === 'flushed' ) {
|
|
this.encoder.removeEventListener( "message", callback );
|
|
this.onpause();
|
|
resolve();
|
|
}
|
|
};
|
|
this.encoder.addEventListener( "message", callback );
|
|
|
|
// must call start for messagePort messages
|
|
if ( this.encoder.start ) {
|
|
this.encoder.start()
|
|
}
|
|
|
|
this.encoder.postMessage( { command: "flush" } );
|
|
});
|
|
}
|
|
this.onpause();
|
|
return Promise.resolve();
|
|
}
|
|
};
|
|
|
|
Recorder.prototype.resume = function() {
|
|
if ( this.state === "paused" ) {
|
|
this.state = "recording";
|
|
this.recordingGainNode.connect(this.encoderNode);
|
|
this.onresume();
|
|
}
|
|
};
|
|
|
|
Recorder.prototype.setRecordingGain = function( gain ){
|
|
this.config.recordingGain = gain;
|
|
|
|
if ( this.recordingGainNode && this.audioContext ) {
|
|
this.recordingGainNode.gain.setTargetAtTime(gain, this.audioContext.currentTime, 0.01);
|
|
}
|
|
};
|
|
|
|
Recorder.prototype.setMonitorGain = function( gain ){
|
|
this.config.monitorGain = gain;
|
|
|
|
if ( this.monitorGainNode && this.audioContext ) {
|
|
this.monitorGainNode.gain.setTargetAtTime(gain, this.audioContext.currentTime, 0.01);
|
|
}
|
|
};
|
|
|
|
Recorder.prototype.start = function(){
|
|
if ( this.state === "inactive" ) {
|
|
this.state = 'loading';
|
|
this.encodedSamplePosition = 0;
|
|
|
|
return this.audioContext.resume()
|
|
.then(() => this.initialize)
|
|
.then(() => Promise.all([this.initSourceNode(), this.initWorker()]))
|
|
.then(() => {
|
|
this.state = "recording";
|
|
this.encoder.postMessage({ command: 'getHeaderPages' });
|
|
this.sourceNode.connect( this.monitorGainNode );
|
|
this.sourceNode.connect( this.recordingGainNode );
|
|
this.monitorGainNode.connect( this.audioContext.destination );
|
|
this.recordingGainNode.connect( this.encoderNode );
|
|
this.onstart();
|
|
})
|
|
.catch(error => {
|
|
this.state = 'inactive';
|
|
throw error;
|
|
});
|
|
}
|
|
return Promise.resolve();
|
|
};
|
|
|
|
Recorder.prototype.stop = function(){
|
|
if ( this.state === "paused" || this.state === "recording" ) {
|
|
this.state = "inactive";
|
|
|
|
// macOS and iOS requires the source to remain connected (in case stopped while paused)
|
|
this.recordingGainNode.connect( this.encoderNode );
|
|
|
|
this.monitorGainNode.disconnect();
|
|
this.clearStream();
|
|
|
|
return new Promise(resolve => {
|
|
var callback = ({ data }) => {
|
|
if ( data["message"] === 'done' ) {
|
|
this.encoder.removeEventListener( "message", callback );
|
|
resolve();
|
|
}
|
|
};
|
|
|
|
this.encoder.addEventListener( "message", callback );
|
|
|
|
// must call start for messagePort messages
|
|
if( this.encoder.start ) {
|
|
this.encoder.start()
|
|
}
|
|
|
|
this.encoder.postMessage({ command: "done" });
|
|
});
|
|
}
|
|
return Promise.resolve();
|
|
};
|
|
|
|
Recorder.prototype.storePage = function( page ) {
|
|
this.recordedPages.push( page );
|
|
this.totalLength += page.length;
|
|
};
|
|
|
|
Recorder.prototype.streamPage = function( page ) {
|
|
this.ondataavailable( page );
|
|
};
|
|
|
|
Recorder.prototype.finish = function() {
|
|
if( !this.config.streamPages ) {
|
|
var outputData = new Uint8Array( this.totalLength );
|
|
this.recordedPages.reduce( function( offset, page ){
|
|
outputData.set( page, offset );
|
|
return offset + page.length;
|
|
}, 0);
|
|
|
|
this.ondataavailable( outputData );
|
|
}
|
|
this.onstop();
|
|
};
|
|
|
|
|
|
// Callback Handlers
|
|
Recorder.prototype.ondataavailable = function(){};
|
|
Recorder.prototype.onpause = function(){};
|
|
Recorder.prototype.onresume = function(){};
|
|
Recorder.prototype.onstart = function(){};
|
|
Recorder.prototype.onstop = function(){};
|
|
|
|
|
|
window.Recorder = Recorder;
|