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
+2 -1
View File
@@ -25,7 +25,8 @@ if (!method_exists(Prompt::class, 'citations')) {
foreach ($citations as $index => $citation) {
$start = $index * 200;
$expected = mb_substr($text, $start, 240);
$exact = $exact && array_keys($citation) === ['id', 'segment_id', 'text']
$exact = $exact && array_keys($citation) === ['id', 'segment_id', 'text', 'start_ms', 'end_ms']
&& $citation['start_ms'] === 0 && $citation['end_ms'] === 120000
&& $citation['segment_id'] === 's_a' && $citation['text'] === $expected
&& mb_strlen($citation['text']) <= 240 && str_contains($text, $citation['text'])
&& preg_match('/^c_[a-f0-9]{32}$/D', $citation['id']) && !isset($seen[$citation['id']]);
@@ -57,7 +57,7 @@ $transport = static function (array $spec, callable $heartbeat) use (&$calls, &$
return ['http_code' => 200, 'errno' => 0, 'body' => json_encode(['choices' => [['finish_reason' => $mode === 'length' ? 'length' : 'stop', 'message' => $message]]])];
};
$make = static function () use ($f, &$active, &$allTasks): int {
$samples = str_repeat(pack('v', 0), 48000 - 1) . random_bytes(2);
$samples = str_repeat(pack('v', 1000), 48000 - 1) . random_bytes(2);
$wav = 'RIFF' . pack('V', 36 + strlen($samples)) . 'WAVEfmt ' . pack('VvvVVvv', 16, 1, 1, 16000, 32000, 2, 16) . 'data' . pack('V', strlen($samples)) . $samples;
$source = $f['private'] . '/fixture.wav'; file_put_contents($source, $wav); chmod($source, 0600);
$upload = followupAudioTestUpload($f, $source, 'synthetic.wav');
@@ -26,6 +26,7 @@ $makeWave = static function (int $seconds) use ($directory): string {
$handle = fopen($path, 'xb'); chmod($path, 0600);
fwrite($handle, 'RIFF' . pack('V', 36 + $dataBytes) . 'WAVEfmt ' . pack('VvvVVvv', 16, 1, 1, 16000, 32000, 2, 16)
. 'data' . pack('V', $dataBytes));
fwrite($handle, pack('v', 1)); // One nonzero LSB must still reach ASR; not a silence/energy threshold.
fseek($handle, 44 + $dataBytes - 1); fwrite($handle, "\0"); fclose($handle);
return $path;
};
@@ -10,13 +10,13 @@ $source = $directory . '/hour.mp3'; $chunk = $directory . '/chunk.wav';
$checks = 0;
$expect = static function (bool $value, string $message) use (&$checks): void { if (!$value) { throw new RuntimeException($message); } $checks++; };
try {
$process = proc_open(['ffmpeg', '-nostdin', '-hide_banner', '-v', 'error', '-f', 'lavfi', '-i', 'anullsrc=r=16000:cl=stereo',
$process = proc_open(['ffmpeg', '-nostdin', '-hide_banner', '-v', 'error', '-f', 'lavfi', '-i', 'anullsrc=r=16000:cl=mono',
'-t', '3599', '-c:a', 'libmp3lame', '-b:a', '32k', '-threads', '1', $source],
[0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
fclose($pipes[0]); $stdout = stream_get_contents($pipes[1]); $stderr = stream_get_contents($pipes[2]); fclose($pipes[1]); fclose($pipes[2]);
$expect(proc_close($process) === 0 && $stdout === '' && $stderr === '', 'bounded generated stereo near-hour synthetic source'); chmod($source, 0600);
$expect(proc_close($process) === 0 && $stdout === '' && $stderr === '', 'bounded generated mono near-hour synthetic source'); chmod($source, 0600);
$hash = hash_file('sha256', $source); $media = new Media([]); $audio = $media->inspect($source, $hash);
$expect($audio['channels'] === 2 && $audio['duration'] >= 3599 && $audio['duration'] <= 3600, 'source stereo and true encoded duration: ' . json_encode([$audio['channels'], $audio['duration']]));
$expect($audio['channels'] === 1 && $audio['duration'] >= 3599 && $audio['duration'] <= 3600, 'source mono and true encoded duration: ' . json_encode([$audio['channels'], $audio['duration']]));
$task = ['sha256' => $hash, 'duration_seconds' => $audio['duration']];
$plan = Checkpoint::initial($task, str_repeat('a', 64), 120000);
$expect(count($plan['chunks']) === 30, '30 bounded chunks cover entire original'); $covered = 0;
@@ -28,5 +28,5 @@ try {
$covered = $segment['end_ms']; unlink($chunk);
}
$expect($covered === (int) ceil($audio['duration'] * 1000) && hash_file('sha256', $source) === $hash, 'tail covered and original immutable');
echo 'FOLLOWUP_AUDIO_PIPELINE_LONG_MEDIA assertions=' . $checks . ' PASS real_near_hour=1 chunks=30 contiguous_coverage=1 bounded_pcm=1 stereo_source_retained=1 network_calls=0' . PHP_EOL;
echo 'FOLLOWUP_AUDIO_PIPELINE_LONG_MEDIA assertions=' . $checks . ' PASS real_near_hour=1 chunks=30 contiguous_coverage=1 bounded_pcm=1 mono_source_retained=1 network_calls=0' . PHP_EOL;
} finally { foreach (glob($directory . '/*') as $file) { unlink($file); } rmdir($directory); }
+1 -1
View File
@@ -24,7 +24,7 @@ $socket = stream_socket_server('tcp://127.0.0.1:0', $errno, $error); $port = (in
$process = proc_open([PHP_BINARY, '-n', '-S', '127.0.0.1:' . $port, __DIR__ . '/fixtures/followup_audio/pipeline_router.php'],
[0 => ['pipe', 'r'], 1 => ['file', $dir . '/stdout', 'a'], 2 => ['file', $dir . '/stderr', 'a']], $pipes, null,
array_merge(getenv(), ['FOLLOWUP_AUDIO_PIPELINE_MOCK_DIR' => $dir])); fclose($pipes[0]);
$pcm = str_repeat(pack('v', 0), 24000);
$pcm = str_repeat(pack('v', 1000), 24000);
$wav = 'RIFF' . pack('V', 36 + strlen($pcm)) . 'WAVEfmt ' . pack('VvvVVvv', 16, 1, 1, 16000, 32000, 2, 16) . 'data' . pack('V', strlen($pcm)) . $pcm;
$path = $dir . '/short.wav'; file_put_contents($path, $wav); chmod($path, 0600);
$slot = ['driver' => 'asr_then_llm', 'label' => 'synthetic pipeline', 'allow_loopback_tunnel' => true,
@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
require __DIR__ . '/fixtures/followup_audio/database.php';
use app\common\service\followupaudio\FollowupAudioStore as Store;
use app\common\service\followupaudio\FollowupAudioApply as Apply;
use app\common\service\followupaudio\FollowupAudioWorker as Worker;
use app\common\service\followupaudio\FollowupAudioDify as Dify;
use app\common\service\followupaudio\FollowupAudioPipelineCheckpoint as Checkpoint;
use app\common\service\followupaudio\FollowupAudioTranscriptPrompt as Prompt;
use app\common\service\followupaudio\FollowupAudioPolicy as Policy;
use think\facade\Db;
if ((int) getenv('FOLLOWUP_AUDIO_TEST_MYSQL_PORT') !== 23317) { throw new RuntimeException('Stereo test requires its separate disposable23317'); }
$f = followupAudioTestDatabase(['asr_stereo_chunk_seconds' => 2]);
$checks = 0; $calls = ['asr' => 0, 'extraction' => 0]; $active = 0; $mode = 'success';
$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 (Throwable $e) { $expect(str_contains($e->getMessage(), $code) || ($e->errorCode ?? '') === $code, 'expected ' . $code . ', got ' . $e->getMessage()); return; }
throw new RuntimeException('Expected ' . $code);
};
$make = static function (int $channels = 2, bool $allSilent = false) use ($f, &$active): int {
static $serial = 0; $serial++;
$frame = $allSilent ? str_repeat("\0", $channels * 2) : pack('v', 1000 + $serial) . ($channels === 2 ? pack('v', 2000 + $serial) : '');
$data = str_repeat($frame, 16000 * ($allSilent ? 1 : 5));
$path = $f['private'] . '/synthetic-' . $serial . '.wav';
file_put_contents($path, 'RIFF' . pack('V', 36 + strlen($data)) . 'WAVEfmt ' . pack('VvvVVvv', 16, 1, $channels, 16000, 32000 * $channels, 2 * $channels, 16) . 'data' . pack('V', strlen($data)) . $data); chmod($path, 0600);
$upload = followupAudioTestUpload($f, $path, 'synthetic.wav');
$task = Store::create($upload, '2026-09-29 10:00:00', 'qwen', 1, $f['actor']); $active = $task['id']; return $active;
};
$transport = static function (array $spec) use (&$calls, &$active, &$mode, $expect): array {
$calls[$spec['stage']]++; $task = Store::task($active); $state = Store::open($active, 'pipeline', $task['pipeline_cipher']);
$expect($state['schema_version'] === 2 && Checkpoint::hasIntent($state), 'new v2 encrypted intent precedes transport');
if ($spec['stage'] === 'asr') {
$intent = array_values(array_filter($state['chunks'], static fn ($chunk): bool => $chunk['state'] === 'intent'))[0];
if ($mode === 'unknown_right' && ($intent['channel'] ?? -1) === 1) { return ['http_code' => 0, 'errno' => 28, 'body' => '']; }
return ['http_code' => 200, 'errno' => 0, 'body' => json_encode(['text' => ($intent['channel'] ?? 0) === 0 ? '合成:今天早晨空腹血糖六点一。' : '合成:今天早餐吃了鸡蛋。'], JSON_UNESCAPED_UNICODE)];
}
$citations = Prompt::citations(Checkpoint::segments($state));
$answer = ['schema_version' => 'followup-audio-transcript-v2', 'summary' => '合成摘要', 'uncertainties' => [], 'items' => [[
'kind' => 'blood', 'values' => ['fasting_blood_sugar' => 6.1], 'record_date' => '2026-09-29', 'record_time' => '08:00',
'date_text' => '今天', 'time_text' => '早晨', 'time_period' => null, 'time_estimated' => false, 'needs_review' => true,
'evidence_ids' => [$citations[0]['id']],
]]];
return ['http_code' => 200, 'errno' => 0, 'body' => json_encode(['choices' => [['finish_reason' => 'stop', 'message' => ['content' => json_encode($answer, JSON_UNESCAPED_UNICODE)]]]])];
};
$run = static fn (): bool => (new Worker(new Dify($transport)))->runOnce();
$resolve = static function (array $detail): array { $items = $detail['items']; foreach ($items as &$item) { $item['selected'] = true; $item['needs_review'] = false; } unset($item); return $items; };
try {
$id = $make(); $expect($run(), 'actual Worker consumes stereo task'); $detail = Store::detail($id);
$expect($detail['status'] === 'review' && $calls === ['asr' => 6, 'extraction' => 1], 'both channels every window then one extraction');
$expect($detail['channel_roles'] === 'unconfirmed' && count($detail['transcript_segments']) === 6 && array_column($detail['transcript_segments'], 'channel') === [0, 1, 0, 1, 0, 1], 'detail exposes canonical ordered channels without inferred roles');
$expect($detail['items'][0]['evidence'][0]['channel'] === 0 && $detail['items'][0]['needs_review'] && !$detail['items'][0]['selected'], 'stereo evidence channel immutable and review forced');
$reject(fn () => Apply::apply($id, $detail['version'], $resolve($detail), 1, $f['actor']), 'CHANNEL_ROLES_REQUIRED');
$before = Store::task($id)['review_cipher'];
$reject(fn () => Store::saveDraft($id, $detail['version'], [], 3, 'left_service'), '诊单不存在或无权操作');
$expect(Store::task($id)['review_cipher'] === $before, 'role scope failure does not mutate ciphertext');
Db::name('admin')->where('id', 1)->update(['disable' => 1]);
$reject(fn () => Store::saveDraft($id, $detail['version'], [], 1, 'left_service'), '账号已停用');
Db::name('admin')->where('id', 1)->update(['disable' => 0]);
$reject(fn () => Store::saveDraft($id, $detail['version'], [], 1, 'patient_is_left'), 'CHANNEL_ROLES_INVALID');
$saved = Store::saveDraft($id, $detail['version'], $resolve($detail), 1, 'left_service');
$expect($saved['channel_roles'] === 'left_service' && $saved['version'] === $detail['version'] + 1 && !$saved['items'][0]['selected'] && $saved['items'][0]['needs_review'], 'role confirmation is version-bound and clears simultaneous selection');
$reject(fn () => Store::saveDraft($id, $detail['version'], [], 1, 'right_service'), 'VERSION_CONFLICT');
$expect(Store::detail($id)['channel_roles'] === 'left_service', 'stale role changes cannot win');
$forged = $saved['items']; $forged[0]['evidence'][0]['channel'] = 1;
$reject(fn () => Store::saveDraft($id, $saved['version'], $forged, 1), 'IMMUTABLE_FIELD');
$forged = $saved['items']; $forged[0]['channel_roles'] = 'right_service';
$reject(fn () => Store::saveDraft($id, $saved['version'], $forged, 1), 'IMMUTABLE_FIELD');
$saved = Store::saveDraft($id, $saved['version'], $resolve($saved), 1);
$expect($saved['channel_roles'] === 'left_service' && $saved['items'][0]['selected'], 'ordinary value review retains saved role');
$beforeCalls = $calls;
$saved = Store::saveDraft($id, $saved['version'], $resolve($saved), 1, 'right_service');
$expect($saved['channel_roles'] === 'right_service' && !$saved['items'][0]['selected'] && $saved['items'][0]['needs_review'] && $calls === $beforeCalls, 'role swap forces new review, no re-extraction');
$sourceEvidence = $saved['items'][0]['evidence'];
Db::name('tcm_blood_record')->insert(['diagnosis_id' => 1, 'patient_id' => 101, 'record_date' => Policy::dayTimestamp('2026-09-29'), 'record_time' => '08:00', 'fasting_blood_sugar' => 5.5]);
$reject(fn () => Apply::apply($id, $saved['version'], $resolve($saved), 1, $f['actor']), 'STALE_REVIEW');
$fresh = Store::detail($id);
$expect($fresh['channel_roles'] === 'right_service' && !$fresh['items'][0]['selected'] && $fresh['items'][0]['needs_review'], 'actual conflict refresh preserves role metadata');
$expect($fresh['items'][0]['evidence'] === $sourceEvidence, 'source evidence not relabeled by role change');
$applied = Apply::apply($id, $fresh['version'], $resolve($fresh), 1, $f['actor']);
$expect($applied['status'] === 'applied' && (int) Db::name('followup_audio_audit')->where('task_id', $id)->count() === 1, 'explicit re-review applies one new local record');
$audit = Db::name('followup_audio_audit')->where('task_id', $id)->find();
$auditSource = Store::open($id, 'audit-source:' . $audit['item_id'], $audit['source_cipher']);
$expect($auditSource['channel_roles'] === 'right_service' && $auditSource['channel_roles_annotation']['actor_id'] === 1 && $auditSource['evidence'][0]['channel'] === 0, 'audit stores human role annotation separately from original channel evidence');
$appliedCipher = Store::task($id)['applied_cipher'];
$expect(Store::detail($id)['channel_roles'] === 'right_service', 'applied role comes from stored server metadata');
$reject(fn () => Store::saveDraft($id, Store::detail($id)['version'], [], 1, 'left_service'), 'NOT_REVIEWABLE');
$expect(Store::task($id)['applied_cipher'] === $appliedCipher, 'adopted results immutable');
$monoId = $make(1); $run(); $mono = Store::detail($monoId);
$expect($mono['status'] === 'review' && $mono['channel_roles'] === 'unconfirmed' && !array_key_exists('channel', $mono['transcript_segments'][0]), 'mono remains compatible');
$reject(fn () => Store::saveDraft($monoId, $mono['version'], [], 1, 'left_service'), 'CHANNEL_ROLES_INVALID');
$expect(Apply::apply($monoId, $mono['version'], $resolve($mono), 1, $f['actor'])['status'] === 'applied', 'mono has no new role-confirmation gate');
$mode = 'unknown_right'; $unknownId = $make(); $beforeCalls = $calls; $run(); $unknown = Store::detail($unknownId);
$expect($unknown['status'] === 'needs_reconciliation' && !$unknown['can_retry'] && $unknown['pipeline_progress']['transcribed'] === 1
&& $calls['asr'] === $beforeCalls['asr'] + 2, 'unknown right channel retains completed left and forbids blind retry');
$unknownCipher = Store::task($unknownId)['pipeline_cipher']; $reject(fn () => Store::retry($unknownId), 'RETRY_NOT_SAFE');
$expect(Store::task($unknownId)['pipeline_cipher'] === $unknownCipher, 'unknown checkpoint never cleared');
$mode = 'success'; $silentId = $make(2, true); $beforeCalls = $calls; $run(); $silent = Store::detail($silentId);
$expect($calls === $beforeCalls && $silent['error_code'] === 'ASR_TRANSCRIPT_EMPTY' && $silent['pipeline_progress'] === ['transcribed' => 2, 'total' => 2], 'both exactly-zero channels retained without ASR or LLM');
$silentState = Store::open($silentId, 'pipeline', Store::task($silentId)['pipeline_cipher']);
$expect(array_column($silentState['chunks'], 'state') === ['local_silence', 'local_silence'] && (int) Store::task($silentId)['upstream_started_at'] === 0, 'no fabricated upstream result for local silence');
$legacyId = $make(1); $legacyTask = Store::task($legacyId); $oldFingerprint = str_repeat('c', 64);
$legacyState = ['schema_version' => 1, 'revision' => 3, 'source_sha256' => $legacyTask['sha256'], 'fingerprint' => $oldFingerprint, 'duration_ms' => 5000, 'chunk_ms' => 120000,
'chunks' => [['id' => 'asr-' . hash('sha256', $legacyTask['sha256'] . ':' . $oldFingerprint . ':0:5000'), 'start_ms' => 0, 'end_ms' => 5000,
'state' => 'complete', 'request_id' => 'fa-' . str_repeat('c', 32), 'text' => 'legacy synthetic text']], 'extraction' => ['state' => 'pending']];
$legacyCipher = Store::seal($legacyId, 'pipeline', $legacyState);
Db::name('followup_audio_task')->where('id', $legacyId)->update(['status' => 'failed', 'stage' => 'failed', 'pipeline_cipher' => $legacyCipher,
'upstream_ids_json' => json_encode(['provider_fingerprint' => $oldFingerprint]), 'upstream_started_at' => time()]);
$oldDetail = Store::detail($legacyId);
$expect($oldDetail['transcript'] === 'legacy synthetic text' && $oldDetail['channel_roles'] === 'unconfirmed' && !isset($oldDetail['transcript_segments'][0]['channel']) && !$oldDetail['can_retry'], 'old task text readable without rebinding provider');
$expect(Store::task($legacyId)['pipeline_cipher'] === $legacyCipher, 'legacy reads do not rewrite checkpoints');
$reject(fn () => Store::retry($legacyId), 'PROVIDER_CONFIGURATION_CHANGED');
$legacyAppliedId = $make(1); $oldAppliedCipher = Store::seal($legacyAppliedId, 'applied', ['items' => [['kind' => 'blood', 'record_id' => 999]]]);
Db::name('followup_audio_task')->where('id', $legacyAppliedId)->update(['status' => 'applied', 'stage' => 'applied', 'applied_cipher' => $oldAppliedCipher]);
$expect(Store::detail($legacyAppliedId)['applied_items'] === [['kind' => 'blood', 'record_id' => 999]] && Store::task($legacyAppliedId)['applied_cipher'] === $oldAppliedCipher, 'old applied results unchanged');
echo 'FOLLOWUP_AUDIO_STEREO_DATABASE assertions=' . $checks . ' PASS mysql23317=1 actual_worker=1 both_channels=1 role_scope_version=1 role_change_clears=1 apply_role_required=1 immutable_channels=1 audit_annotation=1 unknown_fenced=1 local_silence=1 legacy_unchanged=1' . PHP_EOL;
} finally { followupAudioTestDatabaseCleanup($f); }
@@ -0,0 +1,102 @@
<?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); }
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
/** Synthetic channel metadata only: no audio, ASR, model transport or application database. */
require dirname(__DIR__) . '/vendor/autoload.php';
use app\common\service\followupaudio\FollowupAudioTranscriptPrompt as Prompt;
use app\common\service\followupaudio\FollowupAudioPolicy as Policy;
use app\common\service\followupaudio\FollowupAudioPipeline as Pipeline;
if (($override = getenv('FOLLOWUP_AUDIO_PROMPT_OVERRIDE')) !== false && $override !== '') { require $override; }
if (($override = getenv('FOLLOWUP_AUDIO_POLICY_OVERRIDE')) !== false && $override !== '') { require $override; }
$checks = [];
$check = static function (string $name, bool $pass) use (&$checks): void { $checks[$name] = $pass; };
$segments = [
['id' => 'left_0', 'start_ms' => 0, 'end_ms' => 15000, 'channel' => 0, 'text' => '今天七点四十五分收缩压一百二十。'],
['id' => 'right_0', 'start_ms' => 0, 'end_ms' => 15000, 'channel' => 1, 'text' => '昨天先联系过。'],
['id' => 'left_1', 'start_ms' => 15000, 'end_ms' => 30000, 'channel' => 0, 'text' => '', 'source' => 'local_silence'],
['id' => 'right_1', 'start_ms' => 15000, 'end_ms' => 30000, 'channel' => 1, 'text' => '合成接续原文。'],
];
$transcript = implode("\n", array_column($segments, 'text'));
$citations = Prompt::citations($segments);
$check('overlapping_two_channels_are_valid', count($citations) === 3);
$check('channel_time_metadata_preserved', ($citations[0]['channel'] ?? null) === 0
&& ($citations[1]['channel'] ?? null) === 1 && ($citations[0]['start_ms'] ?? null) === 0 && ($citations[0]['end_ms'] ?? null) === 15000);
$check('no_fictional_speaker_prefix', $citations[0]['text'] === $segments[0]['text'] && $citations[1]['text'] === $segments[1]['text']);
$check('local_silence_has_no_citation', !in_array('left_1', array_column($citations, 'segment_id'), true));
$changed = [$segments[0]]; $changed[0]['channel'] = 1;
$check('citation_id_binds_channel', Prompt::citations($changed)[0]['id'] !== $citations[0]['id']);
$mono = [$segments[0]]; unset($mono[0]['channel']);
$monoCitation = Prompt::citations($mono)[0];
$identity = hash('sha256', json_encode([$mono[0]['id'], 0, 15000, $mono[0]['text']], JSON_THROW_ON_ERROR));
$legacyId = 'c_' . substr(hash('sha256', Prompt::CITATION_POLICY . ':' . $identity . ':0'), 0, 32);
$check('mono_citation_identity_unchanged', $monoCitation['id'] === $legacyId && !array_key_exists('channel', $monoCitation));
$quote = ['segment_id' => 'left_0', 'text' => $segments[0]['text'], 'start_ms' => 0, 'end_ms' => 15000, 'position_type' => 'segment', 'channel' => 0];
$item = ['kind' => 'blood', 'values' => ['systolic_pressure' => 120], 'record_date' => '2026-03-01',
'record_time' => '07:45', 'date_text' => '今天', 'time_text' => '七点四十五分', 'time_period' => null,
'time_estimated' => false, 'needs_review' => false, 'evidence' => [$quote]];
$input = ['transcript' => $transcript, 'summary' => '', 'uncertainties' => [], 'items' => [$item], 'transcript_segments' => $segments];
$normalized = Policy::normalizeExtraction($input, '2026-03-01 10:00:00');
$check('channel_always_requires_review', $normalized['items'][0]['needs_review'] && !$normalized['items'][0]['selected']);
$check('channel_evidence_preserved', ($normalized['items'][0]['evidence'][0]['channel'] ?? null) === 0);
$check('silent_channel_segment_retained', $normalized['transcript_segments'][2] === $segments[2]);
$check('renormalization_preserves_channel', Policy::normalizeExtraction($normalized, '2026-03-01 10:00:00') === $normalized);
$monoInput = $input; $monoInput['transcript_segments'] = $mono; $monoInput['transcript'] = $mono[0]['text'];
unset($monoInput['items'][0]['evidence'][0]['channel']);
$monoResult = Policy::normalizeExtraction($monoInput, '2026-03-01 10:00:00');
$check('mono_does_not_gain_channel_or_forced_review', !$monoResult['items'][0]['needs_review'] && !array_key_exists('channel', $monoResult['items'][0]['evidence'][0]));
$monoInput['items'][0]['evidence'][0]['channel'] = 0;
$check('mono_rejects_fabricated_channel', Policy::normalizeExtraction($monoInput, '2026-03-01 10:00:00')['items'] === []);
foreach (['wrong_channel' => 1, 'string_channel' => '0', 'invalid_channel' => 2, 'null_channel' => null] as $name => $bad) {
$copy = $input; $copy['items'][0]['evidence'][0]['channel'] = $bad;
$actual = Policy::normalizeExtraction($copy, '2026-03-01 10:00:00');
$check('reject_evidence_' . $name, $actual['items'] === []);
}
$copy = $input; unset($copy['items'][0]['evidence'][0]['channel']);
$check('reject_evidence_missing_channel', Policy::normalizeExtraction($copy, '2026-03-01 10:00:00')['items'] === []);
$invalid = [];
$invalid['same_channel_overlap'] = [$segments[0], array_replace($segments[2], ['start_ms' => 14000])];
$invalid['global_order_reversed'] = [$segments[1], $segments[0]];
$invalid['same_channel_reverse'] = [$segments[2], $segments[0]];
$invalid['mixed_channel_presence'] = [$segments[0], array_replace($mono[0], ['id' => 'missing_channel'])];
$invalid['non_integer_channel'] = [array_replace($segments[0], ['channel' => '0'])];
$invalid['unsupported_channel'] = [array_replace($segments[0], ['channel' => 2])];
$invalid['null_channel'] = [array_replace($segments[0], ['channel' => null])];
foreach ($invalid as $name => $bad) {
$rejected = false; try { Prompt::citations($bad); } catch (DomainException $error) { $rejected = true; }
$check('prompt_reject_' . $name, $rejected);
$rejected = false;
try { Policy::normalizeExtraction(array_replace($input, ['transcript_segments' => $bad, 'items' => []]), '2026-03-01 10:00:00'); }
catch (DomainException $error) { $rejected = true; }
$check('policy_reject_' . $name, $rejected);
}
$catalog = ['blood' => [['key' => 'systolic_pressure', 'type' => 'number']]];
$prompt = Prompt::build($transcript, $segments, '2026-03-01 10:00:00', $catalog);
foreach (['左右声道', '角色未知', '不能默认', '不是准确话轮', '可能交叠', 'needs_review=true', '静音不代表否认'] as $rule) {
$check('prompt_rule_' . $rule, str_contains($prompt, $rule));
}
// Production validation integration can be separately delayed until the backend peer installs channel resolution.
if (!in_array('--policy-only', $argv, true)) {
unset($item['evidence']); $item['evidence_ids'] = [$citations[0]['id']];
$raw = ['schema_version' => 'followup-audio-transcript-v2', 'summary' => '合成通道', 'uncertainties' => [], 'items' => [$item]];
$actual = Pipeline::validateAnswer(json_encode($raw, JSON_THROW_ON_ERROR), $transcript, $segments, '2026-03-01 10:00:00', $catalog);
$check('production_resolves_canonical_channel', ($actual['items'][0]['evidence'][0]['channel'] ?? null) === 0);
$check('production_forces_review', ($actual['items'][0]['needs_review'] ?? false) === true);
$raw['items'][0]['channel'] = 1;
$rejected = false; try { Pipeline::validateAnswer(json_encode($raw, JSON_THROW_ON_ERROR), $transcript, $segments, '2026-03-01 10:00:00', $catalog); }
catch (\Throwable $error) { $rejected = true; }
$check('production_rejects_model_channel', $rejected);
}
$failed = array_keys(array_filter($checks, static fn (bool $pass): bool => !$pass));
echo json_encode(['suite' => 'followup-audio-stereo-quality', 'synthetic_only' => true, 'transport_calls' => 0,
'checks' => $checks, 'passed' => count($checks) - count($failed), 'total' => count($checks), 'failures' => $failed],
JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) . PHP_EOL;
exit($failed === [] ? 0 : 1);