- Implemented.

Safari/iPhone recording now uses MP4/AAC when WebM is unavailable.
WebM remains the default when supported and FFmpeg is disabled.
Added optional FFmpeg profiles: recommended M4A/AAC-LC or requested 
MP4/H.264 + AAC.
Added MIME-aware, playsinline audio playback.
Added dashboard controls under Plans & Platform → Voice Transcoding / 
FFmpeg.
Added Apache/Nginx media MIME configuration and documentation.
Core implementation: voice_transcoder.php (line 72), messages.php (line 
86), and cyberchat-app.js (line 635).
This commit is contained in:
Ty Clifford
2026-06-09 02:21:42 -04:00
parent ae0fb45c48
commit 6217a216a7
13 changed files with 325 additions and 21 deletions
+33 -10
View File
@@ -14,7 +14,7 @@
loginChallenge: initialChallenge,
verifyToken: new URLSearchParams(location.search).get('verify') || '',
voice: { recorder: null, stream: null, timer: null, heartbeat: null, started: 0,
chunks: [], blob: null, duration: 0, url: '', autoSend: false, autoPlay: false,
chunks: [], blob: null, mime: '', duration: 0, url: '', autoSend: false, autoPlay: false,
playQueue: [], currentAudio: null, queuePlaying: false, autoplayWarningShown: false, sending: false }
};
let root;
@@ -89,9 +89,11 @@
function linkify(text) {
return text.replace(/(https?:\/\/[^\s<>]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
}
function audioPlayer(src = '', id = '') {
function audioPlayer(src = '', id = '', mime = '') {
return `<span class="cc-cyber-audio">
<audio ${id ? `id="${id}"` : ''} preload="metadata" ${src ? `src="${esc(src)}"` : ''}></audio>
<audio ${id ? `id="${id}"` : ''} preload="metadata" playsinline>${src
? `<source src="${esc(src)}" ${mime ? `type="${esc(mime)}"` : ''}>`
: ''}</audio>
<button class="cc-audio-play" type="button" aria-label="Play audio"><span class="cc-audio-play-icon"></span></button>
<span class="cc-audio-signal" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
<input class="cc-audio-seek" type="range" min="0" max="1000" value="0" aria-label="Audio position">
@@ -565,7 +567,7 @@
item.dataset.id = String(id);
item.dataset.renderKey = messageRenderKey(message);
const content = message.message_type === 'voice' && message.voice_url
? `<span class="cc-voice-message"><span class="cc-voice-label">VOICE ${duration(message.voice_duration)}</span>${audioPlayer(publicUrl(message.voice_url))}</span>`
? `<span class="cc-voice-message"><span class="cc-voice-label">VOICE ${duration(message.voice_duration)}</span>${audioPlayer(publicUrl(message.voice_url), '', message.voice_mime)}</span>`
: linkify(esc(message.message));
item.innerHTML = `<span class="cc-msg-time">${clock(message.created_at)}</span>
<span class="cc-msg-user" style="color:${esc(message.color)}">${esc(message.username)}</span>
@@ -636,15 +638,31 @@
if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) return toast('Voice recording is not supported');
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mime = ['audio/webm;codecs=opus', 'audio/webm'].find(type => MediaRecorder.isTypeSupported(type));
if (!mime) { stream.getTracks().forEach(track => track.stop()); return toast('WebM recording is not supported'); }
const candidates = [
'audio/webm;codecs=opus', 'audio/webm',
'audio/mp4;codecs=mp4a.40.2', 'audio/mp4', 'audio/aac'
];
let recorder = null;
let mime = '';
for (const candidate of candidates) {
if (MediaRecorder.isTypeSupported && !MediaRecorder.isTypeSupported(candidate)) continue;
try {
recorder = new MediaRecorder(stream, { mimeType: candidate });
mime = candidate;
break;
} catch {}
}
if (!recorder) recorder = new MediaRecorder(stream);
state.voice.stream = stream;
state.voice.chunks = [];
state.voice.started = Date.now();
const recorder = new MediaRecorder(stream, { mimeType: mime });
const recordedMime = recorder.mimeType || mime || 'audio/webm';
recorder.addEventListener('dataavailable', event => { if (event.data.size) state.voice.chunks.push(event.data); });
recorder.addEventListener('stop', () => {
if (!recorder._discard) finishVoice(new Blob(state.voice.chunks, { type: mime }), (Date.now() - state.voice.started) / 1000);
if (!recorder._discard) finishVoice(
new Blob(state.voice.chunks, { type: recordedMime }),
(Date.now() - state.voice.started) / 1000
);
}, { once: true });
recorder.start(250);
state.voice.recorder = recorder;
@@ -680,6 +698,7 @@
function finishVoice(blob, length) {
if (!blob.size || length < 0.25) return toast('Voice clip was too short');
state.voice.blob = blob;
state.voice.mime = blob.type || 'audio/webm';
state.voice.duration = length;
if (state.voice.autoSend && feature('auto_voice_send')) return sendVoice();
if (state.voice.url) URL.revokeObjectURL(state.voice.url);
@@ -694,6 +713,7 @@
if (state.voice.url) URL.revokeObjectURL(state.voice.url);
state.voice.url = '';
state.voice.blob = null;
state.voice.mime = '';
state.voice.duration = 0;
if ($('#voice-review')) $('#voice-review').hidden = true;
}
@@ -703,7 +723,10 @@
const body = new FormData();
body.append('action', 'send_voice');
body.append('duration', state.voice.duration.toFixed(2));
body.append('voice', state.voice.blob, 'voice.webm');
const uploadName = state.voice.mime.includes('mp4') ? 'voice.m4a'
: state.voice.mime.includes('aac') ? 'voice.aac'
: state.voice.mime.includes('wav') ? 'voice.wav' : 'voice.webm';
body.append('voice', state.voice.blob, uploadName);
const result = await api('messages.php', { method: 'POST', body })
.catch(() => ({ error: 'Voice send failed. Polling will continue.' }));
state.voice.sending = false;
@@ -729,7 +752,7 @@
}
function archiveRow(message) {
const content = message.message_type === 'voice' && message.voice_url
? audioPlayer(publicUrl(message.voice_url)) : esc(message.message);
? audioPlayer(publicUrl(message.voice_url), '', message.voice_mime) : esc(message.message);
return `<div class="cc-archive-row"><time>${esc(message.day_key)} ${clock(message.created_at)}</time>
<span class="cc-type-tag">${esc(message.message_type)}</span><div>${content}</div></div>`;
}