From 6217a216a7aee2997412613b50e527e9cf6d9d5a Mon Sep 17 00:00:00 2001 From: Ty Clifford Date: Tue, 9 Jun 2026 02:21:42 -0400 Subject: [PATCH] =?UTF-8?q?-=20Implemented.=20Safari/iPhone=20recording=20?= =?UTF-8?q?now=20uses=20MP4/AAC=20when=20WebM=20is=20unavailable.=20WebM?= =?UTF-8?q?=20remains=20the=20default=20when=20supported=20and=20FFmpeg=20?= =?UTF-8?q?is=20disabled.=20Added=20optional=20FFmpeg=20profiles:=20recomm?= =?UTF-8?q?ended=20M4A/AAC-LC=20or=20requested=20MP4/H.264=20+=20AAC.=20Ad?= =?UTF-8?q?ded=20MIME-aware,=20playsinline=20audio=20playback.=20Added=20d?= =?UTF-8?q?ashboard=20controls=20under=20Plans=20&=20Platform=20=E2=86=92?= =?UTF-8?q?=20Voice=20Transcoding=20/=20FFmpeg.=20Added=20Apache/Nginx=20m?= =?UTF-8?q?edia=20MIME=20configuration=20and=20documentation.=20Core=20imp?= =?UTF-8?q?lementation:=20voice=5Ftranscoder.php=20(line=2072),=20messages?= =?UTF-8?q?.php=20(line=2086),=20and=20cyberchat-app.js=20(line=20635).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .htaccess | 9 +++ README.md | 22 ++++- admin.php | 6 ++ api/archive.php | 1 + api/config.php | 2 + api/messages.php | 33 ++++++-- assets/js/cyberchat-app.js | 43 +++++++--- config.json | 10 ++- embed-example.html | 4 +- index.html | 2 +- lib/admin_platform.php | 34 ++++++++ lib/voice_transcoder.php | 160 +++++++++++++++++++++++++++++++++++++ nginx-example.conf | 20 +++++ 13 files changed, 325 insertions(+), 21 deletions(-) create mode 100644 lib/voice_transcoder.php diff --git a/.htaccess b/.htaccess index 41521d2..e75f798 100644 --- a/.htaccess +++ b/.htaccess @@ -20,6 +20,15 @@ +# Voice playback MIME types, including Safari/iPhone-compatible AAC in MP4. + + AddType audio/webm .webm + AddType audio/wav .wav + AddType audio/aac .aac + AddType audio/mp4 .m4a + AddType video/mp4 .mp4 + + # Deny direct access to storage and internal PHP libraries RewriteEngine On diff --git a/README.md b/README.md index dd6c208..dbe1224 100644 --- a/README.md +++ b/README.md @@ -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: - - +## 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 - + @@ -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 ``` diff --git a/admin.php b/admin.php index 8405866..450db21 100644 --- a/admin.php +++ b/admin.php @@ -1729,6 +1729,12 @@ td.actions{white-space:nowrap;width:1px} voice.max_duration_secondsintegerMaximum voice clip duration voice.max_upload_bytesintegerMaximum recording upload size voice.auto_play_defaultboolQueue and play incoming clips sequentially by default + voice.transcoding.enabledboolUse FFmpeg for uploaded voice clips + voice.transcoding.ffmpeg_pathstringFFmpeg executable name or absolute path + voice.transcoding.profilestringm4a_aac or mp4_h264_aac + voice.transcoding.audio_bitrate_kbpsintegerAAC output bitrate from 48 to 320 kbps + voice.transcoding.timeout_secondsintegerFFmpeg upload conversion timeout + voice.transcoding.fallback_to_sourceboolRetain WebM/MP4 source when conversion fails security.min_password_lengthintegerMin password chars security.session_timeout_hoursintegerSession expiry in hours security.bcrypt_costintegerbcrypt work factor (10–14) diff --git a/api/archive.php b/api/archive.php index 66d71d2..a6334f5 100644 --- a/api/archive.php +++ b/api/archive.php @@ -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'], ]; diff --git a/api/config.php b/api/config.php index 8598994..08d30e1 100644 --- a/api/config.php +++ b/api/config.php @@ -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], ]); diff --git a/api/messages.php b/api/messages.php index 607493a..810e31e 100644 --- a/api/messages.php +++ b/api/messages.php @@ -1,6 +1,7 @@ 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 diff --git a/assets/js/cyberchat-app.js b/assets/js/cyberchat-app.js index 7aba904..93284c6 100644 --- a/assets/js/cyberchat-app.js +++ b/assets/js/cyberchat-app.js @@ -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, '$1'); } - function audioPlayer(src = '', id = '') { + function audioPlayer(src = '', id = '', mime = '') { return ` - + @@ -565,7 +567,7 @@ item.dataset.id = String(id); item.dataset.renderKey = messageRenderKey(message); const content = message.message_type === 'voice' && message.voice_url - ? `VOICE ${duration(message.voice_duration)}${audioPlayer(publicUrl(message.voice_url))}` + ? `VOICE ${duration(message.voice_duration)}${audioPlayer(publicUrl(message.voice_url), '', message.voice_mime)}` : linkify(esc(message.message)); item.innerHTML = `${clock(message.created_at)} ${esc(message.username)} @@ -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 `
${esc(message.message_type)}
${content}
`; } diff --git a/config.json b/config.json index 5460c55..42a9d92 100644 --- a/config.json +++ b/config.json @@ -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", diff --git a/embed-example.html b/embed-example.html index afa2c40..9116571 100644 --- a/embed-example.html +++ b/embed-example.html @@ -94,7 +94,7 @@ <div id="my-chat" style="width:100%; height:600px"></div> <!-- 4. Script + init --> -<script src="assets/js/cyberchat-app.js?v=20260609.5"></script> +<script src="assets/js/cyberchat-app.js?v=20260609.6"></script> <script> window.CYBERCHAT_BASE = '/path/to/cyberchat'; // adjust to your install path CyberChat.init('#my-chat'); @@ -104,7 +104,7 @@ - + - +