- 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
+1
View File
@@ -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'],
];
+2
View File
@@ -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
View File
@@ -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