<bmx-audio-editor>
A multitrack audio editor in the page: open recordings (WAV, MP3, AAC, Ogg, FLAC), arrange them as clips on tracks, cut, copy, paste, split, trim, move, fade and change gain without touching the originals, add markers, see frequencies as a spectrogram, measure loudness (LUFS) and normalise to a target, find and remove silence, and export WAV.
8 properties · 6 events · 19 methods · 3 parts
Example
A made-up customer call, generated in the page: the agent on one track, the caller on the other, with a transcript. "Find personal data" looks through the transcript for the card number, expiry date, phone number, postcode and email the caller reads out, and through the audio for the security code keyed in on the phone's keypad. The voices are synthetic babble, so listen for the bleeps rather than the words.
Show markup
<div role="group" aria-label="Try it" style="display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 12px; align-items: center">
<button type="button" id="ex-ae-find">Find personal data</button>
<button type="button" id="ex-ae-bleep">Bleep everything found</button>
<button type="button" id="ex-ae-loud">Measure loudness</button>
<button type="button" id="ex-ae-wav">Export the redacted call</button>
</div>
<bmx-audio-editor id="ex-ae" label="Call recording" style="--bmx-audio-editor-height: 26rem"></bmx-audio-editor>
<p class="note" id="ex-ae-out" role="status" style="margin: 12px 0 0">A made-up customer call, generated in the page: the agent on one track, the caller on the other, with a transcript. "Find personal data" looks through the transcript for the card number, expiry date, phone number, postcode and email the caller reads out, and through the audio for the security code keyed in on the phone's keypad. The voices are synthetic babble, so listen for the bleeps rather than the words.</p>
<script type="module">
await customElements.whenDefined('bmx-audio-editor');
const editor = document.getElementById('ex-ae');
const out = document.getElementById('ex-ae-out');
const RATE = 16000;
// ---- A seeded "voice": a buzzing source through three moving resonances (formants),
// syllable by syllable, band-limited like a phone line. It sounds like speech from a
// distance, without being any real person's voice.
let seed = 20261016;
const rand = () => ((seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296);
const VOWELS = [[800, 1200, 2500], [500, 1900, 2600], [300, 2300, 3000], [500, 900, 2400], [350, 800, 2300], [500, 1500, 2500]];
const syllables = w => Math.max(1, (w.toLowerCase().replace(/e\b/, '').match(/[aeiouy]+/g) || []).length);
function speak(buf, start, words, pitch) {
let t = start;
const timed = [];
let phase = 0;
for (const word of words) {
const digit = /^(oh|one|two|three|four|five|six|seven|eight|nine|double|zero|[a-z])$/i.test(word.replace(/[,.?]/g, ''));
const n = syllables(word);
const dur = digit ? 0.34 : n * 0.17 + 0.04;
const s = Math.round(t * RATE);
const e = Math.round((t + dur) * RATE);
let f = VOWELS[Math.floor(rand() * VOWELS.length)];
const r = [[0, 0], [0, 0], [0, 0]];
for (let i = s; i < e && i < buf.length; i++) {
const k = (i - s) / (e - s);
const syl = Math.floor(k * n);
if (Math.floor(((i - 1 - s) / (e - s)) * n) !== syl) f = VOWELS[Math.floor(rand() * VOWELS.length)];
const inSyl = k * n - syl;
const env = Math.min(1, inSyl / 0.12, (1 - inSyl) / 0.18) * (0.85 + 0.15 * Math.sin(k * 9));
const f0 = pitch * (1.08 - 0.16 * k) * (1 + 0.01 * (rand() - 0.5));
phase = (phase + f0 / RATE) % 1;
const src = (phase < 0.6 ? phase / 0.6 : (1 - phase) / 0.4) - 0.5 + (inSyl < 0.15 ? (rand() - 0.5) * 0.8 : 0);
let y = 0;
f.forEach((F, j) => {
const rr = Math.exp((-Math.PI * (60 + j * 40)) / RATE);
const c = 2 * rr * Math.cos((2 * Math.PI * F) / RATE);
const v = (1 - rr) * src + c * r[j][0] - rr * rr * r[j][1];
r[j][1] = r[j][0];
r[j][0] = v;
y += v * [1, 0.55, 0.3][j];
});
buf[i] += Math.max(0, env) * y * 0.9;
}
timed.push({ word: word.replace(/^\W+|\W+$/g, '') || word, start: +t.toFixed(3), end: +(t + dur).toFixed(3) });
t += dur + 0.07;
}
return { words: timed, end: t };
}
function tone(buf, start, lo, hi, dur = 0.12) {
const s = Math.round(start * RATE);
for (let i = 0; i < dur * RATE; i++) buf[s + i] += 0.22 * (Math.sin((2 * Math.PI * lo * i) / RATE) + Math.sin((2 * Math.PI * hi * i) / RATE)) * Math.min(1, i / 40, (dur * RATE - i) / 40);
}
// A biquad (low or high pass), for the telephone band of 300 Hz - 3.4 kHz.
function pass(buf, hz, high) {
const w = (2 * Math.PI * hz) / RATE, al = Math.sin(w) / Math.SQRT2, c = Math.cos(w), a0 = 1 + al;
const b = high ? [(1 + c) / 2, -(1 + c), (1 + c) / 2] : [(1 - c) / 2, 1 - c, (1 - c) / 2];
let x1 = 0, x2 = 0, y1 = 0, y2 = 0;
for (let i = 0; i < buf.length; i++) {
const y = (b[0] * buf[i] + b[1] * x1 + b[2] * x2 + 2 * c * y1 - (1 - al) * y2) / a0;
x2 = x1; x1 = buf[i]; y2 = y1; y1 = y; buf[i] = y;
}
}
const script = [
['agent', "Good morning, you're through to Kestrel Home Energy. How can I help today?"],
['caller', "Hi, I'd like to pay my winter bill please."],
['agent', 'Of course. Can I take the postcode on the account?'],
['caller', "Yes, it's B S one three nine Z Z."],
['agent', 'Thank you. And the best number to reach you on?'],
['caller', 'Oh seven seven double oh nine double oh one two three.'],
['agent', 'Lovely. The balance is one hundred and twelve pounds forty. Can I take the long number on the card?'],
['caller', 'Sure. Four one one one, one one one one, one one one one, one one one one.'],
['agent', 'And the expiry date?'],
['caller', 'Oh nine twenty eight.'],
['agent', 'Thanks. For security, please key in the three digit code from the back of the card on your keypad now.'],
['keys', '739'],
['agent', "That's gone through. Can I send the receipt by email?"],
['caller', "Yes, it's sam dot carter at example dot com."],
['agent', 'All done. Thank you for calling, have a lovely day.'],
['caller', 'You too, bye.'],
];
const LENGTH = 62;
const agent = new Float32Array(LENGTH * RATE);
const caller = new Float32Array(LENGTH * RATE);
const segments = [];
let t = 0.6;
const KEYS = { 7: [852, 1209], 3: [697, 1477], 9: [852, 1477] };
for (const [who, text] of script) {
if (who === 'keys') {
t += 0.8;
for (const k of text) {
tone(caller, t, ...KEYS[k]);
t += 0.45;
}
t += 0.6;
continue;
}
const r = speak(who === 'agent' ? agent : caller, t, text.split(' '), who === 'agent' ? 205 : 118);
segments.push({ speaker: who === 'agent' ? 'Agent' : 'Caller', words: r.words });
t = r.end + 0.45 + rand() * 0.4;
}
for (const buf of [agent, caller]) {
for (let i = 0; i < buf.length; i++) buf[i] += (rand() - 0.5) * 0.004;
pass(buf, 300, true);
pass(buf, 3400, false);
}
editor.project = {
title: 'Call 2291 - card payment',
sampleRate: RATE,
channels: 2,
tracks: [
{ id: 'agent', name: 'Agent', gain: 0, pan: -0.4 },
{ id: 'caller', name: 'Caller', gain: 0, pan: 0.4 },
],
clips: [
{ id: 'a', track: 'agent', source: 'agent', start: 0, offset: 0, duration: LENGTH, name: 'Agent' },
{ id: 'c', track: 'caller', source: 'caller', start: 0, offset: 0, duration: LENGTH, name: 'Caller' },
],
// A marker where the caller starts reading out the card.
markers: [{ time: segments[7].words[1].start, label: 'Card details' }],
};
await editor.addAudio({ sampleRate: RATE, channels: [agent] }, { id: 'agent', name: 'Agent', sourceOnly: true });
await editor.addAudio({ sampleRate: RATE, channels: [caller] }, { id: 'caller', name: 'Caller', sourceOnly: true });
editor.transcript = { segments };
let found = [];
document.getElementById('ex-ae-find').addEventListener('click', async () => {
editor.panel = 'redact';
found = await editor.findRedactions();
out.textContent = `findRedactions(): ${found.map(f => `${f.label} at ${f.start.toFixed(1)} s`).join(', ')}.`;
});
document.getElementById('ex-ae-bleep').addEventListener('click', async () => {
if (!found.length) found = await editor.findRedactions();
await editor.applyRedactions(found, 'bleep');
editor.panel = 'redact';
out.textContent = `applyRedactions(): ${found.length} ranges bleeped across both tracks. Press play, or export the redaction log from Export.`;
});
document.getElementById('ex-ae-loud').addEventListener('click', async () => {
editor.panel = 'levels';
const l = await editor.measureLoudness();
out.textContent = `measureLoudness(): ${l.integrated.toFixed(1)} LUFS integrated, peak ${l.samplePeak.toFixed(1)} dBFS, range ${l.range.toFixed(1)} LU.`;
});
document.getElementById('ex-ae-wav').addEventListener('click', async () => {
const wav = await editor.exportWav({ bits: 16 });
out.textContent = `exportWav(): ${(wav.size / 1024).toFixed(0)} KB of 16-bit WAV - the bleeps are in the file, the card number is not.`;
});
editor.addEventListener('bmxChange', e => (out.textContent = `bmxChange (${e.detail.reason}): ${e.detail.project.clips.length} clips, ${e.detail.project.redactions.length} redactions, ${e.detail.project.markers.length} markers.`));
</script>
Built for call recordings too: give it a transcript with word timings and it finds card numbers, security codes, bank details, phone numbers, email addresses, postcodes and dates of birth in what was said - and keypad tones in the audio - and bleeps or silences them across every track, with a log of what was redacted.
ed.project = { sampleRate: 16000, channels: 1, tracks: [...], clips: [...] }; await ed.addAudio(file, { id: 'agent.wav' }); ed.transcript = await (await fetch('call.json')).json(); const found = await ed.findRedactions(); await ed.applyRedactions(found, 'bleep'); const wav = await ed.exportWav();
Everything happens in the page: audio is never uploaded. The timeline runs left to right in every language, as recordings do.
Properties
| Property | Attribute | Type | Default | Description |
|---|---|---|---|---|
label |
label |
string |
— | An accessible name. |
noFiles |
no-files |
boolean |
false |
Hide the Open buttons (when the page supplies the audio). |
panel |
panel |
BmxAudioEditorPanel |
'edit' |
The side panel shown. Mutable. |
project |
project |
BmxAudioProject | Record<string, unknown> | string |
— | The project: tracks, clips, markers and redactions, or JSON. The audio itself is added with addAudio. Mutable: edits replace it. |
readonly |
readonly |
boolean |
false |
Listen, select, measure and export, but not change. |
src |
src |
string |
— | A recording to open as a one-track project. |
strings |
strings |
Partial<BmxAudioEditorStrings> | string |
— | Replacement words, for translation (an object or JSON). |
transcript |
property only | unknown |
— | Word timings for finding personal data: any of the transcript shapes readWords reads, or WebVTT / SRT text. |
Events
| Event | Detail | Description |
|---|---|---|
bmxChange |
BmxAudioChangeDetail |
The project changed. |
bmxError |
{ message: string; } |
Something could not be read or played. |
bmxExport |
BmxAudioExportDetail |
An export was made. Cancel to keep it from downloading. |
bmxLoad |
BmxAudioLoadDetail |
A recording was read. |
bmxPlayState |
BmxAudioPlayDetail |
Playback started or stopped. |
bmxSelectionChange |
BmxAudioSelectionDetail |
The selection or the chosen clips changed. |
Methods
| Method | Signature | Description |
|---|---|---|
addAudio |
addAudio(input: Blob | ArrayBuffer | string | BmxAudio, options?: BmxAudioAddOptions) => Promise<string> |
Add a recording: a File or Blob, bytes, a URL, or samples ({ sampleRate, channels: Float32Array[] }). It goes on a new track as one clip unless sourceOnly - use that to supply the audio for clips a project already refers to by id. Returns the source id. |
applyRedactions |
applyRedactions(items: readonly { start: number; end: number; label?: string; }[], mode?: BmxRedactionMode) => Promise<void> |
Bleep (or silence) ranges - findings, or any { start, end, label }. |
exportWav |
exportWav(options?: { bits?: 16 | 24 | 32; start?: number; end?: number; download?: boolean; }) => Promise<Blob> |
The mix as a WAV file (a Blob), markers included as cue points. |
findRedactions |
findRedactions(options?: { kinds?: (BmxPiiKind | "dtmf")[]; }) => Promise<BmxRedactionCandidate[]> |
Find what to redact: personal data in the transcript and keypad tones in the audio. kinds narrows the search (default: everything but long numbers). |
findSilence |
findSilence(options?: { threshold?: number; minDuration?: number; }) => Promise<[number, number][]> |
Find the silent stretches of the mix; they are listed in the Levels panel, to remove or mark. |
getProject |
getProject() => Promise<BmxAudioProject> |
The project as it stands. |
getSelection |
getSelection() => Promise<BmxAudioSelectionDetail | undefined> |
|
getSource |
getSource(id: string) => Promise<BmxAudio | undefined> |
The audio for a source id, if loaded. |
getTime |
getTime() => Promise<number> |
The playhead's time. |
measureLoudness |
measureLoudness() => Promise<BmxLoudness> |
Measure the mix's loudness (integrated LUFS, range, peak). |
normalizeLoudness |
normalizeLoudness(target?: string | { lufs?: number; peak?: number; }) => Promise<number> |
Bring the mix to a loudness target - streaming (-14 LUFS), podcast (-16), speech (-19), broadcast (-23) or { lufs, peak } - without its peak passing -1 dBFS (or the peak given). Returns the gain applied, in dB. |
pause |
pause() => Promise<void> |
|
play |
play(from?: number) => Promise<void> |
Start playing (from a time, or the playhead). |
redo |
redo() => Promise<void> |
|
removeRanges |
removeRanges(ranges: readonly (readonly [number, number])[]) => Promise<void> |
Remove stretches of time from every track and close the gaps - what findSilence found, say. |
renderMix |
renderMix(options?: { start?: number; end?: number; sampleRate?: number; channels?: 1 | 2; redact?: boolean; }) => Promise<BmxAudio> |
Render the mix (or part of it): what is heard, redactions applied. |
seek |
seek(time: number) => Promise<void> |
Move the playhead. |
select |
select(start: number, end: number, tracks?: string[]) => Promise<void> |
Select a time range on some tracks (all by default); end <= start clears the selection. |
undo |
undo() => Promise<void> |
CSS shadow parts
| Part | Description |
|---|---|
panel |
The side panel. |
timeline |
The tracks. |
toolbar |
The toolbar. |
CSS custom properties
| Property | Description |
|---|---|
--bmx-audio-editor-clip |
A clip's background. |
--bmx-audio-editor-clip-chosen |
A chosen clip's background. |
--bmx-audio-editor-fade |
Fade curves and handles. |
--bmx-audio-editor-grid |
Ruler ticks. |
--bmx-audio-editor-head-width |
The track heads' width. |
--bmx-audio-editor-height |
The timeline's maximum height before it scrolls. |
--bmx-audio-editor-marker |
Markers. |
--bmx-audio-editor-panel-width |
The side panel's width. |
--bmx-audio-editor-playhead |
The playhead. |
--bmx-audio-editor-radius |
The element's corner radius. |
--bmx-audio-editor-redaction |
Redacted ranges. |
--bmx-audio-editor-ruler-text |
Ruler labels. |
--bmx-audio-editor-selection |
The selection. |
--bmx-audio-editor-wave |
The waveform. |
--bmx-audio-editor-wave-chosen |
The waveform of a chosen clip. |