Skip to main content

Audio Recording in Oracle Apex

Oracle APEX

Audio Recording in Oracle APEX

Record, store & playback voice notes directly inside your APEX application

🎙 Audio Recording 💾 BLOB Storage 🔧 JavaScript + PL/SQL

Introduction

While working on a client project, I received an interesting requirement — they wanted users to be able to leave voice comments directly inside the Oracle APEX application, instead of typing long text notes.

This is a very practical need. Think about situations where typing is inconvenient — field inspections, warehouse operations, patient feedback, or quick manager notes. Voice is faster, more natural, and captures tone that text cannot.

In this blog, I will walk you through how I implemented audio recording inside Oracle APEX — from the UI to saving the audio as a BLOB in the database.

What We Will Build

🎤A mic recorder UI with animated waveform
⏱️Live recording timer showing mm:ss
▶️Playback before saving to database
💾Save audio BLOB to Oracle table
1

Create the Database Table

First, create a table to store the audio recordings. The audio_blob column stores the actual audio as a BLOB, and mime_type stores the audio format (webm or mp4).

CREATE TABLE audio_recordings (
    id          NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    filename    VARCHAR2(255),
    mime_type   VARCHAR2(100),
    audio_blob  BLOB,
    created_on  DATE DEFAULT SYSDATE
);
2

Create Hidden Page Items

In your APEX page (e.g. Page 5), create two hidden items. These will hold the audio data and filename temporarily before submission.

  • P5_AUDIO_DATA — stores the audio as a Base64 encoded string
  • P5_FILENAME — stores the generated filename (e.g. rec_1720000000.webm)
How to create: Page Designer → Right-click on your region → Create Page Item → Type: Hidden
3

Create a Static Content Region (HTML)

Create a Static Content region on the page and paste the HTML below. This builds the recorder UI with three states: Idle, Recording, and Done.

Important: Make sure all buttons have type="button" — without this, browsers treat them as submit buttons and the page will reload immediately when clicked!
<style>
  @keyframes pulse { 0%,100%{transform:scale(1);opacity:1} 50%{transform:scale(1.15);opacity:0.7} }
  @keyframes bar   { 0%,100%{height:4px} 50%{height:var(--h)} }
  .rec-dot  { animation: pulse 1.2s ease-in-out infinite; }
  .wave-bar { animation: bar 0.6s ease-in-out infinite; background:#E24B4A; border-radius:2px; width:4px; }
  .wave-bar:nth-child(1){--h:18px;animation-delay:0s}
  .wave-bar:nth-child(2){--h:28px;animation-delay:0.1s}
  .wave-bar:nth-child(3){--h:36px;animation-delay:0.2s}
  .wave-bar:nth-child(4){--h:24px;animation-delay:0.3s}
  .wave-bar:nth-child(5){--h:32px;animation-delay:0.15s}
  .wave-bar:nth-child(6){--h:20px;animation-delay:0.25s}
  .wave-bar:nth-child(7){--h:30px;animation-delay:0.05s}
  .wave-bar:nth-child(8){--h:16px;animation-delay:0.35s}
  .wave-bar:nth-child(9){--h:26px;animation-delay:0.12s}
  .wave-bar:nth-child(10){--h:22px;animation-delay:0.28s}
  .wave-bar:nth-child(11){--h:34px;animation-delay:0.08s}
  .wave-bar:nth-child(12){--h:18px;animation-delay:0.32s}
  .btn-rec { cursor:pointer;border:none;border-radius:50px;padding:10px 24px;font-size:14px;
             font-weight:500;display:inline-flex;align-items:center;gap:8px;transition:opacity 0.2s; }
  .btn-rec:disabled { opacity:0.35;cursor:not-allowed; }
</style>

<div style="background:#fff;border:1px solid #e0e0e0;border-radius:12px;padding:2rem;
            max-width:480px;margin:0 auto;font-family:inherit;">

  <div style="display:flex;align-items:center;gap:10px;margin-bottom:1.5rem;
              border-bottom:1px solid #f0f0f0;padding-bottom:1rem;">
    <span style="font-size:20px;">🎙</span>
    <span style="font-size:15px;font-weight:600;color:#1a1a1a;">Audio Recorder</span>
  </div>

  <!-- IDLE STATE -->
  <div id="idlePanel" style="display:flex;flex-direction:column;align-items:center;gap:1rem;">
    <div style="width:72px;height:72px;border-radius:50%;background:#FCEBEB;
                display:flex;align-items:center;justify-content:center;font-size:32px;">🎤</div>
    <p id="idleMsg" style="font-size:13px;color:#888;margin:0;text-align:center;">
      Click start to begin recording</p>
    <button type="button" id="startBtn" class="btn-rec" style="background:#E24B4A;color:#fff;">
      ▶ Start Recording
    </button>
  </div>

  <!-- RECORDING STATE -->
  <div id="recordingPanel" style="display:none;flex-direction:column;align-items:center;gap:1.25rem;">
    <div style="display:flex;align-items:center;gap:8px;">
      <span class="rec-dot" style="width:10px;height:10px;border-radius:50%;
                               background:#E24B4A;display:inline-block;"></span>
      <span style="font-size:13px;color:#E24B4A;font-weight:600;">Recording</span>
      <span id="recTimer" style="font-size:13px;color:#888;margin-left:4px;">0:00</span>
    </div>
    <div style="display:flex;align-items:flex-end;gap:4px;height:40px;">
      <div class="wave-bar"></div><div class="wave-bar"></div><div class="wave-bar"></div>
      <div class="wave-bar"></div><div class="wave-bar"></div><div class="wave-bar"></div>
      <div class="wave-bar"></div><div class="wave-bar"></div><div class="wave-bar"></div>
      <div class="wave-bar"></div><div class="wave-bar"></div><div class="wave-bar"></div>
    </div>
    <button type="button" id="stopBtn" class="btn-rec"
            style="background:#f5f5f5;color:#1a1a1a;border:1px solid #ddd;">
      ⏹ Stop Recording
    </button>
  </div>

  <!-- DONE STATE -->
  <div id="donePanel" style="display:none;flex-direction:column;align-items:center;gap:1rem;">
    <div style="width:72px;height:72px;border-radius:50%;background:#EAF3DE;
                display:flex;align-items:center;justify-content:center;font-size:32px;">✅</div>
    <p id="doneMsg" style="font-size:13px;color:#555;margin:0;text-align:center;">Ready to save</p>
    <audio id="audioPlayback" controls style="width:100%;"></audio>
    <div style="display:flex;gap:10px;width:100%;">
      <button type="button" id="reRecordBtn" class="btn-rec"
              style="flex:1;justify-content:center;background:#f5f5f5;color:#1a1a1a;border:1px solid #ddd;">
        🔄 Record Again
      </button>
      <button type="button" id="SAVE_AUDIO" class="btn-rec"
              style="flex:1;justify-content:center;background:#EAF3DE;color:#3B6D11;">
        💾 Save
      </button>
    </div>
  </div>

</div>
4

Add JavaScript (Execute When Page Loads)

Go to Page Properties → JavaScript → Execute When Page Loads and paste the code below.

AudioContext keep-alive
Pipes mic audio through a ScriptProcessor so the browser never auto-kills the stream.
start(1000) timeslice
Collects audio data every 1 second — keeps MediaRecorder alive continuously.
type="button" fix
Without this, the browser submits the APEX form immediately on click.
Base64 encoding
FileReader converts the audio Blob to Base64 for the hidden page item.
var mediaRecorder, audioChunks = [], isRecording = false;
var audioContext, sourceNode, keepAliveNode;
var recTimerInterval, elapsedSeconds = 0;

function showPanel(name) {
  document.getElementById('idlePanel').style.display      = name === 'idle'      ? 'flex' : 'none';
  document.getElementById('recordingPanel').style.display = name === 'recording' ? 'flex' : 'none';
  document.getElementById('donePanel').style.display      = name === 'done'      ? 'flex' : 'none';
}

function fmtTime(s) {
  var m = Math.floor(s / 60), ss = s % 60;
  return m + ':' + (ss < 10 ? '0' : '') + ss;
}

document.getElementById('startBtn').addEventListener('click', function () {
  if (isRecording) return;
  audioChunks = [];
  apex.item('P5_AUDIO_DATA').setValue('');
  apex.item('P5_FILENAME').setValue('');

  navigator.mediaDevices.getUserMedia({ audio: true }).then(function (stream) {
    audioContext  = new (window.AudioContext || window.webkitAudioContext)();
    sourceNode    = audioContext.createMediaStreamSource(stream);
    keepAliveNode = audioContext.createScriptProcessor(4096, 1, 1);
    keepAliveNode.onaudioprocess = function (e) {
      var i = e.inputBuffer.getChannelData(0);
      var o = e.outputBuffer.getChannelData(0);
      for (var x = 0; x < i.length; x++) o[x] = i[x];
    };
    sourceNode.connect(keepAliveNode);
    keepAliveNode.connect(audioContext.destination);

    var mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus'
                 : MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm'
                 : MediaRecorder.isTypeSupported('audio/mp4')  ? 'audio/mp4' : '';

    mediaRecorder = new MediaRecorder(stream, mimeType ? { mimeType: mimeType } : {});
    mediaRecorder.start(1000);
    isRecording = true;

    elapsedSeconds = 0;
    showPanel('recording');
    recTimerInterval = setInterval(function () {
      elapsedSeconds++;
      document.getElementById('recTimer').innerText = fmtTime(elapsedSeconds);
    }, 1000);

    mediaRecorder.addEventListener('dataavailable', function (e) {
      if (e.data && e.data.size > 0) audioChunks.push(e.data);
    });

    mediaRecorder.addEventListener('stop', function () {
      clearInterval(recTimerInterval);
      if (keepAliveNode) keepAliveNode.disconnect();
      if (sourceNode)    sourceNode.disconnect();
      if (audioContext)  audioContext.close();
      stream.getTracks().forEach(function (t) { t.stop(); });

      if (audioChunks.length === 0) {
        document.getElementById('idleMsg').innerText = '⚠️ No audio captured. Try again.';
        showPanel('idle'); isRecording = false; return;
      }

      var finalMime = mimeType || 'audio/webm';
      var audioBlob = new Blob(audioChunks, { type: finalMime });
      document.getElementById('audioPlayback').src = URL.createObjectURL(audioBlob);

      var reader = new FileReader();
      reader.readAsDataURL(audioBlob);
      reader.onloadend = function () {
        apex.item('P5_AUDIO_DATA').setValue(reader.result.split(',')[1]);
        var ext = finalMime.indexOf('mp4') > -1 ? 'm4a' : 'webm';
        apex.item('P5_FILENAME').setValue('rec_' + Date.now() + '.' + ext);
        document.getElementById('doneMsg').innerText =
          'Recorded ' + fmtTime(elapsedSeconds) + ' — click Save to store';
      };
      showPanel('done');
      isRecording = false;
    });

  }).catch(function (err) {
    document.getElementById('idleMsg').innerText = '❌ Mic error: ' + err.message;
  });
});

document.getElementById('stopBtn').addEventListener('click', function () {
  if (mediaRecorder && mediaRecorder.state === 'recording') mediaRecorder.stop();
});

document.getElementById('reRecordBtn').addEventListener('click', function () {
  document.getElementById('idleMsg').innerText = 'Click start to begin recording';
  showPanel('idle');
});

document.getElementById('SAVE_AUDIO').addEventListener('click', function () {
  if (isRecording) { alert('Please stop recording first.'); return; }
  apex.submit('SAVE_AUDIO');
});
5

Create a PL/SQL Process

In Page Designer, go to Processing → Right-click → Create Process. Set the server-side condition to Request = SAVE_AUDIO.

DECLARE
  l_blob    BLOB;
  l_raw     RAW(32767);
  l_b64     CLOB  := :P5_AUDIO_DATA;
  l_offset  INTEGER := 1;
  l_amount  INTEGER := 7800;
  l_buffer  VARCHAR2(8000);
BEGIN
  IF :P5_AUDIO_DATA IS NULL OR LENGTH(:P5_AUDIO_DATA) < 100 THEN
    RETURN;
  END IF;

  DBMS_LOB.CREATETEMPORARY(l_blob, TRUE);

  WHILE l_offset <= DBMS_LOB.GETLENGTH(l_b64) LOOP
    DBMS_LOB.READ(l_b64, l_amount, l_offset, l_buffer);
    l_raw := UTL_ENCODE.BASE64_DECODE(UTL_RAW.CAST_TO_RAW(l_buffer));
    DBMS_LOB.WRITEAPPEND(l_blob, UTL_RAW.LENGTH(l_raw), l_raw);
    l_offset := l_offset + l_amount;
  END LOOP;

  INSERT INTO audio_recordings (filename, mime_type, audio_blob)
  VALUES (:P5_FILENAME, 'audio/webm', l_blob);

  COMMIT;
  DBMS_LOB.FREETEMPORARY(l_blob);
END;

Common Issues & Fixes

⚠️ Recording stops immediatelyMissing type="button" on the button tag — browser defaults to submit and reloads the page.
⚠️ Stops after a few secondsBrowser kills idle mic stream. Fixed by AudioContext keep-alive that actively pipes audio through a ScriptProcessor.
⚠️ ORA-01536 quota errorRun: ALTER USER your_schema QUOTA UNLIMITED ON APEX_BIGFILE_INSTANCE_TBS2; as ADMIN.
⚠️ Mic not working on mobilegetUserMedia requires HTTPS. Make sure your APEX app is served over a secure connection.
Found this helpful? Share it with your APEX community 🙌

Comments

Popular posts from this blog

Generate Custom PDFs in Oracle APEX with jsPDF

Generate Custom PDFs in Oracle APEX with jsPDF A complete step-by-step guide to building a client-side PDF generator with profile image embedding and PL/SQL database persistence. &#128196; Step-by-step guide Oracle APEX jsPDF Dynamic Actions PL/SQL JavaScript "Picture this: your Oracle APEX application is polished, your users love it — but when they ask for a downloadable report with a profile picture and custom styling, you realize vanilla APEX isn't built for that out of the box. What if a single JavaScript library and one Dynamic Action could change everything?" Welcome to the world of jsPDF inside Oracle APEX . In this tutorial, we wire up a PDF generator that captures a user's name, email, phone number, and profile picture — renders them into a beautiful PDF layout — and saves everything to Oracle, all in one button click. ⓘ What you'll build: A dynamic PDF generat...

APEX Custom Auth Settings, Decoded!

Oracle APEX Security PL/SQL APEX Custom Auth Settings, Decoded Why I Build this I was building an internal APEX app — strictly for people on our corporate network. The default authentication worked, but it had a few things that kept bothering me. Anyone with valid credentials could log in from anywhere. Sessions expired with a useless error page. There was no audit trail — no way to know who logged in, when, or from where. And passwords were stored in plain text, which I just couldn't leave alone. So I did what any developer does — I built it myself. Custom authentication, from scratch, in PL/SQL. Turned out to be one of the best learning experiences I've had with APEX. Here's exactly how I did it. Img 1 : Head to Shared Components → Authentication Schemes → Create, choose Custom as the Scheme Type, and plug in your function and procedure names. Quick note before we get into the code — the s...

Record, Preview, Save — Video Recording in Oracle APEX

Oracle APEX · Video Recording Record Video Directly in Oracle APEX 🎬 Have you ever wished your users could say it instead of type it? In many modern applications, we've seen the ability to record a short video clip directly in the browser — no file uploads, no external tools. Just hit record, watch it back, and decide — save it or try again. Today, we're bringing that same experience into Oracle APEX: record, pause/resume, preview, save to DB, or retry. Let's build it. 1 Step 1 : Start by creating a Static Content Region on your APEX page. This will act as the container for our entire video recorder UI.Paste the provided HTML code into the region source. This gives you two video panels — one for the live camera preview and one for the recorded playback — along with your action buttons: Start, Pause, Resume, Stop, Save, and Retry. Copy <!-- Recorder UI --> < div id= "recorderUI...