v2.6.0

<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

PropertyAttributeTypeDefaultDescription
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

EventDetailDescription
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

MethodSignatureDescription
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

PartDescription
panel The side panel.
timeline The tracks.
toolbar The toolbar.

CSS custom properties

PropertyDescription
--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.