Proxy.php 频谱分析支撑功能介绍

一、核心定位

Proxy.php 是专为 Web Audio 频谱分析 设计的音频资源代理网关,解决浏览器加载音频文件时的跨域拦截、DNS 污染、MIME 不匹配、大文件分块、文本元数据乱码等问题,让前端 AnalyzerNode 能稳定获取 AudioBuffer 并执行 FFT(快速傅里叶变换)生成可视化频谱/波形。

二、频谱分析场景下的请求流程

  1. 前端频谱模块发起 fetch(url) 或 AudioContext.decodeAudioData() 调用
  2. 浏览器因 CORS 无法直连跨域/受限音频,转而请求 proxy.php?url=目标音频地址
  3. proxy.php 判定音频位置(本地/远程),绕过 DNS 污染拉取资源
  4. 返回符合 Web Audio 要求的 CORS 头、正确 MIME、支持 Range 的音频流
  5. 前端拿到 AudioBuffer → 喂给 AnalyzerNode → 执行 getByteFrequencyData/getByteTimeDomainData → 渲染频谱

<?php
/**
 * Proxy.php —— Web Audio 频谱分析资源代理网关
 *
 * 功能:本地/远程音频代理转发、CORS 跨域、Range 分块、DNS 污染绕过、
 *       自动编码转换(GBK→UTF-8)、多 IP 自动 fallback、Debug 调试面板
 *
 * @author LMP34
 * @date   2026-10-24
 * @link   https://www.cnlog.cc/
 * @require PHP 5.4+ / curl / mbstring / allow_url_fopen=On
 */
error_reporting(E_ALL);
ini_set('display_errors', 0);
ini_set('log_errors', 0);
//ini_set('error_log', __DIR__ . '/proxy_error.log');

$url = isset($_GET['url']) ? $_GET['url'] : '';
if ($url === '') {
    header('HTTP/1.1 400 Bad Request');
    exit('no url');
}

header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, HEAD, OPTIONS');
header('Access-Control-Allow-Headers: Range, Content-Type');

if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
    header('HTTP/1.1 204 No Content');
    exit;
}

$hasRange = isset($_SERVER['HTTP_RANGE']) && $_SERVER['HTTP_RANGE'] !== '';
$debug = isset($_GET['debug']) ? true : false;

$debugLog = array();
function proxyLog($msg) {
    global $debugLog;
    $debugLog[] = $msg;
 //   @file_put_contents(__DIR__ . '/proxy_error.log', date('Y-m-d H:i:s') . ' ' . $msg . "\n", FILE_APPEND);
}

$urlParts = parse_url($url);
$isRemoteProxy = isset($urlParts['scheme']) && ($urlParts['scheme'] === 'http' || $urlParts['scheme'] === 'https')
    && isset($urlParts['host']) && $urlParts['host'] !== $_SERVER['HTTP_HOST'];

if (!$isRemoteProxy && isset($urlParts['path'])) {
    $localFile = __DIR__ . '/' . ltrim($urlParts['path'], '/');
    if (file_exists($localFile) && is_file($localFile)) {
        proxyLog("LOCAL FILE: " . $localFile);
        $ext = strtolower(pathinfo($localFile, PATHINFO_EXTENSION));
        $audioExts = array('mp3'=>'audio/mpeg','wav'=>'audio/wav','ogg'=>'audio/ogg','m4a'=>'audio/mp4','aac'=>'audio/aac','flac'=>'audio/flac','wma'=>'audio/x-ms-wma');
        $videoExts = array('mp4'=>'video/mp4','webm'=>'video/webm','ogv'=>'video/ogg','mov'=>'video/quicktime','m4v'=>'video/x-m4v');
        $textExts = array('lrc'=>'text/plain','txt'=>'text/plain','srt'=>'text/plain','ass'=>'text/plain','vtt'=>'text/plain','xml'=>'application/xml','json'=>'application/json','css'=>'text/css','js'=>'application/javascript','html'=>'text/html','htm'=>'text/html','ini'=>'text/plain','csv'=>'text/plain','log'=>'text/plain','md'=>'text/plain','smi'=>'text/plain','krc'=>'text/plain');
        $ct = 'application/octet-stream';
        if (isset($audioExts[$ext])) $ct = $audioExts[$ext];
        elseif (isset($videoExts[$ext])) $ct = $videoExts[$ext];
        elseif (isset($textExts[$ext])) $ct = $textExts[$ext] . '; charset=utf-8';
        $size = filesize($localFile);
        if ($hasRange) {
            if (preg_match('/bytes=(\d*)-(\d*)/', $_SERVER['HTTP_RANGE'], $m)) {
                $start = ($m[1] !== '') ? intval($m[1]) : 0;
                $end = ($m[2] !== '') ? intval($m[2]) : $size - 1;
                if ($start > $end || $start >= $size) {
                    header('HTTP/1.1 416 Requested Range Not Satisfiable');
                    header('Content-Range: bytes */' . $size);
                    exit;
                }
                header('Content-Type: ' . $ct);
                header('HTTP/1.1 206 Partial Content');
                header('Content-Length: ' . ($end - $start + 1));
                header('Content-Range: bytes ' . $start . '-' . $end . '/' . $size);
                $fp = fopen($localFile, 'rb');
                fseek($fp, $start);
                echo fread($fp, $end - $start + 1);
                fclose($fp);
                exit;
            }
        }
        if (isset($textExts[$ext])) {
            $content = file_get_contents($localFile);
            if ($content !== false && function_exists('mb_detect_encoding')) {
                $detected = mb_detect_encoding($content, array('UTF-8', 'GBK', 'GB2312', 'GB18030', 'BIG5', 'UTF-7', 'ASCII'), true);
                if ($detected && strtoupper($detected) !== 'UTF-8' && function_exists('mb_convert_encoding')) {
                    proxyLog("LOCAL ENCODING: detect=$detected converting to UTF-8");
                    $content = mb_convert_encoding($content, 'UTF-8', $detected);
                } elseif (!$detected && function_exists('mb_convert_encoding')) {
                    $content2 = @mb_convert_encoding($content, 'UTF-8', 'GBK');
                    if ($content2 !== false && $content2 !== $content) {
                        proxyLog("LOCAL ENCODING: fallback GBK->UTF-8");
                        $content = $content2;
                    }
                }
            }
            header('Content-Type: ' . $ct);
            header('Content-Length: ' . strlen($content));
            echo $content;
            exit;
        }
        header('Content-Type: ' . $ct);
        header('Accept-Ranges: bytes');
        header('Content-Length: ' . $size);
        readfile($localFile);
        exit;
    }
}

function debugDump($content, $httpCode, $errMsg) {
    global $debugLog, $url;
    header('Content-Type: text/html; charset=utf-8');
    echo '<pre style="font-family:Consolas,monospace;font-size:13px;color:#eee;background:#1a1a2e;padding:20px;white-space:pre-wrap;word-break:break-all;">';
    echo '<b style="color:#ffcc66;">=== LMP34 Proxy Debug ===</b>' . "\n\n";
    echo '<b>INPUT URL:</b> ' . htmlspecialchars($url) . "\n\n";
    echo '<b>LOG:</b>' . "\n";
    foreach ($debugLog as $line) echo '  ' . htmlspecialchars($line) . "\n";
    echo "\n" . '<b>HTTP CODE:</b> ' . $httpCode . "\n";
    echo '<b>CURL ERROR:</b> ' . ($errMsg ? htmlspecialchars($errMsg) : '(none)') . "\n";
    echo '<b>CONTENT LEN:</b> ' . strlen($content) . "\n";
    if (strlen($content) > 0) {
        echo "\n<b>CONTENT PREVIEW (first 2000 bytes):</b>\n";
        echo htmlspecialchars(substr($content, 0, 2000));
        if (strlen($content) > 2000) echo "\n\n... (truncated, total " . strlen($content) . " bytes)";
    }
    echo '</pre>';
    exit;
}

/**
 * 绕过服务器 DNS 污染:hosts 硬编码优先 + 公共 DNS 兜底
 * 多个 IP 自动降级尝试
 */
function resolveWithPublicDns($host) {
    if (filter_var($host, FILTER_VALIDATE_IP)) return false;

    $hostsMap = array(
        'www.panurl.cn' => array('104.16.36.185', '104.20.61.142'),
    );
    if (isset($hostsMap[$host])) {
        $list = $hostsMap[$host];
        proxyLog("DNS-resolve via hosts-map: {$host} => " . implode(', ', $list));
        return $list;
    }

    $dnsServers = array('8.8.8.8', '223.5.5.5', '119.29.29.29');

    if (function_exists('shell_exec')) {
        foreach ($dnsServers as $dns) {
            $out = @shell_exec("dig +short @{$dns} {$host} A 2>/dev/null");
            if ($out) {
                $ips = array_filter(array_map('trim', explode("\n", $out)), function($ip) {
                    return filter_var($ip, FILTER_VALIDATE_IP);
                });
                if (!empty($ips)) {
                    proxyLog("DNS-resolve via dig @{$dns}: {$host} => " . implode(', ', $ips));
                    return array_values($ips);
                }
            }
        }
        foreach ($dnsServers as $dns) {
            $out = @shell_exec("nslookup {$host} {$dns} 2>/dev/null");
            if ($out && preg_match_all('/Address:\s*(\d+\.\d+\.\d+\.\d+)/', $out, $m)) {
                $ips = array_unique($m[1]);
                proxyLog("DNS-resolve via nslookup @{$dns}: {$host} => " . implode(', ', $ips));
                return array_values($ips);
            }
        }
    }

    $ip = @gethostbyname($host);
    if ($ip && $ip !== $host && filter_var($ip, FILTER_VALIDATE_IP)) {
        proxyLog("DNS-resolve via gethostbyname: {$host} -> {$ip}");
        return array($ip);
    }

    proxyLog("DNS-resolve FAILED for {$host}");
    return false;
}

proxyLog("REQUEST: " . $url . " range=" . ($hasRange ? $_SERVER['HTTP_RANGE'] : 'none'));

$urlParts = parse_url($url);
$targetHost = isset($urlParts['host']) ? $urlParts['host'] : '';
$targetPort = isset($urlParts['scheme']) && $urlParts['scheme'] === 'https' ? 443 : 80;
$resolvedIps = false;
if ($targetHost) {
    $resolvedIps = resolveWithPublicDns($targetHost);
}

// 构造 curl 的 CURLOPT_RESOLVE 映射(多 IP 供 curl 自动 fallback)
$resolveMap = array();
if (is_array($resolvedIps)) {
    foreach ($resolvedIps as $ip) {
        $resolveMap[] = "{$targetHost}:{$targetPort}:{$ip}";
    }
}

$ext = strtolower(pathinfo(parse_url($url, PHP_URL_PATH), PATHINFO_EXTENSION));
$audioExts = array('mp3'=>'audio/mpeg','wav'=>'audio/wav','ogg'=>'audio/ogg','m4a'=>'audio/mp4','aac'=>'audio/aac','flac'=>'audio/flac','wma'=>'audio/x-ms-wma');
$videoExts = array('mp4'=>'video/mp4','webm'=>'video/webm','ogv'=>'video/ogg','mov'=>'video/quicktime','m4v'=>'video/x-m4v');
$textExts = array('lrc'=>'text/plain','txt'=>'text/plain','srt'=>'text/plain','ass'=>'text/plain','vtt'=>'text/plain','xml'=>'application/xml','json'=>'application/json','css'=>'text/css','js'=>'application/javascript','html'=>'text/html','htm'=>'text/html','ini'=>'text/plain','csv'=>'text/plain','log'=>'text/plain','md'=>'text/plain','smi'=>'text/plain','krc'=>'text/plain');
$defaultCt = 'application/octet-stream';
if (isset($audioExts[$ext])) $defaultCt = $audioExts[$ext];
elseif (isset($videoExts[$ext])) $defaultCt = $videoExts[$ext];
elseif (isset($textExts[$ext])) $defaultCt = $textExts[$ext] . '; charset=utf-8';

$content = false;
$respHeaders = array();
$httpCode = 0;
$errMsg = '';

if (function_exists('curl_init')) {
    $ch = curl_init($url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
    curl_setopt($ch, CURLOPT_MAXREDIRS, 10);
    curl_setopt($ch, CURLOPT_ENCODING, 'gzip, deflate');
    curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
    curl_setopt($ch, CURLOPT_REFERER, dirname($url) . '/');
    curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
    curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
    curl_setopt($ch, CURLOPT_TIMEOUT, 60);
    curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 15);
    $extraHeaders = array(
        'Accept: */*',
        'Accept-Language: zh-CN,zh;q=0.9,en;q=0.8'
    );
    if ($hasRange) {
        $extraHeaders[] = 'Range: ' . $_SERVER['HTTP_RANGE'];
    }
    curl_setopt($ch, CURLOPT_HTTPHEADER, $extraHeaders);
    curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($ch, $header) use (&$respHeaders) {
        $trimmed = trim($header);
        if ($trimmed !== '' && strpos($trimmed, ':') !== false) {
            list($k, $v) = explode(':', $trimmed, 2);
            $respHeaders[strtolower(trim($k))] = trim($v);
        }
        return strlen($header);
    });
    if (!empty($resolveMap)) {
        curl_setopt($ch, CURLOPT_RESOLVE, $resolveMap);
        proxyLog("CURLOPT_RESOLVE: " . count($resolveMap) . " IP(s) set");
    }
    $content = curl_exec($ch);
    $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $errMsg = curl_error($ch);
    $effectiveUrl = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);
    curl_close($ch);
    proxyLog("CURL: code=$httpCode effective=$effectiveUrl err=$errMsg len=" . ($content === false ? 'false' : strlen($content)));
}

if ($content === false) {
    $opts = array(
        'http' => array(
            'method' => 'GET',
            'header' => "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36\r\nReferer: " . dirname($url) . "/\r\n",
            'timeout' => 60
        ),
        'ssl' => array('verify_peer' => false, 'verify_peer_name' => false)
    );
    if ($hasRange) {
        $opts['http']['header'] .= 'Range: ' . $_SERVER['HTTP_RANGE'] . "\r\n";
    }
    $headerHost = ($resolvedIps && $targetHost) ? "Host: {$targetHost}\r\n" : '';
    $ctx = stream_context_create($opts);
    if ($resolvedIps && $targetHost) {
        foreach ($resolvedIps as $ip) {
            $fgcUrl = preg_replace('/^(' . preg_quote($urlParts['scheme'], '/') . ':\/\/)' . preg_quote($targetHost, '/') . '/', '$1' . $ip, $url, 1);
            proxyLog("FGC try: {$fgcUrl}");
            $tryCtx = stream_context_create(array(
                'http' => array(
                    'method' => 'GET',
                    'header' => $headerHost . $opts['http']['header'],
                    'timeout' => 60
                ),
                'ssl' => $opts['ssl']
            ));
            $content = @file_get_contents($fgcUrl, false, $tryCtx);
            if ($content !== false) {
                $httpCode = 200;
                $errMsg = '';
                proxyLog("FGC success via {$ip} len=" . strlen($content));
                break;
            }
            proxyLog("FGC failed via {$ip}");
        }
    } else {
        $content = @file_get_contents($url, false, $ctx);
        if ($content !== false) {
            $httpCode = 200;
            proxyLog("FGC: success len=" . strlen($content));
        } else {
            proxyLog("FGC: failed");
        }
    }
}

if ($content === false) {
    proxyLog("FINAL: 502 error");
    if ($debug) debugDump('', 0, $errMsg ? $errMsg : 'fetch failed');
    header('HTTP/1.1 502 Bad Gateway');
    header('Content-Type: text/plain');
    echo 'proxy error: ' . ($errMsg ? $errMsg : 'fetch failed');
    exit;
}

if (strlen($content) === 0) {
    proxyLog("FINAL: empty response");
    if ($debug) debugDump('', $httpCode, 'empty response from remote');
    header('HTTP/1.1 502 Bad Gateway');
    header('Content-Type: text/plain');
    echo 'proxy error: empty response from remote';
    exit;
}

$contentInfo = isset($respHeaders['content-type']) ? $respHeaders['content-type'] : 'unknown';
proxyLog("CONTENT: type=$contentInfo len=" . strlen($content) . " firstbytes=" . bin2hex(substr($content, 0, 16)));

if ($httpCode >= 400) {
    proxyLog("FINAL: remote error $httpCode");
    if ($debug) debugDump($content, $httpCode, 'remote returned ' . $httpCode);
    header('HTTP/1.1 ' . $httpCode);
    header('Content-Type: text/plain');
    echo 'remote error ' . $httpCode;
    exit;
}

$ct = isset($respHeaders['content-type']) ? $respHeaders['content-type'] : $defaultCt;
if (isset($textExts[$ext]) && strpos($ct, 'charset') === false) {
    $ct = rtrim($ct, '; ') . '; charset=utf-8';
}
if (isset($textExts[$ext]) && !$hasRange) {
    $detected = false;
    if (function_exists('mb_detect_encoding')) {
        $detected = mb_detect_encoding($content, array('UTF-8', 'GBK', 'GB2312', 'GB18030', 'BIG5', 'UTF-7', 'ASCII'), true);
    }
    if ($detected && strtoupper($detected) !== 'UTF-8' && function_exists('mb_convert_encoding')) {
        proxyLog("ENCODING: detect=$detected converting to UTF-8");
        $content = mb_convert_encoding($content, 'UTF-8', $detected);
        $ct = preg_replace('/charset=[^;]+/i', 'charset=utf-8', $ct);
        if (strpos($ct, 'charset') === false) $ct = rtrim($ct, '; ') . '; charset=utf-8';
    } elseif (!$detected && function_exists('mb_convert_encoding')) {
        $content2 = @mb_convert_encoding($content, 'UTF-8', 'GBK');
        if ($content2 !== false && $content2 !== $content) {
            proxyLog("ENCODING: fallback GBK->UTF-8 (changed)");
            $content = $content2;
            $ct = preg_replace('/charset=[^;]+/i', 'charset=utf-8', $ct);
            if (strpos($ct, 'charset') === false) $ct = rtrim($ct, '; ') . '; charset=utf-8';
        }
    }
}
header('Content-Type: ' . $ct);
header('Accept-Ranges: bytes');

$contentLength = strlen($content);
header('Content-Length: ' . $contentLength);

if ($hasRange && $httpCode === 206) {
    header('HTTP/1.1 206 Partial Content');
    if (isset($respHeaders['content-range'])) {
        header('Content-Range: ' . $respHeaders['content-range']);
    }
}

proxyLog("FINAL: ct=$ct len=$contentLength code=$httpCode");
if ($debug) debugDump($content, $httpCode, 'success');
echo $content;

三、频谱场景核心支撑功能

1. 跨域 CORS 全链路支持(频谱前提)

全局设置 Access-Control-Allow-Origin: *、允许 Range/Content-Type 头、拦截 OPTIONS 预检返回 204,彻底解决 Web Audio 跨域加载音频被浏览器拦截的问题,前端可直接用 fetch() 拉取任意域音频并 decode 成 AudioBuffer。

2. 音频资源自动适配(频谱解码基础)

按音频扩展名(mp3/wav/ogg/m4a/aac/flac/wma)自动映射正确的 Content-Type,确保 AudioContext.decodeAudioData() 不会因 MIME 不匹配报错;远程音频优先使用响应头 MIME,缺失时按扩展名兜底。

Proxy.php 频谱分析支撑功能介绍

3. Range 分块请求支持(大音频频谱场景)

完整支持 HTTP Range 协议,本地文件通过 fseek 精准切片、远程资源原样透传 Range 头,让前端对大尺寸音频文件可分块加载,避免一次性加载全量 AudioBuffer 导致的内存溢出,同时适配 AnalyzerNode 分段处理需求。

4. DNS 污染绕过(受限音频频谱拉取)

内置三级降级 DNS 解析策略对抗运营商/防火墙 DNS 污染:

  • 硬编码 hosts 映射:针对已知污染域名直接绑定多个真实 IP
  • 公共 DNS 兜底:依次尝试多个公共 DNS 服务器
  • 系统 gethostbyname 最终兜底

解析到多个 IP 后,cURL 分支通过 CURLOPT_RESOLVE 注入实现连接级自动 fallback;file_get_contents 分支则遍历 IP 逐个替换 Host 重试,确保被污染的音频 CDN 资源能被成功拉取。

5. 本地/远程音频统一入口(频谱逻辑简化)

前端无需区分音频存储位置(同服务器本地 or 外部远程),统一使用 proxy.php?url=xxx 格式请求,频谱模块只需处理一种 URL 规则,减少前端分支逻辑。

6. LRC/字幕元数据自动转码(频谱时间轴同步)

对文本类元数据(lrc/srt/ass/krc/smi 等)自动检测编码(UTF-8/GBK/GB2312/GB18030/BIG5),非 UTF-8 自动转换为 UTF-8,确保频谱分析时的音频时间轴与歌词/字幕时间轴能正确对齐,避免乱码导致的同步失效。

7. 远程音频拉取多重降级(频谱稳定性保障)

  • 优先使用 cURL,支持多 IP 自动 fallback、gzip/deflate 解压、最多 10 次重定向跟随
  • cURL 不可用/失败时,降级为 file_get_contents + stream_context_create
  • file_get_contents 直连失败时,遍历解析到的所有 IP 逐个替换 Host 重试
  • 所有方案均失败时返回 502 错误并附带 debug 信息

8. UA 伪装与防爬虫拦截(受限 CDN 兼容)

伪装成主流浏览器 User-Agent、自动模拟真实 Referer、禁用 SSL 严格校验,绕过音频 CDN 的爬虫拦截策略,确保受限资源能被正常拉取用于频谱分析。

9. Debug 调试面板(频谱问题快速排查)

URL 追加 &debug 时输出完整调试信息:DNS 解析链路、cURL 错误、HTTP 状态码、Content-Type、音频大小、响应头、资源前 2000 字节预览,快速定位频谱分析失败原因(如 MIME 错误、DNS 劫持、跨域拦截)。

四、频谱场景调用示例

  • 本地测试音频:proxy.php?url=audio/test.mp3
  • 远程受限音频:proxy.php?url=https://xxxxx.com/song.flac
  • 歌词+频谱同步:proxy.php?url=https://xxxxx.com/lyric.lrc
  • 频谱问题调试:proxy.php?url=https://xxxxx.com/song.flac&debug

五、频谱场景环境要求

  • PHP 5.4+
  • 必装扩展:curl、mbstring(文本编码转换)、allow_url_fopen=On
  • 可选系统命令:dig / nslookup(增强 DNS 污染绕过能力)