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
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
);
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)
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.
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>
Add JavaScript (Execute When Page Loads)
Go to Page Properties → JavaScript → Execute When Page Loads and paste the code below.
Pipes mic audio through a ScriptProcessor so the browser never auto-kills the stream.
Collects audio data every 1 second — keeps MediaRecorder alive continuously.
Without this, the browser submits the APEX form immediately on click.
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');
});
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
type="button" on the button tag — browser defaults to submit and reloads the page.ALTER USER your_schema QUOTA UNLIMITED ON APEX_BIGFILE_INSTANCE_TBS2; as ADMIN.
Comments
Post a Comment