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); } } } }