feat: preserve stereo channels and require reviewed speaker roles

This commit is contained in:
2026-10-08 16:15:41 +08:00
parent 13948f5982
commit a20d3565af
28 changed files with 1134 additions and 113 deletions
@@ -6,14 +6,16 @@ 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
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 > 120000) { throw new FollowupAudioException('AUDIO_INVALID'); }
$count = (int) ceil(ceil($durationSeconds * 1000) / $chunkMs);
|| $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;
}
@@ -64,27 +66,75 @@ final class FollowupAudioPipelineMedia
|| 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); }
$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 > 120) { throw new FollowupAudioException('AUDIO_INVALID'); }
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 {
$process = @proc_open([(string) ($this->settings['ffmpeg'] ?? 'ffmpeg'), '-nostdin', '-hide_banner', '-v', 'error',
$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',
'-ac', '1', '-ar', '16000', '-c:a', 'pcm_s16le', '-threads', '1', '-f', 'wav', '-y', $output],
'-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]);