- 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>
|
</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
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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 (10–14)</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'],
|
'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'],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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="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="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>
|
<span class="kw"><script></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
@@ -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');
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user