feat: preserve stereo channels and require reviewed speaker roles
This commit is contained in:
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user