eb57cde99f
Per-tier audio bitrate: Free 64, Plus 96, Pro 128 kbps. Configurable text/voice reply threads and depth. Registration/login-only honeypot and internal CAPTCHA. Optional Google reCAPTCHA v2/v3 with server verification. Archive/export support for replies and bitrate. Dashboard controls for all settings. Verified JavaScript, JSON, SQLite migrations, desktop/mobile UI, and browser console. Native PHP lint was unavailable because PHP is not installed. Google implementation follows Siteverify and reCAPTCHA v3.
163 lines
6.2 KiB
PHP
163 lines
6.2 KiB
PHP
<?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,
|
|
?int $bitrateOverride = null
|
|
): array {
|
|
if (!getConfigVal('voice.transcoding.enabled', false)) {
|
|
return ['filename' => $sourceFilename, 'mime' => $sourceMime, 'transcoded' => false];
|
|
}
|
|
|
|
$profile = voiceTranscodingProfile();
|
|
$bitrate = max(48, min(320, $bitrateOverride
|
|
?? (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];
|
|
}
|