Files
zyt/server/tests/FollowupAudioStereoMediaTest.php
T

103 lines
10 KiB
PHP

<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
use app\common\service\followupaudio\FollowupAudioPipelineMedia as Media;
use app\common\service\followupaudio\FollowupAudioPipelineCheckpoint as Checkpoint;
use app\common\service\followupaudio\FollowupAudioPipeline as Pipeline;
use app\common\service\followupaudio\FollowupAudioProviderConfig as Provider;
use app\common\service\followupaudio\FollowupAudioException as Error;
$checks = 0; $expect = static function (bool $ok, string $why) use (&$checks): void { if (!$ok) { throw new RuntimeException($why); } $checks++; };
$reject = static function (callable $call, string $code) use ($expect): void {
try { $call(); } catch (Error $e) { $expect($e->errorCode === $code, 'expected ' . $code . ', got ' . $e->errorCode); return; }
throw new RuntimeException('Expected ' . $code);
};
$directory = sys_get_temp_dir() . '/fa-stereo-media-' . bin2hex(random_bytes(6)); mkdir($directory, 0700);
$pcm = static function (int $seconds, int $amplitude, int $frequency): string {
$data = ''; for ($i = 0; $i < $seconds * 16000; $i++) { $data .= pack('v', ((int) round($amplitude * sin(2 * M_PI * $frequency * $i / 16000))) & 65535); } return $data;
};
$wave = static function (string $name, array $channels) use ($directory): string {
$count = count($channels); $data = ''; $size = strlen($channels[0]);
for ($i = 0; $i < $size; $i += 2) { foreach ($channels as $samples) { $data .= substr($samples, $i, 2); } }
$path = $directory . '/' . $name . '.wav';
file_put_contents($path, 'RIFF' . pack('V', 36 + strlen($data)) . 'WAVEfmt ' . pack('VvvVVvv', 16, 1, $count, 16000, 32000 * $count, 2 * $count, 16) . 'data' . pack('V', strlen($data)) . $data);
chmod($path, 0600); return $path;
};
$data = static function (string $path): string {
$bytes = file_get_contents($path); $offset = 12;
while ($offset + 8 <= strlen($bytes)) { $length = unpack('V', substr($bytes, $offset + 4, 4))[1]; if (substr($bytes, $offset, 4) === 'data') { return substr($bytes, $offset + 8, $length); } $offset += 8 + $length + ($length % 2); }
throw new RuntimeException('Missing PCM data');
};
$settings = ['asr_stereo_chunk_seconds' => 2, 'providers' => ['qwen' => ['driver' => 'asr_then_llm', 'label' => 'Synthetic',
'asr' => ['base_url' => 'https://asr.invalid/v1', 'api_key' => 'synthetic', 'model' => 'synthetic-asr'],
'extraction' => ['base_url' => 'https://llm.invalid/v1', 'api_key' => 'synthetic', 'model' => 'synthetic-llm']]]];
$provider = Provider::resolve('qwen', $settings, []);
$taskFor = static fn (string $path, float $seconds): array => ['id' => 1, 'sha256' => hash_file('sha256', $path), 'duration_seconds' => $seconds,
'recorded_at' => '2026-09-29 10:00:00', 'model_key' => 'qwen', 'upstream_started_at' => 0,
'upstream_ids_json' => json_encode(['provider_fingerprint' => $provider['fingerprint']])];
try {
$left = $pcm(5, 1000, 440); $right = $pcm(5, 3000, 880);
$source = $wave('stereo', [$left, $right]); $task = $taskFor($source, 5); $media = new Media($settings); $audio = $media->inspect($source, $task['sha256']);
$plan = Checkpoint::initial($task, $provider['fingerprint'], 2000, 2);
$expect($plan['schema_version'] === 2 && $plan['source_channels'] === 2 && $plan['channel_policy'] === 'separate-fixed-windows-v1', 'new bound v2 checkpoint');
$expect(count($plan['chunks']) === 6 && array_column($plan['chunks'], 'channel') === [0, 1, 0, 1, 0, 1], 'both channels each window, temporal interleave');
$expect(array_column($plan['chunks'], 'start_ms') === [0, 0, 2000, 2000, 4000, 4000] && array_column($plan['chunks'], 'end_ms') === [2000, 2000, 4000, 4000, 5000, 5000], 'true shared offsets including tail');
foreach ($plan['chunks'] as $segment) {
$chunk = $directory . '/chunk.wav'; $media->chunk($audio, $segment, $chunk, static fn (): bool => true);
$expected = substr($segment['channel'] === 0 ? $left : $right, $segment['start_ms'] * 32, ($segment['end_ms'] - $segment['start_ms']) * 32);
$expect($data($chunk) === $expected && Media::digitalSilence($chunk) === null, 'exact independent PCM samples, no cross-channel mixing'); unlink($chunk);
}
$expect(hash_file('sha256', $source) === $task['sha256'], 'original stereo bytes immutable');
$swap = $wave('swapped', [$right, $left]); $swapTask = $taskFor($swap, 5); $swapAudio = $media->inspect($swap, $swapTask['sha256']); $swapPlan = Checkpoint::initial($swapTask, $provider['fingerprint'], 2000, 2);
$chunk = $directory . '/swapped-chunk.wav'; $media->chunk($swapAudio, $swapPlan['chunks'][0], $chunk, static fn (): bool => true);
$expect($data($chunk) === substr($right, 0, 64000) && $swapPlan['chunks'][0]['id'] !== $plan['chunks'][0]['id'], 'physical channel swap observed without assigning roles'); unlink($chunk);
$reject(static fn () => $media->chunk($audio, ['start_ms' => 0, 'end_ms' => 1000], $directory . '/forbidden.wav', static fn (): bool => true), 'AUDIO_CHANNEL_REQUIRED');
$mono = $wave('mono', [$left]); $monoTask = $taskFor($mono, 5); $monoPlan = Checkpoint::initial($monoTask, $provider['fingerprint'], 120000, 1);
$expect(count($monoPlan['chunks']) === 1 && !isset($monoPlan['chunks'][0]['channel']), 'mono retains whole120s compatibility without channel invention');
$three = $wave('three', [$left, $right, $left]); $reject(static fn () => $media->inspect($three, hash_file('sha256', $three)), 'AUDIO_CHANNELS_UNSUPPORTED');
$expect(Media::assertChunkPlan(3600, 15000, 2) === 480 && Media::assertChunkPlan(500, 1000, 2) === 1000, 'budget counts both channels');
$reject(static fn () => Media::assertChunkPlan(501, 1000, 2), 'ASR_SEGMENT_LIMIT');
$badSettings = $settings; $badSettings['asr_stereo_chunk_seconds'] = 31; $reject(static fn () => Provider::resolve('qwen', $badSettings, []), 'CONFIG_INVALID');
$newSettings = $settings; $newSettings['asr_stereo_chunk_seconds'] = 3;
$expect(Provider::resolve('qwen', $newSettings, [])['fingerprint'] !== $provider['fingerprint'], 'stereo window binds provider identity');
$expect($provider['silence_policy'] === 'exact-zero-pcm-v1' && $provider['channel_policy'] === Checkpoint::CHANNEL_POLICY, 'explicit policies in provider binding');
// Exact-zero left vs nonzero one-LSB right. No RMS threshold or made-up ASR request.
$silent = str_repeat("\0", 32000); $quiet = str_repeat(pack('v', 1), 16000);
$mixed = $wave('zero-and-low', [$silent, $quiet]); $mixedTask = $taskFor($mixed, 1); $state = []; $calls = 0; $intents = 0;
$heartbeat = static function (array $fields) use (&$state, &$intents, $mixedTask): bool {
if (isset($fields['upstream_started_at'])) { $intents++; }
if (isset($fields['pipeline_checkpoint'])) { $cp = $fields['pipeline_checkpoint']; Checkpoint::transition($state, $cp['state'], $mixedTask, $cp['expected_revision']); $state = $cp['state']; }
return true;
};
$transport = static function (array $spec) use (&$calls, &$state, $expect): array {
$calls++; $expect($state['chunks'][0]['state'] === 'local_silence' && $state['chunks'][1]['state'] === 'intent', 'only nonzero right is a supplier intent');
$expect(Media::digitalSilence($spec['multipart']['file']->getFilename()) === null, 'one-LSB audio is not discarded');
return ['http_code' => 429, 'errno' => 0, 'body' => '{}'];
};
$pipeline = new Pipeline($settings, $provider, $transport);
$reject(static fn () => $pipeline->run($mixed, $mixedTask, $heartbeat), 'ASR_REJECTED');
$expect($calls === 1 && $intents === 1 && !isset($state['chunks'][0]['request_id']) && $state['chunks'][0]['text'] === '', 'local silence has zero billable request IDs');
$segments = Checkpoint::segments($state);
$expect($segments[0]['channel'] === 0 && $segments[0]['source'] === 'local_silence' && $segments[0]['start_ms'] === 0 && $segments[0]['end_ms'] === 1000, 'silence coverage explicit, not omitted');
$forged = $state; $forged['chunks'][0]['request_id'] = 'fa-' . str_repeat('a', 32); $reject(static fn () => Checkpoint::validate($forged, $mixedTask), 'PIPELINE_CHECKPOINT_INVALID');
$forged = $state; $forged['chunks'][0]['pcm_sha256'] = str_repeat('a', 64); $reject(static fn () => Checkpoint::validate($forged, $mixedTask), 'PIPELINE_CHECKPOINT_INVALID');
$forged = $state; $forged['chunks'][0]['text'] = 'fake response'; $reject(static fn () => Checkpoint::validate($forged, $mixedTask), 'PIPELINE_CHECKPOINT_INVALID');
$forged = $state; $forged['chunks'][0]['channel'] = 1; $reject(static fn () => Checkpoint::validate($forged, $mixedTask), 'PIPELINE_CHECKPOINT_INVALID');
$pending = Checkpoint::initial($mixedTask, $provider['fingerprint'], 2000, 2); $pending['revision'] = 1;
$intent = $pending; $intent['revision'] = 2; $intent['chunks'][0]['state'] = 'intent'; $intent['chunks'][0]['request_id'] = 'fa-' . str_repeat('a', 32);
$notResolved = $pending; $notResolved['revision'] = 3; $notResolved['chunks'][0] = $state['chunks'][0];
$reject(static fn () => Checkpoint::transition($intent, $notResolved, $mixedTask, 2), 'PIPELINE_CHECKPOINT_INVALID');
// Historical v1 remains readable but may not be rebound to the new policy for retry.
$legacy = ['schema_version' => 1, 'revision' => 1, 'source_sha256' => $monoTask['sha256'], 'fingerprint' => $provider['fingerprint'],
'duration_ms' => 5000, 'chunk_ms' => 120000, 'chunks' => [['id' => 'asr-' . hash('sha256', $monoTask['sha256'] . ':' . $provider['fingerprint'] . ':0:5000'),
'start_ms' => 0, 'end_ms' => 5000, 'state' => 'complete', 'request_id' => 'fa-' . str_repeat('b', 32), 'text' => 'legacy synthetic']], 'extraction' => ['state' => 'pending']];
$bytes = json_encode($legacy); Checkpoint::validate($legacy, $monoTask);
$expect(json_encode($legacy) === $bytes && Checkpoint::segments($legacy)[0]['text'] === 'legacy synthetic', 'old v1 reading unchanged');
$before = $calls; $reject(static fn () => $pipeline->run($mono, $monoTask, static fn (): bool => true, $legacy), 'PIPELINE_POLICY_CHANGED');
$expect($calls === $before, 'legacy state cannot reroute requests under new policy');
echo 'FOLLOWUP_AUDIO_STEREO_MEDIA assertions=' . $checks . ' PASS separate_pcm=1 both_channels_covered=1 offsets=1 swaps=1 mono=1 unsupported_channels_rejected=1 exact_zero_local=1 low_volume_sent=1 legacy_read_only=1 live_model_calls=0' . PHP_EOL;
} finally { foreach (glob($directory . '/*') as $file) { unlink($file); } rmdir($directory); }