Skip to content

Commit 1610ab1

Browse files
committed
Optimize blob and wav file saving
No need to save the entire audio in memory
1 parent 67ff765 commit 1610ab1

File tree

5 files changed

+187
-72
lines changed

5 files changed

+187
-72
lines changed

src/utils/audio.js

Lines changed: 43 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@
99

1010
import { getFile } from './hub.js';
1111
import { FFT, max } from './maths.js';
12-
import { calculateReflectOffset, saveBlob } from './core.js';
12+
import { calculateReflectOffset } from './core.js';
13+
import { saveBlob } from './io.js';
1314
import { apis } from '../env.js';
1415
import { Tensor, matmul } from './tensor.js';
15-
import fs from 'node:fs';
1616

1717
/**
1818
* Helper function to read audio from a path/URL.
@@ -733,23 +733,24 @@ export function window_function(window_length, name, { periodic = true, frame_le
733733
}
734734

735735
/**
736-
* Encode audio data to a WAV file.
736+
* Efficiently encode audio data to a WAV file.
737737
* WAV file specs : https://en.wikipedia.org/wiki/WAV#WAV_File_header
738738
*
739739
* Adapted from https://www.npmjs.com/package/audiobuffer-to-wav
740-
* @param {Float32Array} samples The audio samples.
740+
* @param {Float32Array[]} chunks The audio samples.
741741
* @param {number} rate The sample rate.
742-
* @returns {ArrayBuffer} The WAV audio buffer.
742+
* @returns {Blob} The WAV file as a Blob.
743743
*/
744-
function encodeWAV(samples, rate) {
745-
let offset = 44;
746-
const buffer = new ArrayBuffer(offset + samples.length * 4);
744+
function encodeWAV(chunks, rate) {
745+
const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0);
746+
747+
const buffer = new ArrayBuffer(44);
747748
const view = new DataView(buffer);
748749

749750
/* RIFF identifier */
750751
writeString(view, 0, 'RIFF');
751752
/* RIFF chunk length */
752-
view.setUint32(4, 36 + samples.length * 4, true);
753+
view.setUint32(4, 36 + totalLength * 4, true);
753754
/* RIFF type */
754755
writeString(view, 8, 'WAVE');
755756
/* format chunk identifier */
@@ -771,13 +772,10 @@ function encodeWAV(samples, rate) {
771772
/* data chunk identifier */
772773
writeString(view, 36, 'data');
773774
/* data chunk length */
774-
view.setUint32(40, samples.length * 4, true);
775-
776-
for (let i = 0; i < samples.length; ++i, offset += 4) {
777-
view.setFloat32(offset, samples[i], true);
778-
}
775+
view.setUint32(40, totalLength * 4, true);
779776

780-
return buffer;
777+
// @ts-expect-error TS2322
778+
return new Blob([buffer, ...chunks], { type: "audio/wav" });
781779
}
782780

783781
function writeString(view, offset, string) {
@@ -789,7 +787,7 @@ function writeString(view, offset, string) {
789787
export class RawAudio {
790788
/**
791789
* Create a new `RawAudio` object.
792-
* @param {Float32Array} audio Audio data
790+
* @param {Float32Array|Float32Array[]} audio Audio data, either as a single `Float32Array` chunk or multiple `Float32Array` chunks.
793791
* @param {number} sampling_rate Sampling rate of the audio data
794792
*/
795793
constructor(audio, sampling_rate) {
@@ -798,44 +796,49 @@ export class RawAudio {
798796
}
799797

800798
/**
801-
* Convert the audio to a wav file buffer.
802-
* @returns {ArrayBuffer} The WAV file.
799+
* Get the audio data, accumulating all chunks if necessary.
800+
* @returns {Float32Array} The audio data.
803801
*/
804-
toWav() {
805-
return encodeWAV(this.audio, this.sampling_rate);
802+
get data() {
803+
if (Array.isArray(this.audio)) {
804+
if (this.audio.length === 0) {
805+
return new Float32Array(0);
806+
}
807+
if (this.audio.length === 1) {
808+
return this.audio[0];
809+
}
810+
// Concatenate all chunks into a single Float32Array
811+
const totalLength = this.audio.reduce((acc, chunk) => acc + chunk.length, 0);
812+
const result = new Float32Array(totalLength);
813+
let offset = 0;
814+
for (const chunk of this.audio) {
815+
result.set(chunk, offset);
816+
offset += chunk.length;
817+
}
818+
return result;
819+
} else {
820+
return this.audio;
821+
}
806822
}
807823

808824
/**
809825
* Convert the audio to a blob.
810826
* @returns {Blob}
811827
*/
812828
toBlob() {
813-
const wav = this.toWav();
814-
const blob = new Blob([wav], { type: 'audio/wav' });
815-
return blob;
829+
let audio = this.audio;
830+
if (audio instanceof Float32Array) {
831+
audio = [audio]; // Ensure audio is an array of chunks
832+
}
833+
return encodeWAV(audio, this.sampling_rate);
816834
}
817835

818836
/**
819837
* Save the audio to a wav file.
820838
* @param {string} path
839+
* @returns {Promise<void>}
821840
*/
822841
async save(path) {
823-
let fn;
824-
825-
if (apis.IS_BROWSER_ENV) {
826-
if (apis.IS_WEBWORKER_ENV) {
827-
throw new Error('Unable to save a file from a Web Worker.');
828-
}
829-
fn = saveBlob;
830-
} else if (apis.IS_FS_AVAILABLE) {
831-
fn = async (/** @type {string} */ path, /** @type {Blob} */ blob) => {
832-
let buffer = await blob.arrayBuffer();
833-
fs.writeFileSync(path, Buffer.from(buffer));
834-
};
835-
} else {
836-
throw new Error('Unable to save because filesystem is disabled in this environment.');
837-
}
838-
839-
await fn(path, this.toBlob());
842+
return saveBlob(path, this.toBlob());
840843
}
841844
}

src/utils/core.js

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -187,32 +187,6 @@ export function calculateReflectOffset(i, w) {
187187
return Math.abs(((i + w) % (2 * w)) - w);
188188
}
189189

190-
/**
191-
* Save blob file on the web.
192-
* @param {string} path The path to save the blob to
193-
* @param {Blob} blob The blob to save
194-
*/
195-
export function saveBlob(path, blob) {
196-
// Convert the canvas content to a data URL
197-
const dataURL = URL.createObjectURL(blob);
198-
199-
// Create an anchor element with the data URL as the href attribute
200-
const downloadLink = document.createElement('a');
201-
downloadLink.href = dataURL;
202-
203-
// Set the download attribute to specify the desired filename for the downloaded image
204-
downloadLink.download = path;
205-
206-
// Trigger the download
207-
downloadLink.click();
208-
209-
// Clean up: remove the anchor element from the DOM
210-
downloadLink.remove();
211-
212-
// Revoke the Object URL to free up memory
213-
URL.revokeObjectURL(dataURL);
214-
}
215-
216190
/**
217191
*
218192
* @param {Object} o

src/utils/image.js

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@
77
* @module utils/image
88
*/
99

10-
import { isNullishDimension, saveBlob } from './core.js';
10+
import { isNullishDimension } from './core.js';
1111
import { getFile } from './hub.js';
1212
import { apis } from '../env.js';
1313
import { Tensor } from './tensor.js';
14+
import { saveBlob } from './io.js';
1415

1516
// Will be empty (or not used) if running in browser or web-worker
1617
import sharp from 'sharp';
@@ -769,6 +770,7 @@ export class RawImage {
769770
/**
770771
* Save the image to the given path.
771772
* @param {string} path The path to save the image to.
773+
* @returns {Promise<void>}
772774
*/
773775
async save(path) {
774776
if (IS_BROWSER_OR_WEBWORKER) {
@@ -782,15 +784,19 @@ export class RawImage {
782784
// Convert image to Blob
783785
const blob = await this.toBlob(mime);
784786

785-
saveBlob(path, blob);
786-
} else if (!apis.IS_FS_AVAILABLE) {
787-
throw new Error('Unable to save the image because filesystem is disabled in this environment.');
788-
} else {
787+
return saveBlob(path, blob);
788+
} else if (apis.IS_FS_AVAILABLE) {
789789
const img = this.toSharp();
790-
return await img.toFile(path);
790+
await img.toFile(path);
791+
} else {
792+
throw new Error('Unable to save the image because filesystem is disabled in this environment.');
791793
}
792794
}
793795

796+
/**
797+
* Convert the image to a Sharp instance.
798+
* @returns {import('sharp').Sharp} The Sharp instance.
799+
*/
794800
toSharp() {
795801
if (IS_BROWSER_OR_WEBWORKER) {
796802
throw new Error('toSharp() is only supported in server-side environments.');

src/utils/io.js

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
2+
import fs from 'node:fs';
3+
import { Readable } from 'node:stream';
4+
import { pipeline as pipe } from 'node:stream/promises';
5+
6+
import { apis } from "../env.js";
7+
8+
/**
9+
* Save blob file.
10+
* @param {string} path The path to save the blob to
11+
* @param {Blob} blob The blob to save
12+
* @returns {Promise<void>} A promise that resolves when the blob has been saved
13+
*/
14+
export async function saveBlob(path, blob) {
15+
if (apis.IS_BROWSER_ENV) {
16+
if (apis.IS_WEBWORKER_ENV) {
17+
throw new Error('Unable to save a file from a Web Worker.');
18+
}
19+
// Convert the canvas content to a data URL
20+
const dataURL = URL.createObjectURL(blob);
21+
22+
// Create an anchor element with the data URL as the href attribute
23+
const downloadLink = document.createElement('a');
24+
downloadLink.href = dataURL;
25+
26+
// Set the download attribute to specify the desired filename for the downloaded image
27+
downloadLink.download = path;
28+
29+
// Trigger the download
30+
downloadLink.click();
31+
32+
// Clean up: remove the anchor element from the DOM
33+
downloadLink.remove();
34+
35+
// Revoke the Object URL to free up memory
36+
URL.revokeObjectURL(dataURL);
37+
38+
} else if (apis.IS_FS_AVAILABLE) {
39+
// Convert Blob to a Node.js Readable Stream
40+
const webStream = blob.stream();
41+
const nodeStream = Readable.fromWeb(webStream);
42+
43+
// Create the file write stream
44+
const fileStream = fs.createWriteStream(path);
45+
46+
// Pipe the readable stream to the file write stream
47+
await pipe(nodeStream, fileStream);
48+
49+
} else {
50+
throw new Error('Unable to save because filesystem is disabled in this environment.');
51+
}
52+
}

tests/utils/audio.test.js

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { RawAudio } from "../../src/utils/audio.js";
2+
3+
/**
4+
* Helper function to generate a sine wave.
5+
* @param {number} length Length of the audio in samples.
6+
* @param {number} freq Frequency of the sine wave.
7+
* @param {number} sampling_rate Sampling rate.
8+
* @returns {Float32Array} The generated sine wave.
9+
*/
10+
function generateSineWave(length, freq, sampling_rate) {
11+
const audio = new Float32Array(length);
12+
for (let i = 0; i < length; ++i) {
13+
audio[i] = Math.sin((2 * Math.PI * freq * i) / sampling_rate);
14+
}
15+
return audio;
16+
}
17+
18+
describe("Audio utilities", () => {
19+
describe("RawAudio", () => {
20+
it("should create RawAudio from a single Float32Array", () => {
21+
const sampling_rate = 16000;
22+
const audioData = generateSineWave(1000, 440, sampling_rate);
23+
const rawAudio = new RawAudio(audioData, sampling_rate);
24+
25+
expect(rawAudio.sampling_rate).toBe(sampling_rate);
26+
expect(rawAudio.data).toBeInstanceOf(Float32Array);
27+
expect(rawAudio.data).toEqual(audioData);
28+
expect(rawAudio.data.length).toBe(1000);
29+
});
30+
31+
it("should create RawAudio from multiple Float32Array chunks", () => {
32+
const sampling_rate = 16000;
33+
const chunk1 = generateSineWave(500, 440, sampling_rate);
34+
const chunk2 = generateSineWave(500, 880, sampling_rate);
35+
const rawAudio = new RawAudio([chunk1, chunk2], sampling_rate);
36+
37+
expect(rawAudio.sampling_rate).toBe(sampling_rate);
38+
expect(rawAudio.data).toBeInstanceOf(Float32Array);
39+
expect(rawAudio.data.length).toBe(1000);
40+
41+
// Check if concatenation is correct
42+
const combined = new Float32Array(1000);
43+
combined.set(chunk1, 0);
44+
combined.set(chunk2, 500);
45+
expect(rawAudio.data).toEqual(combined);
46+
});
47+
48+
it("should handle empty array of chunks", () => {
49+
const rawAudio = new RawAudio([], 16000);
50+
expect(rawAudio.data).toBeInstanceOf(Float32Array);
51+
expect(rawAudio.data.length).toBe(0);
52+
});
53+
54+
it("should convert to Blob (WAV)", () => {
55+
const sampling_rate = 16000;
56+
const audioData = generateSineWave(1000, 440, sampling_rate);
57+
const rawAudio = new RawAudio(audioData, sampling_rate);
58+
59+
const blob = rawAudio.toBlob();
60+
expect(blob).toBeInstanceOf(Blob);
61+
expect(blob.type).toBe("audio/wav");
62+
63+
// WAV header is 44 bytes
64+
// 1000 samples * 4 bytes/sample (float32) = 4000 bytes
65+
expect(blob.size).toBe(4044);
66+
});
67+
68+
it("should convert to Blob (WAV) from chunks", () => {
69+
const sampling_rate = 16000;
70+
const chunk1 = generateSineWave(500, 440, sampling_rate);
71+
const chunk2 = generateSineWave(500, 880, sampling_rate);
72+
const rawAudio = new RawAudio([chunk1, chunk2], sampling_rate);
73+
74+
const blob = rawAudio.toBlob();
75+
expect(blob).toBeInstanceOf(Blob);
76+
expect(blob.type).toBe("audio/wav");
77+
expect(blob.size).toBe(4044); // 44 header + 4000 data
78+
});
79+
});
80+
});

0 commit comments

Comments
 (0)