- 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
+160
View File
@@ -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];
}