164 lines
11 KiB
PHP
164 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
namespace app\common\service\followupaudio;
|
|
|
|
/** Bounded local decoding; chunk offsets are real coverage boundaries, not word alignment. */
|
|
final class FollowupAudioPipelineMedia
|
|
{
|
|
public const SILENCE_POLICY = 'exact-zero-pcm-v1';
|
|
private array $settings;
|
|
public function __construct(array $settings) { $this->settings = $settings; }
|
|
/** One budget for planning, source validation and checkpoints, before any supplier request. */
|
|
public static function assertChunkPlan(float $durationSeconds, int $chunkMs, int $channels = 1): int
|
|
{
|
|
if (!in_array($channels, [1, 2], true)) { throw new FollowupAudioException('AUDIO_CHANNELS_UNSUPPORTED'); }
|
|
if (!is_finite($durationSeconds) || $durationSeconds <= 0 || $durationSeconds > 3600
|
|
|| $chunkMs < 1000 || $chunkMs > ($channels === 2 ? 30000 : 120000)) { throw new FollowupAudioException('AUDIO_INVALID'); }
|
|
$count = (int) ceil(ceil($durationSeconds * 1000) / $chunkMs) * $channels;
|
|
if ($count > FollowupAudioTranscriptPrompt::MAX_SEGMENTS) { throw new FollowupAudioException('ASR_SEGMENT_LIMIT'); }
|
|
return $count;
|
|
}
|
|
|
|
public function inspect(string $path, string $sha256, bool $processing = false): array
|
|
{
|
|
$real = realpath($path);
|
|
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
|
$mimes = ['wav' => 'audio/wav', 'mp3' => 'audio/mpeg', 'm4a' => 'audio/mp4', 'amr' => 'audio/amr'];
|
|
if (!$real || is_link($path) || !is_file($real) || !is_readable($real) || !isset($mimes[$extension])
|
|
|| filesize($real) <= 0 || filesize($real) > min(524288000, (int) ($this->settings['max_bytes'] ?? 524288000))
|
|
|| !preg_match('/^[a-f0-9]{64}$/D', $sha256) || !hash_equals($sha256, (string) hash_file('sha256', $real))) {
|
|
throw new FollowupAudioException('AUDIO_INVALID');
|
|
}
|
|
$arguments = [(string) ($this->settings['ffprobe'] ?? 'ffprobe'), '-v', 'error', '-protocol_whitelist', 'file,pipe'];
|
|
if ($extension === 'amr') { $arguments[] = '-count_packets'; }
|
|
$process = @proc_open(array_merge($arguments, ['-show_entries',
|
|
'format=duration,format_name:stream=codec_type,nb_read_packets,channels', '-of', 'json', $real]), [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
|
|
if (!is_resource($process)) { throw new FollowupAudioException('FFPROBE_UNAVAILABLE'); }
|
|
fclose($pipes[0]);
|
|
stream_set_blocking($pipes[1], false);
|
|
stream_set_blocking($pipes[2], false);
|
|
$body = '';
|
|
$deadline = microtime(true) + 15;
|
|
$exit = -1;
|
|
do {
|
|
$body .= (string) stream_get_contents($pipes[1]);
|
|
stream_get_contents($pipes[2]); // Never expose filenames or parser messages.
|
|
$state = proc_get_status($process);
|
|
if (!$state['running']) { $exit = $state['exitcode']; break; }
|
|
if (strlen($body) > 65536 || microtime(true) > $deadline) { proc_terminate($process, 9); break; }
|
|
usleep(10000);
|
|
} while (true);
|
|
$body .= (string) stream_get_contents($pipes[1]);
|
|
fclose($pipes[1]); fclose($pipes[2]);
|
|
$closed = proc_close($process);
|
|
if ($exit < 0) { $exit = $closed; }
|
|
$metadata = json_decode($body, true);
|
|
$streams = $metadata['streams'] ?? [];
|
|
$duration = (float) ($metadata['format']['duration'] ?? 0);
|
|
// AMR demuxer bitrate estimates drift on long files; every complete AMR packet represents 20 ms.
|
|
if ($extension === 'amr' && count($streams) === 1 && (int) ($streams[0]['nb_read_packets'] ?? 0) > 0) {
|
|
$duration = (int) $streams[0]['nb_read_packets'] * 0.02;
|
|
}
|
|
if ($exit !== 0 || !is_array($streams) || count($streams) !== 1 || !is_finite($duration) || $duration <= 0
|
|
|| !in_array($extension, explode(',', (string) ($metadata['format']['format_name'] ?? '')), true)
|
|
|| $duration > min(3600, (float) ($this->settings['max_seconds'] ?? 3600)) + ($processing ? 0.25 : 0)
|
|
|| array_filter($streams, static fn ($stream): bool => !is_array($stream) || ($stream['codec_type'] ?? '') !== 'audio')) {
|
|
throw new FollowupAudioException('AUDIO_INVALID');
|
|
}
|
|
$channels = (int) ($streams[0]['channels'] ?? 0);
|
|
if (!in_array($channels, [1, 2], true)) { throw new FollowupAudioException('AUDIO_CHANNELS_UNSUPPORTED'); }
|
|
if (!$processing) {
|
|
$seconds = $channels === 2 ? (int) ($this->settings['asr_stereo_chunk_seconds'] ?? 15) : (int) ($this->settings['asr_chunk_seconds'] ?? 120);
|
|
self::assertChunkPlan($duration, $seconds * 1000, $channels);
|
|
}
|
|
return ['path' => $real, 'extension' => $extension, 'mime' => $mimes[$extension], 'duration' => $duration, 'channels' => (int) ($streams[0]['channels'] ?? 0)];
|
|
}
|
|
|
|
|
|
/** Only exact zero PCM samples qualify. Low energy and silent-looking containers do not. */
|
|
public static function digitalSilence(string $path): ?array
|
|
{
|
|
$size = (int) filesize($path);
|
|
if ($size < 44 || $size > 8388608 || is_link($path)) { throw new FollowupAudioException('AUDIO_NORMALIZATION_FAILED'); }
|
|
$handle = fopen($path, 'rb');
|
|
if (!$handle) { throw new FollowupAudioException('AUDIO_NORMALIZATION_FAILED'); }
|
|
try {
|
|
$header = fread($handle, 12);
|
|
if (substr($header, 0, 4) !== 'RIFF' || substr($header, 8, 4) !== 'WAVE') { throw new FollowupAudioException('AUDIO_NORMALIZATION_FAILED'); }
|
|
$format = false; $dataCount = 0; $bytes = 0; $zero = true; $hash = hash_init('sha256');
|
|
while (ftell($handle) + 8 <= $size) {
|
|
$entry = fread($handle, 8); $length = unpack('V', substr($entry, 4, 4))[1];
|
|
if ($length < 0 || ftell($handle) + $length > $size) { throw new FollowupAudioException('AUDIO_NORMALIZATION_FAILED'); }
|
|
$tag = substr($entry, 0, 4);
|
|
if ($tag === 'fmt ') {
|
|
if ($length < 16 || $length > 128) { throw new FollowupAudioException('AUDIO_NORMALIZATION_FAILED'); }
|
|
$fmt = unpack('vencoding/vchannels/Vrate/Vbytes/vblock/vbits', substr(fread($handle, $length), 0, 16));
|
|
$format = $fmt === ['encoding' => 1, 'channels' => 1, 'rate' => 16000, 'bytes' => 32000, 'block' => 2, 'bits' => 16];
|
|
} elseif ($tag === 'data') {
|
|
$dataCount++; $bytes += $length;
|
|
for ($remaining = $length; $remaining > 0;) {
|
|
$block = fread($handle, min(65536, $remaining));
|
|
if ($block === '') { throw new FollowupAudioException('AUDIO_NORMALIZATION_FAILED'); }
|
|
$remaining -= strlen($block); hash_update($hash, $block);
|
|
if (strspn($block, "\0") !== strlen($block)) { $zero = false; }
|
|
}
|
|
} else { fseek($handle, $length, SEEK_CUR); }
|
|
if ($length % 2) { fseek($handle, 1, SEEK_CUR); }
|
|
}
|
|
if (!$format || $dataCount !== 1 || $bytes < 2 || $bytes % 2) { throw new FollowupAudioException('AUDIO_NORMALIZATION_FAILED'); }
|
|
return $zero ? ['pcm_sha256' => hash_final($hash), 'pcm_bytes' => $bytes] : null;
|
|
} finally { fclose($handle); }
|
|
}
|
|
|
|
public function chunk(array $audio, array $segment, string $output, callable $heartbeat): void
|
|
{
|
|
$channels = (int) ($audio['channels'] ?? 0);
|
|
if (!in_array($channels, [1, 2], true)) { throw new FollowupAudioException('AUDIO_CHANNELS_UNSUPPORTED'); }
|
|
if ($channels === 2 && (!isset($segment['channel']) || !is_int($segment['channel']) || !in_array($segment['channel'], [0, 1], true))) {
|
|
throw new FollowupAudioException('AUDIO_CHANNEL_REQUIRED');
|
|
}
|
|
if ($channels === 1 && array_key_exists('channel', $segment)) { throw new FollowupAudioException('AUDIO_INVALID'); }
|
|
$timeout = (int) ($this->settings['normalize_timeout'] ?? 120);
|
|
$limit = min(8388608, (int) ($this->settings['asr_max_chunk_bytes'] ?? 4194304));
|
|
if ($timeout < 1 || $timeout > 600 || $limit < 1024) { throw new FollowupAudioException('CONFIG_INVALID'); }
|
|
$duration = ($segment['end_ms'] - $segment['start_ms']) / 1000;
|
|
if ($duration <= 0 || $duration > ($channels === 2 ? 30 : 120)) { throw new FollowupAudioException('AUDIO_INVALID'); }
|
|
$handle = @fopen($output, 'xb');
|
|
if (!$handle) { throw new FollowupAudioException('AUDIO_NORMALIZATION_FAILED'); }
|
|
chmod($output, 0600); fclose($handle);
|
|
$process = null; $pipes = [];
|
|
try {
|
|
$arguments = [(string) ($this->settings['ffmpeg'] ?? 'ffmpeg'), '-nostdin', '-hide_banner', '-v', 'error',
|
|
'-xerror', '-protocol_whitelist', 'file,pipe', '-threads', '1', '-ss', sprintf('%.3f', $segment['start_ms'] / 1000),
|
|
'-i', $audio['path'], '-t', sprintf('%.3f', $duration), '-map', '0:a:0', '-vn', '-sn', '-dn', '-map_metadata', '-1',
|
|
'-af', $channels === 2 ? 'pan=mono|c0=c' . $segment['channel'] : 'anull',
|
|
'-ac', '1', '-ar', '16000', '-c:a', 'pcm_s16le', '-threads', '1', '-f', 'wav', '-y', $output];
|
|
$process = @proc_open($arguments,
|
|
[0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
|
|
if (!is_resource($process)) { throw new FollowupAudioException('AUDIO_NORMALIZATION_FAILED'); }
|
|
fclose($pipes[0]); unset($pipes[0]);
|
|
stream_set_blocking($pipes[1], false); stream_set_blocking($pipes[2], false);
|
|
$deadline = microtime(true) + $timeout; $last = microtime(true); $exit = -1;
|
|
do {
|
|
stream_get_contents($pipes[1], 65536); stream_get_contents($pipes[2], 65536);
|
|
$status = proc_get_status($process); clearstatcache(true, $output);
|
|
if (filesize($output) > $limit) { throw new FollowupAudioException('UPSTREAM_AUDIO_LIMIT'); }
|
|
if (!$status['running']) { $exit = $status['exitcode']; break; }
|
|
if (microtime(true) > $deadline) { throw new FollowupAudioException('AUDIO_NORMALIZATION_TIMEOUT'); }
|
|
if (microtime(true) - $last > 5) { if ($heartbeat([]) === false) { throw new FollowupAudioException('LEASE_LOST'); } $last = microtime(true); }
|
|
usleep(10000);
|
|
} while (true);
|
|
foreach ($pipes as $pipe) { fclose($pipe); } $pipes = [];
|
|
$closed = proc_close($process); $process = null;
|
|
if (($exit < 0 ? $closed : $exit) !== 0) { throw new FollowupAudioException('AUDIO_NORMALIZATION_FAILED'); }
|
|
$copy = $this->inspect($output, (string) hash_file('sha256', $output), true);
|
|
if (abs($copy['duration'] - $duration) > 0.08 || filesize($output) > $limit) { throw new FollowupAudioException('AUDIO_NORMALIZATION_FAILED'); }
|
|
} finally {
|
|
if (is_resource($process)) { proc_terminate($process, 9); }
|
|
foreach ($pipes as $pipe) { if (is_resource($pipe)) { fclose($pipe); } }
|
|
if (is_resource($process)) { proc_close($process); }
|
|
}
|
|
}
|
|
}
|