settings = $settings; } /** One budget for planning, source validation and checkpoints, before any supplier request. */ public static function assertChunkPlan(float $durationSeconds, int $chunkMs): int { if (!is_finite($durationSeconds) || $durationSeconds <= 0 || $durationSeconds > 3600 || $chunkMs < 1000 || $chunkMs > 120000) { throw new FollowupAudioException('AUDIO_INVALID'); } $count = (int) ceil(ceil($durationSeconds * 1000) / $chunkMs); 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'); } if (!$processing) { self::assertChunkPlan($duration, (int) ($this->settings['asr_chunk_seconds'] ?? 120) * 1000); } return ['path' => $real, 'extension' => $extension, 'mime' => $mimes[$extension], 'duration' => $duration, 'channels' => (int) ($streams[0]['channels'] ?? 0)]; } public function chunk(array $audio, array $segment, string $output, callable $heartbeat): void { $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 > 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 { $process = @proc_open([(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', '-ac', '1', '-ar', '16000', '-c:a', 'pcm_s16le', '-threads', '1', '-f', 'wav', '-y', $output], [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); } } } }