exec('CREATE TABLE zyt_dict_data (id INTEGER PRIMARY KEY, type_value TEXT, status INTEGER, sort INTEGER, name TEXT, value TEXT)'); $pdo->exec('CREATE TABLE zyt_followup_audio_upload (id TEXT PRIMARY KEY, diagnosis_id INT, actor_id INT, file_name TEXT, extension TEXT, total_bytes INT, received_bytes INT, sha256 TEXT, duration_seconds REAL, status TEXT, created_at INT, expires_at INT)'); $manager = new think\DbManager(); $manager->setConfig(['default' => 'sqlite', 'connections' => ['sqlite' => ['type' => 'sqlite', 'database' => $directory . '/test.sqlite', 'prefix' => 'zyt_']]]); Container::getInstance()->instance('think\DbManager', $manager); $config = new think\Config(); $settings = ['enabled' => true, 'audio_verified' => true, 'verified_profiles' => ['qwen'], 'private_dir' => $directory . '/private', 'ffprobe' => 'ffprobe', 'ffmpeg' => 'ffmpeg', 'normalize_timeout' => 120, 'max_bytes' => 524288000, 'max_seconds' => 3600, 'upstream_max_bytes' => 20971520, 'request_timeout' => 5]; $config->set($settings, 'followup_audio'); Container::getInstance()->instance('config', $config); $socket = stream_socket_server('tcp://127.0.0.1:0', $errno, $error); $port = (int) substr(strrchr(stream_socket_get_name($socket, false), ':'), 1); fclose($socket); $environment = getenv(); $environment['FOLLOWUP_AUDIO_MOCK_DIR'] = $directory; $process = proc_open([PHP_BINARY, '-n', '-d', 'upload_max_filesize=24M', '-d', 'post_max_size=25M', '-S', '127.0.0.1:' . $port, __DIR__ . '/fixtures/followup_audio/dify_router.php'], [0 => ['pipe', 'r'], 1 => ['file', $directory . '/server.stdout', 'a'], 2 => ['file', $directory . '/server.stderr', 'a']], $pipes, __DIR__, $environment); if (!is_resource($process)) { throw new RuntimeException('MOCK_SERVER_START_FAILED'); } fclose($pipes[0]); $checks = 0; $expect = static function (bool $ok, string $why) use (&$checks): void { if (!$ok) { throw new RuntimeException($why); } $checks++; }; $expectError = static function (callable $call, string $code, bool $uncertain = false) use ($expect): void { try { $call(); } catch (AudioError $e) { $expect($e->errorCode === $code && $e->uncertain === $uncertain, 'expected ' . $code . ', got ' . $e->errorCode); return; } throw new RuntimeException('Expected ' . $code); }; $command = static function (array $args): void { $p = proc_open($args, [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); if (!is_resource($p)) { throw new RuntimeException('TEST_PROCESS_FAILED'); } fclose($pipes[0]); $out = stream_get_contents($pipes[1]); $err = stream_get_contents($pipes[2]); fclose($pipes[1]); fclose($pipes[2]); $exit = proc_close($p); if ($exit !== 0) { throw new RuntimeException('TEST_PROCESS_FAILED exit=' . $exit . ' stderr=' . $err . ' stdout=' . $out); } }; $makeWav = static function (string $path, int $seconds): void { $size = 16000 * 2 * $seconds; $f = fopen($path, 'xb'); fwrite($f, 'RIFF' . pack('V', 36 + $size) . 'WAVEfmt ' . pack('VvvVVvv', 16, 1, 1, 16000, 32000, 2, 16) . 'data' . pack('V', $size)); // Sparse silence: a valid complete waveform, not a forged duration tag, without allocating 115 MB in memory. fseek($f, 44 + $size - 1); fwrite($f, "\0"); fclose($f); chmod($path, 0600); }; $makeTask = static function (string $path, string $extension) use ($directory): array { $id = bin2hex(random_bytes(24)); $dir = $directory . '/private/' . $id; mkdir($dir . '/parts', 0700, true); $target = $dir . '/audio.' . $extension; rename($path, $target); chmod($target, 0600); $duration = Upload::inspect($target, $extension)['duration_seconds']; $hash = hash_file('sha256', $target); think\facade\Db::name('followup_audio_upload')->insert(['id' => $id, 'diagnosis_id' => 91, 'actor_id' => 7, 'file_name' => 'synthetic.' . $extension, 'extension' => $extension, 'total_bytes' => filesize($target), 'received_bytes' => filesize($target), 'sha256' => $hash, 'duration_seconds' => $duration, 'status' => 'complete', 'created_at' => time(), 'expires_at' => time() + 86400]); return ['id' => 1, 'upload_id' => $id, 'diagnosis_id' => 91, 'actor_id' => 7, 'model_key' => 'qwen', 'recorded_at' => '2026-09-29 10:00:00', 'sha256' => $hash, 'duration_seconds' => $duration, 'path' => $target]; }; $adapter = static function (string $scenario, array $overrides = []) use ($port, $settings): Dify { return new Dify(null, $overrides + $settings, ['base_url' => 'http://127.0.0.1:' . $port . '/' . $scenario . '/v1', 'models' => ['qwen' => ['api_key' => 'synthetic-test-key']]]); }; $requests = static fn (): array => is_file($directory . '/requests.jsonl') ? array_map(static fn (string $line): array => json_decode($line, true), file($directory . '/requests.jsonl', FILE_IGNORE_NEW_LINES)) : []; try { $ready = false; for ($i = 0; $i < 100; $i++) { $c = @stream_socket_client('tcp://127.0.0.1:' . $port, $errno, $error, 0.1); if ($c) { fclose($c); $ready = true; break; } usleep(50000); } $expect($ready, 'loopback server ready'); $makeWav($directory . '/source.wav', 3); $command(['ffmpeg', '-nostdin', '-hide_banner', '-v', 'error', '-i', $directory . '/source.wav', '-c:a', 'aac', $directory . '/source.m4a']); $command(['ffmpeg', '-nostdin', '-hide_banner', '-v', 'error', '-i', $directory . '/source.wav', '-c:a', 'libmp3lame', $directory . '/source.mp3']); // 150 complete synthetic AMR-NB mode-7 frames: exactly three seconds. No patient recording. file_put_contents($directory . '/source.amr', "#!AMR\n" . str_repeat("\x3c" . str_repeat("\0", 31), 150)); $tasks = []; foreach (['wav', 'm4a', 'mp3', 'amr'] as $extension) { $task = $makeTask($directory . '/source.' . $extension, $extension); $tasks[$extension] = $task; $events = []; $copies = []; $heartbeat = static function (array $fields = []) use (&$events, &$copies, $task): bool { $events[] = $fields; foreach (glob(dirname($task['path']) . '/processing.*.mp3') ?: [] as $copy) { $copies[] = ['path' => $copy, 'mode' => fileperms($copy) & 0777, 'bytes' => filesize($copy)]; } return true; }; $result = $adapter('media-' . $extension)->analyze($task, $heartbeat); $uploads = array_values(array_filter($requests(), static fn (array $r): bool => $r['scenario'] === 'media-' . $extension && $r['upload'])); $expect(count($uploads) === 1 && count($result['items']) === 1, $extension . ' actual multipart accepted and factual review returned'); $expect(hash_file('sha256', $task['path']) === $task['sha256'], $extension . ' pristine original retained'); $expect(glob(dirname($task['path']) . '/processing.*') === [], $extension . ' processing copy removed after success'); if ($extension === 'amr') { $expect(abs($task['duration_seconds'] - 3) < 0.001, 'AMR duration uses complete packet count, not bitrate estimate'); $expect($uploads[0]['mime'] === 'audio/mpeg' && $uploads[0]['filename'] === 'followup-audio.mp3', 'AMR uses compatible MP3 processing copy'); $expect($copies !== [] && array_unique(array_column($copies, 'mode')) === [0600], 'processing copy private 0600'); } else { $expect($uploads[0]['sha256'] === $task['sha256'], $extension . ' small compatible original unchanged in actual request'); } } $wave = $tasks['wav']; foreach (['missing-ffmpeg', 'limit', 'timeout', 'truncated', 'lease'] as $failure) { $before = count($requests()); $events = []; $extra = ['upstream_max_bytes' => 20000]; $callback = static function (array $fields = []) use (&$events): bool { $events[] = $fields; return true; }; $code = 'AUDIO_NORMALIZATION_FAILED'; if ($failure === 'missing-ffmpeg') { $extra['ffmpeg'] = $directory . '/missing-tool'; } if ($failure === 'limit') { $extra['upstream_max_bytes'] = 1; $code = 'UPSTREAM_AUDIO_LIMIT'; } if ($failure === 'timeout') { $extra['ffmpeg'] = $directory . '/slow-ffmpeg'; $extra['normalize_timeout'] = 1; file_put_contents($extra['ffmpeg'], "#!/bin/sh\nexec sleep 5\n"); chmod($extra['ffmpeg'], 0700); $code = 'AUDIO_NORMALIZATION_TIMEOUT'; } if ($failure === 'truncated') { $makeWav($directory . '/fragment.wav', 1); $command(['ffmpeg', '-nostdin', '-hide_banner', '-v', 'error', '-i', $directory . '/fragment.wav', '-c:a', 'libmp3lame', '-ar', '16000', '-b:a', '32k', $directory . '/fragment.mp3']); $extra['ffmpeg'] = $directory . '/truncated-ffmpeg'; file_put_contents($extra['ffmpeg'], "#!/bin/sh\nfor last; do :; done\ncp " . escapeshellarg($directory . '/fragment.mp3') . ' "$last"' . "\n"); chmod($extra['ffmpeg'], 0700); } if ($failure === 'lease') { $callback = static fn (): bool => false; $code = 'LEASE_LOST'; } $expectError(static fn () => $adapter('media-failure', $extra)->analyze($wave, $callback), $code); $expect(count($requests()) === $before && !array_filter($events, static fn (array $row): bool => isset($row['upstream_started_at'])), $failure . ' local failure never uploads or creates upstream intent'); $expect(glob(dirname($wave['path']) . '/processing.*') === [] && hash_file('sha256', $wave['path']) === $wave['sha256'], $failure . ' cleanup preserves exact original'); } $before = count($requests()); $expectError(static fn () => $adapter('unknown', ['upstream_max_bytes' => 20000])->analyze($wave, static fn (): bool => true), 'UPSTREAM_UNCERTAIN', true); $expect(count($requests()) === $before + 2 && glob(dirname($wave['path']) . '/processing.*') === [], 'unknown HTTP result is not resent and processing copy is erased'); $expect(hash_file('sha256', $wave['path']) === $wave['sha256'], 'unknown result leaves original intact'); $makeWav($directory . '/long.wav', 3600); $long = $makeTask($directory . '/long.wav', 'wav'); $copyMetadata = []; $adapter('media-long')->analyze($long, static function (array $fields = []) use ($long, &$copyMetadata): bool { if (isset($fields['upstream_started_at'])) { foreach (glob(dirname($long['path']) . '/processing.*.mp3') ?: [] as $copy) { $copyMetadata = ['bytes' => filesize($copy), 'mode' => fileperms($copy) & 0777]; $p = proc_open(['ffprobe', '-v', 'error', '-show_entries', 'format=duration:stream=codec_name,sample_rate,channels', '-of', 'json', $copy], [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); fclose($pipes[0]); $copyMetadata += json_decode(stream_get_contents($pipes[1]), true); stream_get_contents($pipes[2]); fclose($pipes[1]); fclose($pipes[2]); proc_close($p); } } return true; }); $uploads = array_values(array_filter($requests(), static fn (array $r): bool => $r['scenario'] === 'media-long' && $r['upload'])); $expect(filesize($long['path']) > 52428800 && hash_file('sha256', $long['path']) === $long['sha256'], 'one-hour >50MiB original preserved byte-for-byte'); $expect(count($uploads) === 1 && $uploads[0]['size'] <= 20971520 && $uploads[0]['mime'] === 'audio/mpeg', 'entire hour uploaded once inside conservative 20MiB budget'); $expect(abs((float) $copyMetadata['format']['duration'] - 3600) < 0.25 && $copyMetadata['mode'] === 0600 && $copyMetadata['streams'][0]['codec_name'] === 'mp3' && (int) $copyMetadata['streams'][0]['channels'] === 1 && (int) $copyMetadata['streams'][0]['sample_rate'] === 16000, 'full-duration mono16k processing copy independently inspected before transport'); $expect(glob(dirname($long['path']) . '/processing.*') === [], 'hour processing copy removed'); echo 'FOLLOWUP_AUDIO_MEDIA assertions=' . $checks . ' PASS formats=MP3,M4A,WAV,AMR original_retained=1 hour_complete=1 bounded_private_copy=1 failure_no_send=1' . PHP_EOL; } finally { proc_terminate($process); proc_close($process); $manager->connect()->close(); $pdo = null; $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST); foreach ($files as $file) { $file->isDir() && !$file->isLink() ? rmdir($file->getPathname()) : unlink($file->getPathname()); } rmdir($directory); }