- 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:
@@ -20,6 +20,15 @@
|
||||
</FilesMatch>
|
||||
</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
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
|
||||
@@ -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 automatic voice sending
|
||||
- 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
|
||||
- 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
|
||||
@@ -29,6 +30,7 @@ CyberChat is a framework-free PHP, SQLite, JavaScript, and HTML5 chat applicatio
|
||||
- A web server with HTTPS for production
|
||||
- Write access to `db/`, `archive/`, `uploads/voice/`, and `config.json`
|
||||
- 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
|
||||
|
||||
## 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`.
|
||||
4. Open `admin.php` and sign in with the initial password `changeme123`.
|
||||
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.
|
||||
|
||||
@@ -115,6 +117,21 @@ Stripe references:
|
||||
- <https://docs.stripe.com/api/checkout/sessions/create>
|
||||
- <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
|
||||
|
||||
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>
|
||||
window.CYBERCHAT_BASE = '/chat';
|
||||
</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>
|
||||
CyberChat.init('#my-chat');
|
||||
</script>
|
||||
@@ -166,6 +183,7 @@ api/stripe-webhook.php Stripe event synchronization
|
||||
assets/js/cyberchat-app.js Browser application
|
||||
lib/mailer.php Reusable Mailgun sender
|
||||
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
|
||||
```
|
||||
|
||||
|
||||
@@ -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_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.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.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 (10–14)</td></tr>
|
||||
|
||||
@@ -47,6 +47,7 @@ $payload = array_map(function(array $row): array {
|
||||
'id' => (int)$row['id'],
|
||||
'message' => $row['message'], 'message_type' => $row['message_type'],
|
||||
'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,
|
||||
'created_at' => (int)$row['created_at'], 'day_key' => $row['day_key'],
|
||||
];
|
||||
|
||||
@@ -21,6 +21,8 @@ jsonResponse([
|
||||
'max_duration_seconds' => $config['voice']['max_duration_seconds'] ?? 60,
|
||||
'max_upload_bytes' => $config['voice']['max_upload_bytes'] ?? 12582912,
|
||||
'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],
|
||||
]);
|
||||
|
||||
+28
-5
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
define('CYBERCHAT_API', true);
|
||||
require_once __DIR__ . '/../bootstrap.php';
|
||||
require_once ROOT_DIR . '/lib/voice_transcoder.php';
|
||||
sendCorsHeaders();
|
||||
|
||||
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']) : '';
|
||||
$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';
|
||||
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 = [
|
||||
'audio/webm' => 'webm',
|
||||
'video/webm' => 'webm',
|
||||
'audio/wav' => 'wav',
|
||||
'audio/x-wav' => '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();
|
||||
ensureWritableDirectory($dir, 'Voice storage');
|
||||
$filename = bin2hex(random_bytes(20)) . '.' . $allowed[$mime];
|
||||
if (!move_uploaded_file($file['tmp_name'], $dir . '/' . $filename)) {
|
||||
$token = bin2hex(random_bytes(20));
|
||||
$sourceFilename = $token . '.' . $allowed[$mime];
|
||||
$sourcePath = $dir . '/' . $sourceFilename;
|
||||
if (!move_uploaded_file($file['tmp_name'], $sourcePath)) {
|
||||
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();
|
||||
try {
|
||||
$db->prepare("INSERT INTO messages
|
||||
|
||||
+33
-10
@@ -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>`;
|
||||
}
|
||||
|
||||
+9
-1
@@ -24,7 +24,15 @@
|
||||
"max_duration_seconds": 60,
|
||||
"max_upload_bytes": 12582912,
|
||||
"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": {
|
||||
"main_db": "db/chat.sqlite",
|
||||
|
||||
+2
-2
@@ -94,7 +94,7 @@
|
||||
<span class="kw"><div</span> id=<span class="str">"my-chat"</span> style=<span class="str">"width:100%; height:600px"</span><span class="kw">></div></span>
|
||||
|
||||
<span class="cm"><!-- 4. Script + init --></span>
|
||||
<span class="kw"><script</span> src=<span class="str">"assets/js/cyberchat-app.js?v=20260609.5"</span><span class="kw">></script></span>
|
||||
<span class="kw"><script</span> src=<span class="str">"assets/js/cyberchat-app.js?v=20260609.6"</span><span class="kw">></script></span>
|
||||
<span class="kw"><script></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>);
|
||||
@@ -104,7 +104,7 @@
|
||||
<script>
|
||||
window.CYBERCHAT_BASE = '';
|
||||
</script>
|
||||
<script src="assets/js/cyberchat-app.js?v=20260609.5"></script>
|
||||
<script src="assets/js/cyberchat-app.js?v=20260609.6"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
CyberChat.init('#chat-here');
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@
|
||||
<script>
|
||||
window.CYBERCHAT_BASE = '';
|
||||
</script>
|
||||
<script src="assets/js/cyberchat-app.js?v=20260609.5"></script>
|
||||
<script src="assets/js/cyberchat-app.js?v=20260609.6"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
CyberChat.init('#app');
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
function handleAdminPlatformAction(string $action): ?string {
|
||||
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([
|
||||
'subscriptions.enabled' => !empty($_POST['subscriptions_enabled']),
|
||||
'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.cancel_url' => trim((string)($_POST['stripe_cancel_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.auto_import' => !empty($_POST['mysql_auto_import']),
|
||||
'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>
|
||||
</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">
|
||||
<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>
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -39,6 +39,26 @@ server {
|
||||
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
|
||||
location ~ \.php$ {
|
||||
fastcgi_pass unix:/run/php/php8.1-fpm.sock; # adjust PHP version
|
||||
|
||||
Reference in New Issue
Block a user