- 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
+9
View File
@@ -20,6 +20,15 @@
</FilesMatch> </FilesMatch>
</IfModule> </IfModule>
# Voice playback MIME types, including Safari/iPhone-compatible AAC in MP4.
<IfModule mod_mime.c>
AddType audio/webm .webm
AddType audio/wav .wav
AddType audio/aac .aac
AddType audio/mp4 .m4a
AddType video/mp4 .mp4
</IfModule>
# Deny direct access to storage and internal PHP libraries # Deny direct access to storage and internal PHP libraries
<IfModule mod_rewrite.c> <IfModule mod_rewrite.c>
RewriteEngine On RewriteEngine On
+20 -2
View File
@@ -13,6 +13,7 @@ CyberChat is a framework-free PHP, SQLite, JavaScript, and HTML5 chat applicatio
- Premium-configurable recording presence for both sending and viewing - Premium-configurable recording presence for both sending and viewing
- Premium-configurable automatic voice sending - Premium-configurable automatic voice sending
- Tier-configurable automatic playback of newly received voice clips - Tier-configurable automatic playback of newly received voice clips
- Optional FFmpeg voice transcoding to iPhone-compatible AAC/M4A or H.264/AAC MP4
- Administrator tier overrides that grant plan features without payment - Administrator tier overrides that grant plan features without payment
- Single-worker queued polling with cursor catch-up, request timeouts, deduplication, bounded retry backoff, and a capped browser message buffer - Single-worker queued polling with cursor catch-up, request timeouts, deduplication, bounded retry backoff, and a capped browser message buffer
- Plan-based custom username colors - Plan-based custom username colors
@@ -29,6 +30,7 @@ CyberChat is a framework-free PHP, SQLite, JavaScript, and HTML5 chat applicatio
- A web server with HTTPS for production - A web server with HTTPS for production
- Write access to `db/`, `archive/`, `uploads/voice/`, and `config.json` - Write access to `db/`, `archive/`, `uploads/voice/`, and `config.json`
- A modern browser with `MediaRecorder` for voice recording - A modern browser with `MediaRecorder` for voice recording
- Optional: FFmpeg with AAC support; `libx264` is also required for the H.264 profile
- Optional: PDO MySQL for the MySQL/MariaDB mirror - Optional: PDO MySQL for the MySQL/MariaDB mirror
## Installation ## Installation
@@ -38,7 +40,7 @@ CyberChat is a framework-free PHP, SQLite, JavaScript, and HTML5 chat applicatio
3. Configure Apache with the included `.htaccess`, or adapt `nginx-example.conf`. 3. Configure Apache with the included `.htaccess`, or adapt `nginx-example.conf`.
4. Open `admin.php` and sign in with the initial password `changeme123`. 4. Open `admin.php` and sign in with the initial password `changeme123`.
5. Immediately change `admin.password` in the Configuration tab. 5. Immediately change `admin.password` in the Configuration tab.
6. Open **Plans & Platform** to configure tiers, Mailgun, Stripe, and optional MySQL mirroring. 6. Open **Plans & Platform** to configure tiers, Mailgun, Stripe, optional voice transcoding, and MySQL mirroring.
The SQLite schema and default Free, Plus, and Pro tiers are created automatically on first use. The SQLite schema and default Free, Plus, and Pro tiers are created automatically on first use.
@@ -115,6 +117,21 @@ Stripe references:
- <https://docs.stripe.com/api/checkout/sessions/create> - <https://docs.stripe.com/api/checkout/sessions/create>
- <https://docs.stripe.com/webhooks> - <https://docs.stripe.com/webhooks>
## Voice Compatibility and FFmpeg
Browsers that support WebM continue to record WebM by default. Safari and iPhone may record MP4/AAC instead, which the upload API also accepts.
Under **Admin > Plans & Platform > Voice Transcoding / FFmpeg**, configure:
- Enable or disable server-side transcoding
- FFmpeg executable name or absolute path
- `M4A / AAC-LC`, recommended for audio-only playback on iPhone, Safari, Android, and desktop
- `MP4 / H.264 + AAC`, which adds a tiny black H.264 video track for systems requiring a conventional MP4
- Audio bitrate and conversion timeout
- Whether to retain the original source when conversion fails
When transcoding is disabled, the original browser recording is stored. WebM remains the preferred default on supported browsers. When enabled, new uploads are converted; existing voice files are unchanged.
## Archives ## Archives
The application archives daily messages into: The application archives daily messages into:
@@ -143,7 +160,7 @@ Set a PDO MySQL DSN in the dashboard, enable the mirror, and optionally enable a
<script> <script>
window.CYBERCHAT_BASE = '/chat'; window.CYBERCHAT_BASE = '/chat';
</script> </script>
<script src="/chat/assets/js/cyberchat-app.js?v=20260609.5"></script> <script src="/chat/assets/js/cyberchat-app.js?v=20260609.6"></script>
<script> <script>
CyberChat.init('#my-chat'); CyberChat.init('#my-chat');
</script> </script>
@@ -166,6 +183,7 @@ api/stripe-webhook.php Stripe event synchronization
assets/js/cyberchat-app.js Browser application assets/js/cyberchat-app.js Browser application
lib/mailer.php Reusable Mailgun sender lib/mailer.php Reusable Mailgun sender
lib/stripe.php Stripe REST client and subscription sync lib/stripe.php Stripe REST client and subscription sync
lib/voice_transcoder.php Optional FFmpeg voice compatibility pipeline
lib/mysql_mirror.php Optional SQLite-to-MySQL mirror lib/mysql_mirror.php Optional SQLite-to-MySQL mirror
``` ```
+6
View File
@@ -1729,6 +1729,12 @@ td.actions{white-space:nowrap;width:1px}
<tr><td class="mono">voice.max_duration_seconds</td><td class="dim">integer</td><td>Maximum voice clip duration</td></tr> <tr><td class="mono">voice.max_duration_seconds</td><td class="dim">integer</td><td>Maximum voice clip duration</td></tr>
<tr><td class="mono">voice.max_upload_bytes</td><td class="dim">integer</td><td>Maximum recording upload size</td></tr> <tr><td class="mono">voice.max_upload_bytes</td><td class="dim">integer</td><td>Maximum recording upload size</td></tr>
<tr><td class="mono">voice.auto_play_default</td><td class="dim">bool</td><td>Queue and play incoming clips sequentially by default</td></tr> <tr><td class="mono">voice.auto_play_default</td><td class="dim">bool</td><td>Queue and play incoming clips sequentially by default</td></tr>
<tr><td class="mono">voice.transcoding.enabled</td><td class="dim">bool</td><td>Use FFmpeg for uploaded voice clips</td></tr>
<tr><td class="mono">voice.transcoding.ffmpeg_path</td><td class="dim">string</td><td>FFmpeg executable name or absolute path</td></tr>
<tr><td class="mono">voice.transcoding.profile</td><td class="dim">string</td><td>m4a_aac or mp4_h264_aac</td></tr>
<tr><td class="mono">voice.transcoding.audio_bitrate_kbps</td><td class="dim">integer</td><td>AAC output bitrate from 48 to 320 kbps</td></tr>
<tr><td class="mono">voice.transcoding.timeout_seconds</td><td class="dim">integer</td><td>FFmpeg upload conversion timeout</td></tr>
<tr><td class="mono">voice.transcoding.fallback_to_source</td><td class="dim">bool</td><td>Retain WebM/MP4 source when conversion fails</td></tr>
<tr><td class="mono">security.min_password_length</td><td class="dim">integer</td><td>Min password chars</td></tr> <tr><td class="mono">security.min_password_length</td><td class="dim">integer</td><td>Min password chars</td></tr>
<tr><td class="mono">security.session_timeout_hours</td><td class="dim">integer</td><td>Session expiry in hours</td></tr> <tr><td class="mono">security.session_timeout_hours</td><td class="dim">integer</td><td>Session expiry in hours</td></tr>
<tr><td class="mono">security.bcrypt_cost</td><td class="dim">integer</td><td>bcrypt work factor (1014)</td></tr> <tr><td class="mono">security.bcrypt_cost</td><td class="dim">integer</td><td>bcrypt work factor (1014)</td></tr>
+1
View File
@@ -47,6 +47,7 @@ $payload = array_map(function(array $row): array {
'id' => (int)$row['id'], 'id' => (int)$row['id'],
'message' => $row['message'], 'message_type' => $row['message_type'], 'message' => $row['message'], 'message_type' => $row['message_type'],
'voice_url' => $row['message_type'] === 'voice' && !empty($row['voice_file']) ? voicePublicPath($row['voice_file']) : null, 'voice_url' => $row['message_type'] === 'voice' && !empty($row['voice_file']) ? voicePublicPath($row['voice_file']) : null,
'voice_mime' => $row['voice_mime'] ?? null,
'voice_duration' => $row['voice_duration'] !== null ? (float)$row['voice_duration'] : null, 'voice_duration' => $row['voice_duration'] !== null ? (float)$row['voice_duration'] : null,
'created_at' => (int)$row['created_at'], 'day_key' => $row['day_key'], 'created_at' => (int)$row['created_at'], 'day_key' => $row['day_key'],
]; ];
+2
View File
@@ -21,6 +21,8 @@ jsonResponse([
'max_duration_seconds' => $config['voice']['max_duration_seconds'] ?? 60, 'max_duration_seconds' => $config['voice']['max_duration_seconds'] ?? 60,
'max_upload_bytes' => $config['voice']['max_upload_bytes'] ?? 12582912, 'max_upload_bytes' => $config['voice']['max_upload_bytes'] ?? 12582912,
'auto_play_default' => $config['voice']['auto_play_default'] ?? true, 'auto_play_default' => $config['voice']['auto_play_default'] ?? true,
'transcoding_enabled' => $config['voice']['transcoding']['enabled'] ?? false,
'output_profile' => $config['voice']['transcoding']['profile'] ?? 'm4a_aac',
], ],
'subscriptions' => ['enabled' => $config['subscriptions']['enabled'] ?? true], 'subscriptions' => ['enabled' => $config['subscriptions']['enabled'] ?? true],
]); ]);
+28 -5
View File
@@ -1,6 +1,7 @@
<?php <?php
define('CYBERCHAT_API', true); define('CYBERCHAT_API', true);
require_once __DIR__ . '/../bootstrap.php'; require_once __DIR__ . '/../bootstrap.php';
require_once ROOT_DIR . '/lib/voice_transcoder.php';
sendCorsHeaders(); sendCorsHeaders();
try { archiveYesterdayIfNeeded(); } catch (Throwable $e) { error_log($e->getMessage()); } try { archiveYesterdayIfNeeded(); } catch (Throwable $e) { error_log($e->getMessage()); }
@@ -83,26 +84,48 @@ function sendVoiceMessage(): never {
} }
$mime = class_exists('finfo') ? (string)(new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']) : ''; $mime = class_exists('finfo') ? (string)(new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']) : '';
$header = (string)file_get_contents($file['tmp_name'], false, null, 0, 12); $header = (string)file_get_contents($file['tmp_name'], false, null, 0, 16);
if (str_starts_with($header, "\x1A\x45\xDF\xA3")) $mime = 'audio/webm'; if (str_starts_with($header, "\x1A\x45\xDF\xA3")) $mime = 'audio/webm';
elseif (str_starts_with($header, 'RIFF') && substr($header, 8, 4) === 'WAVE') $mime = 'audio/wav'; elseif (str_starts_with($header, 'RIFF') && substr($header, 8, 4) === 'WAVE') $mime = 'audio/wav';
elseif (strlen($header) >= 8 && substr($header, 4, 4) === 'ftyp') $mime = 'audio/mp4';
elseif (strlen($header) >= 2 && ord($header[0]) === 0xFF && (ord($header[1]) & 0xF0) === 0xF0) $mime = 'audio/aac';
$allowed = [ $allowed = [
'audio/webm' => 'webm', 'audio/webm' => 'webm',
'video/webm' => 'webm', 'video/webm' => 'webm',
'audio/wav' => 'wav', 'audio/wav' => 'wav',
'audio/x-wav' => 'wav', 'audio/x-wav' => 'wav',
'audio/wave' => 'wav', 'audio/wave' => 'wav',
'audio/mp4' => 'm4a',
'video/mp4' => 'm4a',
'audio/aac' => 'aac',
'audio/x-aac' => 'aac',
]; ];
if (!isset($allowed[$mime])) jsonResponse(['error' => 'Voice clips must be WAV or WebM'], 400); if (!isset($allowed[$mime])) jsonResponse(['error' => 'Voice clips must be WebM, MP4/M4A, AAC, or WAV'], 400);
$dir = voiceUploadDir(); $dir = voiceUploadDir();
ensureWritableDirectory($dir, 'Voice storage'); ensureWritableDirectory($dir, 'Voice storage');
$filename = bin2hex(random_bytes(20)) . '.' . $allowed[$mime]; $token = bin2hex(random_bytes(20));
if (!move_uploaded_file($file['tmp_name'], $dir . '/' . $filename)) { $sourceFilename = $token . '.' . $allowed[$mime];
$sourcePath = $dir . '/' . $sourceFilename;
if (!move_uploaded_file($file['tmp_name'], $sourcePath)) {
jsonResponse(['error' => 'Could not store voice clip'], 500); jsonResponse(['error' => 'Could not store voice clip'], 500);
} }
$storedMime = $mime === 'video/webm' ? 'audio/webm' : $mime; $sourceMime = match ($mime) {
'video/webm' => 'audio/webm',
'video/mp4' => 'audio/mp4',
'audio/x-wav', 'audio/wave' => 'audio/wav',
'audio/x-aac' => 'audio/aac',
default => $mime,
};
try {
$stored = transcodeVoiceRecording($sourcePath, $sourceFilename, $sourceMime);
} catch (Throwable $e) {
if (is_file($sourcePath)) @unlink($sourcePath);
throw $e;
}
$filename = $stored['filename'];
$storedMime = $stored['mime'];
$db = getDB(); $db = getDB();
try { try {
$db->prepare("INSERT INTO messages $db->prepare("INSERT INTO messages
+33 -10
View File
@@ -14,7 +14,7 @@
loginChallenge: initialChallenge, loginChallenge: initialChallenge,
verifyToken: new URLSearchParams(location.search).get('verify') || '', verifyToken: new URLSearchParams(location.search).get('verify') || '',
voice: { recorder: null, stream: null, timer: null, heartbeat: null, started: 0, 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 } playQueue: [], currentAudio: null, queuePlaying: false, autoplayWarningShown: false, sending: false }
}; };
let root; let root;
@@ -89,9 +89,11 @@
function linkify(text) { function linkify(text) {
return text.replace(/(https?:\/\/[^\s<>]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>'); 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"> 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> <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> <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"> <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.id = String(id);
item.dataset.renderKey = messageRenderKey(message); item.dataset.renderKey = messageRenderKey(message);
const content = message.message_type === 'voice' && message.voice_url 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)); : linkify(esc(message.message));
item.innerHTML = `<span class="cc-msg-time">${clock(message.created_at)}</span> 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> <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'); if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) return toast('Voice recording is not supported');
try { try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mime = ['audio/webm;codecs=opus', 'audio/webm'].find(type => MediaRecorder.isTypeSupported(type)); const candidates = [
if (!mime) { stream.getTracks().forEach(track => track.stop()); return toast('WebM recording is not supported'); } '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.stream = stream;
state.voice.chunks = []; state.voice.chunks = [];
state.voice.started = Date.now(); 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('dataavailable', event => { if (event.data.size) state.voice.chunks.push(event.data); });
recorder.addEventListener('stop', () => { 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 }); }, { once: true });
recorder.start(250); recorder.start(250);
state.voice.recorder = recorder; state.voice.recorder = recorder;
@@ -680,6 +698,7 @@
function finishVoice(blob, length) { function finishVoice(blob, length) {
if (!blob.size || length < 0.25) return toast('Voice clip was too short'); if (!blob.size || length < 0.25) return toast('Voice clip was too short');
state.voice.blob = blob; state.voice.blob = blob;
state.voice.mime = blob.type || 'audio/webm';
state.voice.duration = length; state.voice.duration = length;
if (state.voice.autoSend && feature('auto_voice_send')) return sendVoice(); if (state.voice.autoSend && feature('auto_voice_send')) return sendVoice();
if (state.voice.url) URL.revokeObjectURL(state.voice.url); if (state.voice.url) URL.revokeObjectURL(state.voice.url);
@@ -694,6 +713,7 @@
if (state.voice.url) URL.revokeObjectURL(state.voice.url); if (state.voice.url) URL.revokeObjectURL(state.voice.url);
state.voice.url = ''; state.voice.url = '';
state.voice.blob = null; state.voice.blob = null;
state.voice.mime = '';
state.voice.duration = 0; state.voice.duration = 0;
if ($('#voice-review')) $('#voice-review').hidden = true; if ($('#voice-review')) $('#voice-review').hidden = true;
} }
@@ -703,7 +723,10 @@
const body = new FormData(); const body = new FormData();
body.append('action', 'send_voice'); body.append('action', 'send_voice');
body.append('duration', state.voice.duration.toFixed(2)); 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 }) const result = await api('messages.php', { method: 'POST', body })
.catch(() => ({ error: 'Voice send failed. Polling will continue.' })); .catch(() => ({ error: 'Voice send failed. Polling will continue.' }));
state.voice.sending = false; state.voice.sending = false;
@@ -729,7 +752,7 @@
} }
function archiveRow(message) { function archiveRow(message) {
const content = message.message_type === 'voice' && message.voice_url 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> 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>`; <span class="cc-type-tag">${esc(message.message_type)}</span><div>${content}</div></div>`;
} }
+9 -1
View File
@@ -24,7 +24,15 @@
"max_duration_seconds": 60, "max_duration_seconds": 60,
"max_upload_bytes": 12582912, "max_upload_bytes": 12582912,
"auto_play_default": true, "auto_play_default": true,
"upload_dir": "uploads/voice" "upload_dir": "uploads/voice",
"transcoding": {
"enabled": false,
"ffmpeg_path": "ffmpeg",
"profile": "m4a_aac",
"audio_bitrate_kbps": 128,
"timeout_seconds": 45,
"fallback_to_source": true
}
}, },
"database": { "database": {
"main_db": "db/chat.sqlite", "main_db": "db/chat.sqlite",
+2 -2
View File
@@ -94,7 +94,7 @@
<span class="kw">&lt;div</span> id=<span class="str">"my-chat"</span> style=<span class="str">"width:100%; height:600px"</span><span class="kw">&gt;&lt;/div&gt;</span> <span class="kw">&lt;div</span> id=<span class="str">"my-chat"</span> style=<span class="str">"width:100%; height:600px"</span><span class="kw">&gt;&lt;/div&gt;</span>
<span class="cm">&lt;!-- 4. Script + init --&gt;</span> <span class="cm">&lt;!-- 4. Script + init --&gt;</span>
<span class="kw">&lt;script</span> src=<span class="str">"assets/js/cyberchat-app.js?v=20260609.5"</span><span class="kw">&gt;&lt;/script&gt;</span> <span class="kw">&lt;script</span> src=<span class="str">"assets/js/cyberchat-app.js?v=20260609.6"</span><span class="kw">&gt;&lt;/script&gt;</span>
<span class="kw">&lt;script&gt;</span> <span class="kw">&lt;script&gt;</span>
window.CYBERCHAT_BASE = <span class="str">'/path/to/cyberchat'</span>; <span class="cm">// adjust to your install path</span> window.CYBERCHAT_BASE = <span class="str">'/path/to/cyberchat'</span>; <span class="cm">// adjust to your install path</span>
CyberChat.init(<span class="str">'#my-chat'</span>); CyberChat.init(<span class="str">'#my-chat'</span>);
@@ -104,7 +104,7 @@
<script> <script>
window.CYBERCHAT_BASE = ''; window.CYBERCHAT_BASE = '';
</script> </script>
<script src="assets/js/cyberchat-app.js?v=20260609.5"></script> <script src="assets/js/cyberchat-app.js?v=20260609.6"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
CyberChat.init('#chat-here'); CyberChat.init('#chat-here');
+1 -1
View File
@@ -17,7 +17,7 @@
<script> <script>
window.CYBERCHAT_BASE = ''; window.CYBERCHAT_BASE = '';
</script> </script>
<script src="assets/js/cyberchat-app.js?v=20260609.5"></script> <script src="assets/js/cyberchat-app.js?v=20260609.6"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
CyberChat.init('#app'); CyberChat.init('#app');
+34
View File
@@ -2,6 +2,8 @@
function handleAdminPlatformAction(string $action): ?string { function handleAdminPlatformAction(string $action): ?string {
if ($action === 'save_platform_services') { if ($action === 'save_platform_services') {
$voiceProfile = (string)($_POST['voice_transcoding_profile'] ?? 'm4a_aac');
if (!in_array($voiceProfile, ['m4a_aac', 'mp4_h264_aac'], true)) $voiceProfile = 'm4a_aac';
setConfigValues([ setConfigValues([
'subscriptions.enabled' => !empty($_POST['subscriptions_enabled']), 'subscriptions.enabled' => !empty($_POST['subscriptions_enabled']),
'mailgun.api_key' => trim((string)($_POST['mailgun_api_key'] ?? '')), 'mailgun.api_key' => trim((string)($_POST['mailgun_api_key'] ?? '')),
@@ -13,6 +15,12 @@ function handleAdminPlatformAction(string $action): ?string {
'stripe.success_url' => trim((string)($_POST['stripe_success_url'] ?? '')), 'stripe.success_url' => trim((string)($_POST['stripe_success_url'] ?? '')),
'stripe.cancel_url' => trim((string)($_POST['stripe_cancel_url'] ?? '')), 'stripe.cancel_url' => trim((string)($_POST['stripe_cancel_url'] ?? '')),
'stripe.portal_return_url' => trim((string)($_POST['stripe_portal_return_url'] ?? '')), 'stripe.portal_return_url' => trim((string)($_POST['stripe_portal_return_url'] ?? '')),
'voice.transcoding.enabled' => !empty($_POST['voice_transcoding_enabled']),
'voice.transcoding.ffmpeg_path' => trim((string)($_POST['voice_ffmpeg_path'] ?? 'ffmpeg')) ?: 'ffmpeg',
'voice.transcoding.profile' => $voiceProfile,
'voice.transcoding.audio_bitrate_kbps' => max(48, min(320, (int)($_POST['voice_audio_bitrate'] ?? 128))),
'voice.transcoding.timeout_seconds' => max(5, min(300, (int)($_POST['voice_transcoding_timeout'] ?? 45))),
'voice.transcoding.fallback_to_source' => !empty($_POST['voice_transcoding_fallback']),
'database.mysql.enabled' => !empty($_POST['mysql_enabled']), 'database.mysql.enabled' => !empty($_POST['mysql_enabled']),
'database.mysql.auto_import' => !empty($_POST['mysql_auto_import']), 'database.mysql.auto_import' => !empty($_POST['mysql_auto_import']),
'database.mysql.dsn' => trim((string)($_POST['mysql_dsn'] ?? '')), 'database.mysql.dsn' => trim((string)($_POST['mysql_dsn'] ?? '')),
@@ -158,6 +166,32 @@ function renderAdminPlatformTab(): void {
<p class="dim">Webhook endpoint: <span class="mono"><?= esc(appBaseUrl() . '/api/stripe-webhook.php') ?></span></p> <p class="dim">Webhook endpoint: <span class="mono"><?= esc(appBaseUrl() . '/api/stripe-webhook.php') ?></span></p>
</div></div> </div></div>
<div class="panel"><div class="panel-head"><span class="panel-title">// VOICE TRANSCODING / FFMPEG</span></div><div class="panel-body">
<label class="cb-row"><input type="checkbox" name="voice_transcoding_enabled"
<?= getConfigVal('voice.transcoding.enabled', false) ? 'checked' : '' ?>>
<span>Transcode uploaded voice clips for broader playback compatibility</span></label>
<div class="form-row3">
<div class="field"><label>FFmpeg Binary</label><input name="voice_ffmpeg_path"
value="<?= esc(getConfigVal('voice.transcoding.ffmpeg_path', 'ffmpeg')) ?>" placeholder="/usr/bin/ffmpeg"></div>
<div class="field"><label>Output Profile</label><select name="voice_transcoding_profile">
<?php $voiceProfile = getConfigVal('voice.transcoding.profile', 'm4a_aac'); ?>
<option value="m4a_aac" <?= $voiceProfile === 'm4a_aac' ? 'selected' : '' ?>>M4A / AAC-LC (recommended)</option>
<option value="mp4_h264_aac" <?= $voiceProfile === 'mp4_h264_aac' ? 'selected' : '' ?>>MP4 / H.264 + AAC</option>
</select></div>
<div class="field"><label>Audio Bitrate (kbps)</label><input type="number" min="48" max="320"
name="voice_audio_bitrate" value="<?= (int)getConfigVal('voice.transcoding.audio_bitrate_kbps', 128) ?>"></div>
</div>
<div class="form-row">
<div class="field"><label>Conversion Timeout (seconds)</label><input type="number" min="5" max="300"
name="voice_transcoding_timeout" value="<?= (int)getConfigVal('voice.transcoding.timeout_seconds', 45) ?>"></div>
<label class="cb-row"><input type="checkbox" name="voice_transcoding_fallback"
<?= getConfigVal('voice.transcoding.fallback_to_source', true) ? 'checked' : '' ?>>
<span>Keep the original recording if FFmpeg fails</span></label>
</div>
<p class="dim">AAC-LC in an M4A container is the recommended audio-only profile for iPhone, Safari, Android, and desktop browsers.
The H.264 profile creates a tiny black video track plus AAC audio for systems that require a conventional MP4 file.</p>
</div></div>
<div class="panel"><div class="panel-head"><span class="panel-title">// OPTIONAL MYSQL / MARIADB MIRROR</span></div><div class="panel-body"> <div class="panel"><div class="panel-head"><span class="panel-title">// OPTIONAL MYSQL / MARIADB MIRROR</span></div><div class="panel-body">
<label class="cb-row"><input type="checkbox" name="mysql_enabled" <?= getConfigVal('database.mysql.enabled', false) ? 'checked' : '' ?>><span>Enable MySQL mirror</span></label> <label class="cb-row"><input type="checkbox" name="mysql_enabled" <?= getConfigVal('database.mysql.enabled', false) ? 'checked' : '' ?>><span>Enable MySQL mirror</span></label>
<label class="cb-row"><input type="checkbox" name="mysql_auto_import" <?= getConfigVal('database.mysql.auto_import', false) ? 'checked' : '' ?>><span>Automatically import all SQLite databases on the configured interval</span></label> <label class="cb-row"><input type="checkbox" name="mysql_auto_import" <?= getConfigVal('database.mysql.auto_import', false) ? 'checked' : '' ?>><span>Automatically import all SQLite databases on the configured interval</span></label>
+160
View File
@@ -0,0 +1,160 @@
<?php
function voiceTranscodingProfile(): string {
$profile = (string)getConfigVal('voice.transcoding.profile', 'm4a_aac');
return in_array($profile, ['m4a_aac', 'mp4_h264_aac'], true) ? $profile : 'm4a_aac';
}
function voiceFfmpegBinary(): string {
$binary = trim((string)getConfigVal('voice.transcoding.ffmpeg_path', 'ffmpeg'));
if ($binary === '' || preg_match('/[\x00-\x1F\x7F]/', $binary)) {
throw new RuntimeException('The FFmpeg binary path is invalid.');
}
return $binary;
}
function runVoiceTranscodeCommand(array $command, int $timeoutSeconds): array {
if (!function_exists('proc_open')) {
return ['exit_code' => 127, 'stderr' => 'PHP proc_open is unavailable.'];
}
$process = @proc_open($command, [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
], $pipes, null, null, ['bypass_shell' => true]);
if (!is_resource($process)) {
return ['exit_code' => 127, 'stderr' => 'FFmpeg could not be started.'];
}
fclose($pipes[0]);
stream_set_blocking($pipes[1], false);
stream_set_blocking($pipes[2], false);
$stdout = '';
$stderr = '';
$started = microtime(true);
$exitCode = -1;
while (true) {
$stdout .= (string)stream_get_contents($pipes[1]);
$stderr .= (string)stream_get_contents($pipes[2]);
$status = proc_get_status($process);
if (!$status['running']) {
$exitCode = (int)$status['exitcode'];
break;
}
if ((microtime(true) - $started) >= $timeoutSeconds) {
proc_terminate($process);
usleep(100000);
$status = proc_get_status($process);
if ($status['running']) proc_terminate($process, 9);
$exitCode = 124;
$stderr .= "\nFFmpeg timed out.";
break;
}
usleep(50000);
}
$stdout .= (string)stream_get_contents($pipes[1]);
$stderr .= (string)stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
$closeCode = proc_close($process);
if ($exitCode < 0) $exitCode = $closeCode;
return [
'exit_code' => $exitCode,
'stdout' => substr($stdout, -4000),
'stderr' => substr($stderr, -4000),
];
}
function transcodeVoiceRecording(
string $sourcePath,
string $sourceFilename,
string $sourceMime
): array {
if (!getConfigVal('voice.transcoding.enabled', false)) {
return ['filename' => $sourceFilename, 'mime' => $sourceMime, 'transcoded' => false];
}
$profile = voiceTranscodingProfile();
$bitrate = max(48, min(320, (int)getConfigVal('voice.transcoding.audio_bitrate_kbps', 128)));
$timeout = max(5, min(300, (int)getConfigVal('voice.transcoding.timeout_seconds', 45)));
$fallback = (bool)getConfigVal('voice.transcoding.fallback_to_source', true);
$token = pathinfo($sourceFilename, PATHINFO_FILENAME);
if (!preg_match('/^[a-f0-9]{40}$/', $token)) {
throw new RuntimeException('Voice recording filename is invalid.');
}
$extension = $profile === 'mp4_h264_aac' ? 'mp4' : 'm4a';
$outputMime = $profile === 'mp4_h264_aac' ? 'video/mp4' : 'audio/mp4';
$outputFilename = $token . '.' . $extension;
$outputPath = dirname($sourcePath) . '/' . $outputFilename;
$temporaryPath = dirname($sourcePath) . '/.' . $token . '.tmp.' . $extension;
$binary = voiceFfmpegBinary();
if ($profile === 'mp4_h264_aac') {
$command = [
$binary, '-nostdin', '-hide_banner', '-loglevel', 'error', '-y',
'-f', 'lavfi', '-i', 'color=c=black:s=16x16:r=1',
'-i', $sourcePath,
'-map', '0:v:0', '-map', '1:a:0', '-shortest',
'-c:v', 'libx264', '-preset', 'veryfast', '-tune', 'stillimage',
'-profile:v', 'baseline', '-level', '3.0', '-pix_fmt', 'yuv420p',
'-c:a', 'aac', '-profile:a', 'aac_low', '-b:a', $bitrate . 'k',
'-map_metadata', '-1', '-movflags', '+faststart', $temporaryPath,
];
} else {
$command = [
$binary, '-nostdin', '-hide_banner', '-loglevel', 'error', '-y',
'-i', $sourcePath, '-vn',
'-c:a', 'aac', '-profile:a', 'aac_low', '-b:a', $bitrate . 'k',
'-map_metadata', '-1', '-movflags', '+faststart', $temporaryPath,
];
}
$result = runVoiceTranscodeCommand($command, $timeout);
$validOutput = $result['exit_code'] === 0
&& is_file($temporaryPath)
&& filesize($temporaryPath) > 0;
if (!$validOutput) {
if (is_file($temporaryPath)) @unlink($temporaryPath);
$detail = trim((string)($result['stderr'] ?? ''));
error_log('Voice transcoding failed: ' . ($detail !== '' ? $detail : 'unknown FFmpeg error'));
if ($fallback) {
return ['filename' => $sourceFilename, 'mime' => $sourceMime, 'transcoded' => false];
}
throw new RuntimeException('Voice transcoding failed. Check the FFmpeg dashboard settings.');
}
$sourceBackupPath = null;
if ($outputPath === $sourcePath) {
$sourceBackupPath = dirname($sourcePath) . '/.' . $token . '.source.' . $extension;
if (!@rename($sourcePath, $sourceBackupPath)) {
@unlink($temporaryPath);
if ($fallback) {
return ['filename' => $sourceFilename, 'mime' => $sourceMime, 'transcoded' => false];
}
throw new RuntimeException('The original voice clip could not be prepared for replacement.');
}
}
if (!@rename($temporaryPath, $outputPath)) {
@unlink($temporaryPath);
if ($sourceBackupPath !== null && is_file($sourceBackupPath)) {
@rename($sourceBackupPath, $sourcePath);
}
if ($fallback && is_file($sourcePath)) {
return ['filename' => $sourceFilename, 'mime' => $sourceMime, 'transcoded' => false];
}
throw new RuntimeException('The transcoded voice clip could not be stored.');
}
if ($sourceBackupPath !== null) {
@unlink($sourceBackupPath);
} else {
@unlink($sourcePath);
}
return ['filename' => $outputFilename, 'mime' => $outputMime, 'transcoded' => true];
}
+20
View File
@@ -39,6 +39,26 @@ server {
return 404; return 404;
} }
location ~* ^/uploads/voice/.*\.m4a$ {
default_type audio/mp4;
try_files $uri =404;
}
location ~* ^/uploads/voice/.*\.aac$ {
default_type audio/aac;
try_files $uri =404;
}
location ~* ^/uploads/voice/.*\.webm$ {
default_type audio/webm;
try_files $uri =404;
}
location ~* ^/uploads/voice/.*\.mp4$ {
default_type video/mp4;
try_files $uri =404;
}
# PHP files # PHP files
location ~ \.php$ { location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.1-fpm.sock; # adjust PHP version fastcgi_pass unix:/run/php/php8.1-fpm.sock; # adjust PHP version