From f3e80259aa5aa5c8828a4a0ee598ce49c2e8270a Mon Sep 17 00:00:00 2001 From: Vincent Giersch Date: Fri, 6 Feb 2026 11:30:16 +0100 Subject: [PATCH] feat(embed): add getMP3() and getWAV() methods for audio export Add audio export capabilities with MP3 and WAV formats, along with an exportProgress event for tracking rendering progress. Co-Authored-By: Claude Opus 4.6 --- karma.conf.js | 4 +- src/embed.ts | 64 +++++++++++++++++++++ src/types/events.ts | 1 + test/integration/embed-integration.js | 81 +++++++++++++++++++++++++++ test/unit/embed.js | 34 +++++++++++ 5 files changed, 182 insertions(+), 2 deletions(-) diff --git a/karma.conf.js b/karma.conf.js index f3b4cb0..dfe993e 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -1,4 +1,4 @@ -require('dotenv').config(); +require('dotenv').config({ quiet: true }); module.exports = config => { // Mocha configuration @@ -58,7 +58,7 @@ module.exports = config => { }, singleRun: false, concurrency: Infinity, - browserNoActivityTimeout: 60000, + browserNoActivityTimeout: 180000, // 3 minutes for audio export tests }; config.set(configuration); diff --git a/src/embed.ts b/src/embed.ts index d6ddc6f..1328ca9 100644 --- a/src/embed.ts +++ b/src/embed.ts @@ -436,6 +436,70 @@ class Embed { return this.call('getMIDI').then(data => new Uint8Array(data as [number])); } + /** + * Convert the displayed score to MP3 audio + * + * Exports the currently loaded score as an MP3 audio file. The audio is rendered + * client-side using the browser's audio capabilities. This may take some time + * depending on the score length and complexity. + * + * @returns A promise that resolves with a Uint8Array containing the MP3 audio data + * @throws {Error} If no score is loaded or audio rendering fails + * + * @example + * // Get MP3 and play it + * const mp3Data = await embed.getMP3(); + * const blob = new Blob([mp3Data], { type: 'audio/mpeg' }); + * const url = URL.createObjectURL(blob); + * const audio = new Audio(url); + * audio.play(); + * + * @example + * // Download MP3 file + * const mp3Data = await embed.getMP3(); + * const blob = new Blob([mp3Data], { type: 'audio/mpeg' }); + * const url = URL.createObjectURL(blob); + * const a = document.createElement('a'); + * a.href = url; + * a.download = 'score.mp3'; + * a.click(); + */ + getMP3(): Promise { + return this.call('getMP3').then(data => new Uint8Array(data as [number])); + } + + /** + * Convert the displayed score to WAV audio + * + * Exports the currently loaded score as a WAV audio file. The audio is rendered + * client-side using the browser's audio capabilities. WAV files are uncompressed + * and larger than MP3 but provide lossless audio quality. + * + * @returns A promise that resolves with a Uint8Array containing the WAV audio data + * @throws {Error} If no score is loaded or audio rendering fails + * + * @example + * // Get WAV and play it + * const wavData = await embed.getWAV(); + * const blob = new Blob([wavData], { type: 'audio/wav' }); + * const url = URL.createObjectURL(blob); + * const audio = new Audio(url); + * audio.play(); + * + * @example + * // Download WAV file + * const wavData = await embed.getWAV(); + * const blob = new Blob([wavData], { type: 'audio/wav' }); + * const url = URL.createObjectURL(blob); + * const a = document.createElement('a'); + * a.href = url; + * a.download = 'score.wav'; + * a.click(); + */ + getWAV(): Promise { + return this.call('getWAV').then(data => new Uint8Array(data as [number])); + } + /** * Convert the displayed score to PDF * diff --git a/src/types/events.ts b/src/types/events.ts index c928d6b..edd8d66 100644 --- a/src/types/events.ts +++ b/src/types/events.ts @@ -16,6 +16,7 @@ export const EVENTS_NAMES = [ 'playbackPosition', 'restrictedFeatureAttempt', 'embedSize', + 'exportProgress', ] as const; /** diff --git a/test/integration/embed-integration.js b/test/integration/embed-integration.js index d1aaaaf..8d1a1b8 100644 --- a/test/integration/embed-integration.js +++ b/test/integration/embed-integration.js @@ -14,6 +14,25 @@ const PRIVATE_LINK_SHARING_KEY = * #full: Tests that can run on full editor */ +/** + * Checks if data has valid MP3 magic bytes + * MP3 can start with ID3 tag (0x49 0x44 0x33) or MPEG frame sync (0xFF followed by frame header) + */ +function isValidMP3(data) { + if (!(data instanceof Uint8Array) || data.length < 3) return false; + const isID3 = data[0] === 0x49 && data[1] === 0x44 && data[2] === 0x33; + const isMPEG = data[0] === 0xff && (data[1] & 0xe0) === 0xe0; + return isID3 || isMPEG; +} + +/** + * Checks if data has valid WAV magic bytes (RIFF header) + */ +function isValidWAV(data) { + if (!(data instanceof Uint8Array) || data.length < 4) return false; + return data[0] === 0x52 && data[1] === 0x49 && data[2] === 0x46 && data[3] === 0x46; // RIFF +} + describe('Integration - Embed', () => { // On failure, clean dom afterEach(() => { @@ -507,6 +526,68 @@ describe('Integration - Embed', () => { }); }); + describe('MP3 export #full', () => { + it('should export in MP3', function (done) { + this.timeout(120000); // Audio export can take up to 2 minutes + const { embed } = createEmbedForScoreId(PUBLIC_SCORE); + + embed + .getMP3() + .then(mp3 => { + assert.ok(mp3 instanceof Uint8Array); + assert.ok(mp3.length > 0); + assert.ok(isValidMP3(mp3), 'Valid MP3 header'); + done(); + }) + .catch(error => { + done(error); + }); + }); + }); + + describe('WAV export #full', () => { + it('should export in WAV', function (done) { + this.timeout(120000); // Audio export can take up to 2 minutes + const { embed } = createEmbedForScoreId(PUBLIC_SCORE); + + embed + .getWAV() + .then(wav => { + assert.ok(wav instanceof Uint8Array); + assert.ok(wav.length > 0); + assert.ok(isValidWAV(wav), 'Valid WAV header'); + done(); + }) + .catch(error => { + done(error); + }); + }); + }); + + describe('Events - exportProgress #full', () => { + it('should receive exportProgress events during MP3 export', function (done) { + this.timeout(120000); // Audio export can take up to 2 minutes + const { embed } = createEmbedForScoreId(PUBLIC_SCORE); + let progressReceived = false; + + embed.on('exportProgress', progress => { + assert.ok(typeof progress === 'object'); + progressReceived = true; + }); + + embed + .getMP3() + .then(mp3 => { + assert.ok(mp3 instanceof Uint8Array); + assert.ok(progressReceived, 'Should have received exportProgress event'); + done(); + }) + .catch(error => { + done(error); + }); + }); + }); + describe('MIDI import/export', () => { it('shoud load a MIDI file in a blank embed', done => { var container = document.createElement('div'); diff --git a/test/unit/embed.js b/test/unit/embed.js index 1629b87..241f629 100644 --- a/test/unit/embed.js +++ b/test/unit/embed.js @@ -225,4 +225,38 @@ describe('Unit - Embed tests', () => { container.removeChild(embed.element); }); }); + + describe('getMP3 method', () => { + it('should have getMP3 method available', () => { + const container = document.getElementById('container'); + const embed = new Flat.Embed(container, { score: '1234' }); + assert.equal(typeof embed.getMP3, 'function'); + container.removeChild(embed.element); + }); + + it('should return a Promise', () => { + const container = document.getElementById('container'); + const embed = new Flat.Embed(container, { score: '1234' }); + const promise = embed.getMP3(); + assert.ok(promise instanceof Promise); + container.removeChild(embed.element); + }); + }); + + describe('getWAV method', () => { + it('should have getWAV method available', () => { + const container = document.getElementById('container'); + const embed = new Flat.Embed(container, { score: '1234' }); + assert.equal(typeof embed.getWAV, 'function'); + container.removeChild(embed.element); + }); + + it('should return a Promise', () => { + const container = document.getElementById('container'); + const embed = new Flat.Embed(container, { score: '1234' }); + const promise = embed.getWAV(); + assert.ok(promise instanceof Promise); + container.removeChild(embed.element); + }); + }); });