- 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:
@@ -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];
|
||||
}
|
||||
Reference in New Issue
Block a user