How to save MediaRecorder Web API output to disk using a stream
Asked Answered
L

2

6

I am experimenting with the MediaStream Recording API within Electron (therefore Node.js) and wish to handle the output as a stream. Handling as a stream would allow me to process the MediaRecorder output before saving to disk - I could encrypt it, for example. For my specific use case I am just concerned with audio, so I do not have any video elements recording.

My most basic use case is to simply save the output to disk using a stream, but I cannot seem to achieve this fundamental task, so I will focus this question on achieving this.

Question: How to save MediaRecorder Web API output to disk using a stream.

I can save a file to disk using a download “hack”, provided and described as such by Google here, and successfully use node.js fs to open, transform (encrypt), save a new encrypted file, and delete the unencrypted file. This means that I ultimately have to save unencrypted data to disk. Even if for a short amount of time, this feels like a security compromise that I thought would be easy to avoid by encrypting before saving.

There is a risk I am getting quite a few wires crossed between different stream objects, but I am surprised I have not found a solution online yet - therefore I am popping my StackOverflow question cherry.

A project highlighting all I have tried is below. The key code in is record.js, in the save() function.

Ultimately, I am trying to create a suitable readStream to plug into the writeStream created with const writeStream = fs.createWriteStream(fPath); using readStream.pipe(writeStream).

In summary, I have tried the following:

1. Blob to readStream

I cannot convert Blob into readStream, only ReadableStream , ReadableStreamDefaultReader or Uint8Array

2. Blob to file (in memory) and then use fs.createReadStream()

I cannot seem to use an ObjectURL in fs.createReadStream(url), it insists on appending a local Path. The answer to this question suggests this is a limitation of fs.createReadStream() and using http.get() or request() is not suitable in my case because I am not trying to access a remote resource.

3. Blob to buffer and then use fs.createReadStream()

I cannot convert Blob to a buffer that can be used in fs.createReadStream(buffer), only an arrayBuffer or one with null bytes

Any help is greatly appreciated!


Project:

Node 12.13.0, Chrome 80.0.3987.158, and Electron 8.2.0.

Set-up:

  • the four files: main.js, package.json, index.html, record.js are all single level in the project folder.

Contents of each file:

package.json:

{
  "name": "mediarecorderapi",
  "version": "1.0.0",
  "description": "",
  "main": "main.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "start": "electron ."
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "electron": "^8.2.0"
  }
}

main.js:

const { app, BrowserWindow, ipcMain } = require('electron');

function createWindow () {
  // Create the browser window.
  let win = new BrowserWindow({
    width: 1000,
    height: 800,
    x:0,
    y:0,
    title: "Media Recorder Example",
    webPreferences: {
      nodeIntegration: true,
      devTools: true
    } 
  })
  win.openDevTools();
  win.loadFile('index.html')
}

app.whenReady().then(createWindow)

index.html:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>Hello World!</title>
    <!-- https://electronjs.org/docs/tutorial/security#csp-meta-tag -->
    <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline';" />
  </head>
  <body>
    <h1>Hello World!</h1>
    We are using node <script>document.write(process.versions.node)</script>,
    Chrome <script>document.write(process.versions.chrome)</script>,
    and Electron <script>document.write(process.versions.electron)</script>.
    <br/><br/>
    <div>
      <button id="button_rec">Record</button>
      <p>recorder state: <span id="rec_status">inactive</span></p>
    </div>
  </body>

  <script src="record.js"></script>

</html>

record.js:

console.log("hello world from record.js()");

const remote = require('electron').remote;
const path = require('path');
const fs = require('fs');
const appDir = remote.app.getPath('userData');

var recButton = document.getElementById("button_rec");
var recStatusSpan = document.getElementById("rec_status");
var recorder;

init = async function () {
    // html page event handlers:
    recButton.addEventListener("click", () => {record()});

    // SET UP MEDIA RECORDER:
    var audioStream = await navigator.mediaDevices.getUserMedia({audio: true});
    recorder = new MediaRecorder(audioStream, {mimeType: 'audio/webm'});
    chunks = [];
    recorder.onstart = (event) => {
        // ...
    }
    recorder.ondataavailable = (event) => {       
        chunks.push(event.data);
    }
    recorder.onstop = async (event) => {
        let fileName = `audiofile_${Date.now().toString()}.webm`;
        // download(chunks, fileName); // <== This works at downloading the file to disk, but this is not a stream. Use to prove that audio is being recorded and that it can be saved.
        save(chunks, fileName);     // <== Trying to save using a stream 
        chunks = [];
    }
}

record = function() {
    if(recorder.state == "inactive"){
        recorder.start();
        recButton.innerHTML = "Stop Recording";
    } else {
        recorder.stop();
        recButton.innerHTML = "Record";
    }
    recStatusSpan.innerHTML = recorder.state;
}

download = function (audioToSave, fName) {  
    let audioBlob = new Blob(audioToSave, {
      type: "audio/webm"
    });
    let url = URL.createObjectURL(audioBlob);
    let a = document.createElement("a");
    a.style = "display: none";
    a.href = url;
    document.body.appendChild(a);
    a.download = fName;
    a.click();

    // release / remove
    window.URL.revokeObjectURL(url);
    document.body.removeChild(a);
}

save = async function (audioToSave, fName){
    let fPath = path.join(appDir, fName);
    console.log(`Tring to save to: ${fPath}`);

    // create the writeStream - this line creates the 0kb file, ready to be written to
    const writeStream = fs.createWriteStream(`${fPath}`);
    console.log(writeStream); // :) WriteStream {...}

    // The following lines are ultimately trying to get to a suitable readStream to pipe into the writeStream using readStream.pipe(writeStream):
    // Multiple attempts written out - uncomment the method you are trying...

    // The incoming data 'audioToSave' is an array containing a single blob of data.
    console.log(audioToSave); // [Blob]
    
    // ================
    // METHOD 1: Stream a Blob:
    // Issue: I cannot find a method to convert a Blob to a "readStream"
    // ================

    // Lets convert the data to a Blob
    var audioBlob = new Blob(audioToSave, {
        type: "audio/webm"
    });
    console.log(audioBlob); // Blob {size: 9876, type: "audio/webm"}
    // And lets convert the Blob to a Stream
    var audioBlobReadableStream  = audioBlob.stream();  // https://developer.mozilla.org/en-US/docs/Web/API/Blob/stream
    console.log(audioBlobReadableStream ); // ReadableStream {locked: false}
    // audioBlobReadableStream.pipe(writeStream);       // ERROR: Uncaught (in promise) TypeError: audioBlobReadableStream .pipe is not a function
    // audioBlobReadableStream.pipeTo(writeStream);     // ERROR: TypeError: Failed to execute 'pipeTo' on 'audioBlobReadableStream': Illegal invocation

    // converting the ReadableStream into a ReadableStreamDefaultReader:
    var audioBlobReadableStreamDefaultReader  = await audioBlobReadableStream.getReader();
    console.log(audioBlobReadableStreamDefaultReader) // ReadableStreamDefaultReader {closed: Promise}
    // audioBlobReadableStreamDefaultReader.pipe(writeStream);      // ERROR: TypeError: audioBlobReadableStreamDefaultReader.pipe is not a function
    // audioBlobReadableStreamDefaultReader.pipeTo(writeStream);    // ERROR: TypeError: audioBlobReadableStreamDefaultReader.pipeTo is not a function

    // And read the reader:
    var audioBlobReadStream = await audioBlobReadableStreamDefaultReader.read();
    console.log(audioBlobReadStream); // {value: Uint8Array(9876), done: false}
    // audioBlobReadStream.pipe(writeStream);       // ERROR: TypeError: audioBlobReadStream.pipe is not a function
    // audioBlobReadStream.pipeTo(writeStream);     // ERROR: TypeError: audioBlobReadStream.pipeTo is not a function

    // ================
    // METHOD 2: Blob to file, use fs
    // Note, fs.createReadStream() requires a string, Buffer, or URL
    // Issue: I cannot convert a Blob to a file i can access with fs without downloading it
    // ================
    // // Or convert to a file (to try to help fs.read)
    var audioFile = new File([audioBlob], "audioFileName", { type: 'audio/webm' });
    console.log(audioFile); // File {...}

    // ====
    // a: url
    // Issue: fs.createReadStream(url) adds a local path to the objectURL created, and this local path obviously doesn't exist
    // ====
    var url = URL.createObjectURL(audioFile);   
    console.log(url); // blob:file:///{GUID}
    const fileReadStream = fs.createReadStream(url); // ERROR: events.js:187  ENOENT: no such file or directory, open 'C:\... [Local Path] ...\blob:file:\19428f7d-768a-4eff-b551-4068daa8ceb6'
    console.log(fileReadStream); // ReadStream {... path: "blob:file:///{GUID}" ...}
    // fileReadStream.pipe(writeStream); 
    
    // ====
    // b: buffer
    // Issue: I cannot convert a blob to a buffer that I can insert into fs.createReadStream(buffer)
    // ====
    var audioArrayBuffer = await audioBlob.arrayBuffer();
    console.log(audioArrayBuffer); // ArrayBuffer(9876)
    // bufferReadStream = fs.createReadStream(audioArrayBuffer); // ERROR: TypeError [ERR_INVALID_ARG_TYPE]: The "path" argument must be one of type string, Buffer, or URL. Received type object 
    let audioBuffer = toBuffer(audioArrayBuffer)
    console.log(audioBuffer);
    let bufferReadStream = fs.createReadStream(audioBuffer); // ERROR: TypeError [ERR_INVALID_ARG_VALUE]: The argument 'path' must be a string or Uint8Array without null bytes. Received <Buffer 1a 45 ...
    
    function toBuffer(ab) {
        // FROM: https://mcmap.net/q/117961/-convert-a-binary-nodejs-buffer-to-javascript-arraybuffer
        var buf = Buffer.alloc(ab.byteLength);
        var view = new Uint8Array(ab);
        for (var i = 0; i < buf.length; ++i) {
            buf[i] = view[i];
        }
        return buf;
    }
}


init();

Run the following:

npm install -D electron
npm start
Ligan answered 28/3, 2020 at 15:42 Comment(1)
Ultimately I did not need to create a ReadStream, just a buffer like this: let audio = audioToSave[0]; ... const audioArrayBuffer = await audio.arrayBuffer(); ... const audioArray = new Uint8Array(audioArrayBuffer); ... const audioBuffer = Buffer.from(audioArray); and tap that into: const writeStream = fs.createWriteStream(fPath); ... writeStream.write(audioBuffer); Now I can save avoiding the 'hack', not yet piping through encryption before the save...Ligan
L
8

OK, I cracked it… Ultimately, the crux of the challenge was:

how to convert blob into readablestream in node.js.

Anyway, in summary the steps I found to work are: blob > arrayBuffer > array > buffer > readStream

I needed the following function to convert a buffer to a stream. Reference and Node.js docs:

let { Readable } = require('stream') ;

function bufferToStream(buffer) {
    let stream = new Readable ();
    stream.push(buffer);
    stream.push(null);
    return stream;
}

The rest of the conversion steps are one-liners and the full save function is here:

save = async function (audioToSave, fPath) {
    console.log(`Trying to save to: ${fPath}`);

    // create the writeStream - this line creates the 0kb file, ready to be written to
    const writeStream = fs.createWriteStream(fPath);
    console.log(writeStream); // WriteStream {...}

    // The incoming data 'audioToSave' is an array containing a single blob of data.
    console.log(audioToSave); // [Blob]

    // Lets convert the data to a Blob
    var audioBlob = new Blob(audioToSave, {
        type: "audio/webm"
    });
    console.log(audioBlob); // Blob {size: 17955, type: "audio/webm"}
    // note: audioBlob = audio[0] has same effect

    // now we go through the following process: blob > arrayBuffer > array > buffer > readStream:
    const arrayBuffer = await audioBlob.arrayBuffer();
    console.log(arrayBuffer); // ArrayBuffer(17955) {}

    const array = new Uint8Array(arrayBuffer);
    console.log(array); // Uint8Array(17955) [26, 69, ... ]

    const buffer = Buffer.from(array);
    console.log(buffer); // Buffer(17955) [26, 69, ... ]

    let readStream = bufferToStream(buffer);
    console.log(readStream); // Readable {_readableState: ReadableState, readable: true, ... }

    // and now we can pipe:
    readStream.pipe(writeStream);

}

And I can finally pipe and can continue using other stream functions between the data and the save, for example, encryption. :)

Hope this helps someone else too.

Ligan answered 15/4, 2020 at 10:27 Comment(0)
W
2

Alternatively, you can simplify your approach a bit, since Buffer.from() works directly on the array buffer and you can use Readable.from(buffer) to convert a buffer into a ReadableStream.

import { Blob } from 'buffer';
import fs from 'fs';
import { Readable } from 'stream';

const writeStream = fs.createWriteStream(filePath);

// chunks is an array of blobs; you get one of those blobs
// from the `MediaRecorder.ondataavailable` event.data
const chunks = [audioblob1, audioblob2, ...];
const audio = new Blob(chunks, { type: "audio/webm" });
const buffer = Buffer.from(await audio.arrayBuffer());
const readStream = Readable.from(buffer);

readStream.pipe(writeStream).on('finish', () => {
   console.log('🎵 audio saved');
});
Wernick answered 30/4, 2022 at 1:29 Comment(1)
in this case, why we do not use writeStream.write(Buffer.from(await audio.arrayBuffer())); seems this one is working for me as wellCasillas

© 2022 - 2024 — McMap. All rights reserved.