Files
ichni_Creator_Studio/Assets/Scripts/DynamicUI/Timeline/AudioMelSpectrogram.cs
TRADER_FOER a6f24c4258 加上频谱图
Signed-off-by: TRADER_FOER <lhf190@outlook.com>
2026-06-29 01:39:39 +08:00

615 lines
34 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Threading;
using Unity.Mathematics;
using UnityEngine;
using UnityEngine.UI;
namespace Ichni.Editor
{
/// <summary>
/// 非 MonoBehaviour 的 Mel 频谱图渲染器。
///
/// === 架构 ===
/// PathNodeCurveDrawer.Update()
/// └─ AudioMelSpectrogram.UpdateView(curT, maxT)
/// ├─ 首次检测 AudioClip → 后台线程预计算全曲 FFT
/// ├─ 每帧从缓存取出当前可见时间段 → 颜色 LUT → Color32[] → SetPixelData → Apply
/// └─ 视角未变时整帧跳过
///
/// 后台线程PCM 读取(主线程) → FFT + Mel 滤波 + Log(子线程) → 缓存 Log 能量
/// 主线程: 按 visibleStart/End 映射到帧范围 → 逐列查 LUT → 写像素 → GPU upload
/// </summary>
public class AudioMelSpectrogram
{
// ══════════════════════════════════════════════════════════════════════
// [公共参数] 由 PathNodeCurveDrawer 每帧通过字段赋值同步
// ══════════════════════════════════════════════════════════════════════
/// <summary>渲染目标的 RawImage。尺寸从此读取 × resolutionScale 决定纹理大小。</summary>
public RawImage targetImage;
/// <summary>纹理分辨率缩放。0.5 = 半分辨率(宽/高各砍一半,像素数 1/4。</summary>
[Range(0.1f, 1f)] public float resolutionScale = 0.5f;
/// <summary>颜色渐变:归一化能量 [0,1] → Color。Inspector 修改实时生效。</summary>
public Gradient colorGradient;
// ══════════════════════════════════════════════════════════════════════
// [FFT / Mel 常量] 改动需要重新预计算才会生效
// ══════════════════════════════════════════════════════════════════════
/// <summary>FFT 窗口大小(采样点数)。越大频率分辨率越高、时间分辨率越低。
/// 4096 @ 44100Hz ≈ 93ms 窗口,频率分辨率 ≈ 10.8Hz。
/// 配合 512 帧移87.5% overlap维持时间分辨率 ~11.6ms。</summary>
private const int FFT_SIZE = 4096;
/// <summary>帧移(相邻 FFT 窗口的起始采样偏移量)。
/// 512 = 50% overlapHOP_SIZE = FFT_SIZE / 2
/// 每帧对应时间 = HOP_SIZE / sampleRate ≈ 11.6ms。</summary>
private const int HOP_SIZE = 512;
/// <summary>Mel 滤波器组数量。也是缓存和纹理的高度方向分辨率。
/// 128 个三角滤波器在 Mel 尺度上等距分布,覆盖 0 ~ sampleRate/2 Hz。
/// 更高 → 频率细节更多,但低频段相邻 bin 间距更窄(~40Hz
/// 对钢琴相邻半音(~2Hz @ 440Hz仍偏粗但已是实用上限。</summary>
private const int MEL_BINS = 128;
/// <summary>Mel 尺度常数m = 2595 × log10(1 + f/700)。</summary>
private const float MEL_A = 2595f;
/// <summary>Mel 尺度常数f = 700 × (10^(m/2595) - 1)。</summary>
private const float MEL_B = 700f;
// ══════════════════════════════════════════════════════════════════════
// [预计算缓存] 后台线程写入 → 主线程只读
// ══════════════════════════════════════════════════════════════════════
/// <summary>当前已缓存的 AudioClip 引用。检测 clip 变化时触发重新预计算。</summary>
private AudioClip _cachedClip;
/// <summary>预计算完成后的 Log-Mel 能量缓存。
/// 布局flat[ frameIndex × MEL_BINS + melBin ],每个元素 = Log(能量)。
/// 存 Log 值避免每帧重复 Mathf.Log。volatile 保证主线程读到最新值。</summary>
private volatile float[] _logMelData;
/// <summary>后台线程正在写入的临时缓存。完成后通过 _cacheReady 交给主线程。</summary>
private float[] _pendingLogData;
/// <summary>后台线程完成标记。主线程检测到此标志后切 _pendingLogData → data。</summary>
private volatile bool _cacheReady;
/// <summary>总帧数。由 (totalSamples - FFT_SIZE) / HOP_SIZE + 1 计算。
/// 决定了 data 的第一维长度。</summary>
private int _totalFrames;
/// <summary>每帧对应的时间(秒)= HOP_SIZE / sampleRate。
/// frame f 的中心时间 = (f + 1) × _timePerFrame。</summary>
private float _timePerFrame;
/// <summary>Mel 三角滤波器组。_melFilters[m][i] = 第 m 个 mel bin 在 FFT bin i 上的权重。
/// 在预计算开始时构建,之后只读。后台线程安全(写入完成后才启动线程)。</summary>
private float[][] _melFilters;
/// <summary>AudioClip 的采样率Hz。用于 _timePerFrame 和 Mel 滤波器组的频率轴计算。</summary>
private int _sampleRate;
/// <summary>后台线程正在运行的标记。防止重复启动线程。</summary>
private volatile bool _computing;
/// <summary>后台线程已计算完成的帧数。主线程用此判断可以读 _pendingLogData 的前 N 帧。
/// 后台线程每算完一批(每 16 帧)通过 Volatile.Write 更新,
/// 主线程通过 Volatile.Read 读取,保证写 release/读 acquire 语义。</summary>
private volatile int _framesComputed;
// ══════════════════════════════════════════════════════════════════════
// [运行时纹理]
// ══════════════════════════════════════════════════════════════════════
/// <summary>实际写入的 Texture2D。尺寸 = RawImage 像素尺寸 × resolutionScale。
/// 纹理格式 RGBA32FilterMode 可切换为 Point 避免插值模糊。</summary>
private Texture2D _texture;
/// <summary>像素缓冲区CPU 侧)。每帧通过 unsafe 指针直接写入,
/// 然后 SetPixelData 整块提交到 GPU。SetPixelData 比 SetPixels 快因为走原始字节拷贝。</summary>
private Color32[] _pixels32;
/// <summary>当前纹理尺寸。用于检测 RawImage 尺寸变化并重建纹理。</summary>
private int _texW, _texH;
/// <summary>上一帧渲染的 FFT 帧范围。用于帧级抖动过滤:
/// 当 songTime 在帧边界附近振荡时startF/endF 不变就不重绘,
/// 避免子帧级变化导致整张图反复左右偏移。</summary>
private int _prevStartF = -1, _prevEndF = -1;
// ══════════════════════════════════════════════════════════════════════
// [颜色 LUT] 256 级预计算查找表
// ══════════════════════════════════════════════════════════════════════
/// <summary>256 级颜色查找表。norm[0,1] → LUT[(int)(norm × 255)]。
/// 避免每像素调用 Gradient.Evaluate函数调用 + 键插值开销)。</summary>
private Color32[] _colorLut;
/// <summary>上次构建 LUT 时使用的 Gradient 引用。
/// 为 null 或与当前 colorGradient 不同时重建。
/// 注意Inspector 修改 Gradient 时不改变引用,所以用了直接引用 + 用户 Gradient 每次重建的策略。</summary>
private Gradient _appliedGradient;
// ══════════════════════════════════════════════════════════════════════
// [Bin 查找表] y → melBin 的预计算映射
// ══════════════════════════════════════════════════════════════════════
/// <summary>纹理行 y → Mel 滤波器索引。_binForY[y] = round(y / h × MEL_BINS)。
/// 避免每像素重复计算浮点除法和 round。</summary>
private int[] _binForY;
// ══════════════════════════════════════════════════════════════════════
// 每帧入口
// ══════════════════════════════════════════════════════════════════════
/// <summary>每帧由 PathNodeCurveDrawer.Update() 调用。</summary>
/// <param name="audioSource">从 ModuleTime 获取的 AudioSource用于检测 AudioClip。</param>
/// <param name="visibleStart">可见范围起点(秒),通常 = ModuleTime.SmoothTime与 CurrentBeatF 同源)。</param>
/// <param name="visibleEnd">可见范围终点(秒),由 PathNodeCurveDrawer 按 beatRange × 60/BPM 换算。</param>
public void UpdateView(AudioSource audioSource, float visibleStart, float visibleEnd, bool forceUpdate = false)
{
if (targetImage == null) return;
// ── 检测新 AudioClip → 启动后台预计算 ──────────────────────────
// _cachedClip 记录上次预计算的 clip。只要 clip 引用变了就触发重新计算。
// _computing 防重入,后台线程运行期间不重复启动。
if (audioSource != null && audioSource.clip != null && audioSource.clip != _cachedClip)
{
if (!_computing)
StartPreCompute(audioSource.clip);
}
// ── 后台线程完成 → 切缓存 ──────────────────────────────────────
// 必须放在 _computing 检查之前:先完成缓存切换,再让 _computing check 通过。
if (_cacheReady)
{
_logMelData = _pendingLogData;
_pendingLogData = null;
_totalFrames = _logMelData.Length / MEL_BINS;
_cachedClip = audioSource.clip;
_cacheReady = false;
_computing = false;
}
// ── 完全态:全缓存就绪,正常渲染 ────────────────────────────
if (!_computing && _logMelData != null && _totalFrames > 0)
{
RenderCurrentView(visibleStart, visibleEnd, forceUpdate);
return;
}
// ── 部分态:后台计算中,但已有部分帧可供显示 ────────────────────
if (_computing && _pendingLogData != null && _framesComputed > 0)
{
// 用 _pendingLogData 渲染已完成部分
RenderCurrentView(visibleStart, visibleEnd, true,
_pendingLogData, _framesComputed);
return;
}
}
// ══════════════════════════════════════════════════════════════════════
// 渲染当前视图(被完全态和部分态共用)
// ══════════════════════════════════════════════════════════════════════
/// <summary>根据 visibleStart/End 计算帧范围,渲染到纹理并提交 GPU。</summary>
/// <param name="data">数据源:完全态用 data部分态用 _pendingLogData。</param>
/// <param name="framesAvail">可用帧数:完全态 = _totalFrames部分态 = _framesComputed。</param>
private void RenderCurrentView(float visibleStart, float visibleEnd, bool forceUpdate,
float[] data = null, int framesAvail = 0)
{
// 默认走完全态 _logMelData / _totalFrames
if (data == null) { data = _logMelData; framesAvail = _totalFrames; }
if (data == null || framesAvail == 0) return;
// ── 可见时间范围 → FFT 帧范围 ─────────────────────────────────
// FFT 帧 f 的时间中心 = (f × HOP_SIZE + FFT_SIZE/2) / sampleRate
// 当 FFT_SIZE=4096, HOP_SIZE=512: 帧 f 中心在 (f + 4) × _timePerFrame
float frameOffset = FFT_SIZE / (2f * HOP_SIZE);
// 先算帧号,后判断跳过。帧级检测能过滤 songTime 在帧边界附近的振荡,
// 避免 startF/endF 没变时整张图反复左右偏移。
int startF = Mathf.Max(0, Mathf.RoundToInt(visibleStart / _timePerFrame - frameOffset));
int endF = Mathf.Max(1, Mathf.RoundToInt(visibleEnd / _timePerFrame - frameOffset));
// ── 帧号未变 → 跳过重绘(部分态 forceUpdate=true 不会跳过)──
if (!forceUpdate && startF == _prevStartF && endF == _prevEndF && _pixels32 != null)
return;
int visFrames = endF - startF;
float dur = visibleEnd - visibleStart;
if (dur <= 0f) return;
// ── 纹理尺寸 ──
var rect = targetImage.rectTransform.rect;
int w = Mathf.Max(1, Mathf.RoundToInt(rect.width * resolutionScale));
int h = Mathf.Max(1, Mathf.RoundToInt(rect.height * resolutionScale));
bool sizeChanged = _texture == null || _texture.width != w || _texture.height != h;
if (sizeChanged)
InitTexture(w, h);
// ── 全量重绘 ──
BuildColorLut();
RenderColumns(startF, visFrames, w, h, 0, w - 1, data, framesAvail);
_texture.SetPixelData(_pixels32, 0);
_texture.Apply(false);
_prevStartF = startF;
_prevEndF = endF;
_texW = w;
_texH = h;
}
// ══════════════════════════════════════════════════════════════════════
// 列渲染(热路径)
// ══════════════════════════════════════════════════════════════════════
/// <summary>渲染 colFrom~colTo 列的像素。被 UpdateView 每帧调用(全量重绘)。</summary>
/// <param name="startF">可见范围起点帧号</param>
/// <param name="visFrames">可见范围总帧数</param>
/// <param name="w">纹理像素宽度</param>
/// <param name="h">纹理像素高度</param>
/// <param name="colFrom">起始列(含)</param>
/// <param name="colTo">结束列(含)</param>
private void RenderColumns(int startF, int visFrames, int w, int h,
int colFrom, int colTo,
float[] data, int totalFrames)
{
// 在堆上分配每列的临时缓冲区。
// 相比 stackalloc 有少量 GC 压力,但在 h ≤ 128、每帧最多调用一次的情况下可以忽略。
// 用途:一趟读 data 同时写入 colVals + 扫描 min/max
// 第二趟从 colVals 读(避免第二趟再访存托管数组)。
float[] colVals = new float[h];
for (int x = colFrom; x <= colTo; x++)
{
// 计算当前列对应的 FFT 帧号
float t = (float)x / w; // 归一化列位置 [0,1]
int frameIdx = startF + (int)math.round(t * visFrames);
// 超出已计算帧 → 跳过该列,维持透明背景
if (frameIdx >= totalFrames) continue;
if (frameIdx < 0) continue;
int rowBase = frameIdx * MEL_BINS; // 该帧在 data 中的起始偏移
// ── 一趟读入 stack + 扫描 min/max ──
// 遍历 h 行,每行用 _binForY 映射到 Mel bin
// 从 data 取值 → 存入 colVals[y] → 同时更新 mn/mx。
float mn = float.MaxValue, mx = float.MinValue;
for (int y = 0; y < h; y++)
{
float v = data[rowBase + _binForY[y]];
colVals[y] = v;
if (v < mn) mn = v;
if (v > mx) mx = v;
}
// 防止全静音帧导致除零。invRange 在后续乘 norm不额外乘除。
float invRange = 1f / math.max(mx - mn, 0.001f);
// ── 从 stack 渲染 ──
// norm[0,1] → idx[0,255] → LUT[idx] → 写入像素。
for (int y = 0; y < h; y++)
{
float norm = (colVals[y] - mn) * invRange;
int idx = (int)(norm * 255);
if (idx > 255) idx = 255;
_pixels32[y * w + x] = _colorLut[idx];
}
}
}
// ══════════════════════════════════════════════════════════════════════
// 后台预计算
// ══════════════════════════════════════════════════════════════════════
/// <summary>启动后台线程预计算。
/// 主线程:读取 PCM + 混单声道 + 构建 Mel 滤波器组。
/// 子线程Hann 加窗 → FFT → 功率谱 → Mel 三角滤波 → Log → 写入 _pendingLogData。</summary>
private void StartPreCompute(AudioClip clip)
{
_computing = true;
_cacheReady = false;
_framesComputed = 0; // 重置进度计数器
_cachedClip = clip;
_sampleRate = clip.frequency;
_timePerFrame = (float)HOP_SIZE / _sampleRate; // ≈ 11.6ms @44100Hz
// 构建 Mel 滤波器组(主线程,因为 _melFilters 不是 volatile
BuildMelFilterBank();
// ── 主线程:读取 PCM 并混为单声道 ──
// AudioClip.GetData() 必须在主线程调用Unity API 限制)。
// clip.samples = 每声道的采样数totalSamples × channels = 总 float 数。
int totalSamples = clip.samples;
int channels = clip.channels;
float[] samples = new float[totalSamples * channels];
clip.GetData(samples, 0);
// 多声道 → 单声道:取各声道平均值。
float[] mono = new float[totalSamples];
if (channels == 1)
System.Array.Copy(samples, mono, totalSamples);
else
for (int i = 0; i < totalSamples; i++)
{
float sum = 0f;
for (int c = 0; c < channels; c++)
sum += samples[i * channels + c];
mono[i] = sum / channels;
}
// 总帧数 = 能容纳的 HOP_SIZE 步进数
// 最后一帧的起始偏移 = totalSamples - FFT_SIZE不足一帧时返回至少 1 帧。
int totalFrames = Mathf.Max(1, (totalSamples - FFT_SIZE) / HOP_SIZE + 1);
float[] pending = new float[totalFrames * MEL_BINS];
_pendingLogData = pending; // 立即暴露给主线程读取,线程写内容 + 更新 _framesComputed
// ── 后台线程FFT + Mel 滤波 + Log ──
// 所有数据mono, window, _melFilters在线程启动前已就绪不需要同步。
// 只写 pending[],不碰任何 Unity API。
var thread = new Thread(() =>
{
// Blackman-Harris4 项最小旁瓣窗):旁瓣 ≤ -92dB远优于 Hann 的 -32dB。
// 代价:主瓣宽度比 Hann 宽约 60%,但 FFT_SIZE=4096 下仍只有 ~22Hz
// 远窄于 Mel 滤波器间距,不影响频率分辨率。
float[] window = new float[FFT_SIZE];
float bhA0 = 0.35875f, bhA1 = 0.48829f, bhA2 = 0.14128f, bhA3 = 0.01168f;
float invN1 = 1f / (FFT_SIZE - 1);
for (int i = 0; i < FFT_SIZE; i++)
{
float p = Mathf.PI * 2f * i * invN1;
window[i] = bhA0 - bhA1 * Mathf.Cos(p) + bhA2 * Mathf.Cos(2f * p) - bhA3 * Mathf.Cos(3f * p);
}
float[] real = new float[FFT_SIZE];
float[] imag = new float[FFT_SIZE];
float[] power = new float[FFT_SIZE / 2];
for (int f = 0; f < totalFrames; f++)
{
int offset = f * HOP_SIZE;
// 加窗mono[offset + i] × window[i] → real[i]
for (int i = 0; i < FFT_SIZE; i++)
{
int idx = offset + i;
real[i] = idx < totalSamples ? mono[idx] * window[i] : 0f;
imag[i] = 0f;
}
// 原位 Cooley-Tukey radix-2 FFT → real/imag 为复频谱
FFT(real, imag);
// 功率谱 |X|² = real² + imag²只取前 N/2 个正频率 bin
for (int i = 0; i < FFT_SIZE / 2; i++)
power[i] = real[i] * real[i] + imag[i] * imag[i];
// Mel 三角滤波:每个 Mel bin 是若干 FFT bin 的加权和
int row = f * MEL_BINS;
for (int m = 0; m < MEL_BINS; m++)
{
float e = 0f;
var filter = _melFilters[m];
for (int i = 0; i < FFT_SIZE / 2; i++)
e += power[i] * filter[i];
// 存 Log(energy) 而不是 raw energy
// 人耳对声压的感知是对数的,且 Log 压缩后帧循环不再需要 Mathf.Log。
pending[row + m] = Mathf.Log(Mathf.Max(e, 1e-10f));
}
// 每算完 16 帧(≈ 186ms @11.6ms/帧),更新进度计数器。
// Volatile.Write 保证对主线程可见,同时隐含 release 语义,
// 确保写 pending[] 的 stores 在写 _framesComputed 之前完成。
if ((f & 15) == 0 || f == totalFrames - 1)
_framesComputed = f + 1; // volatile 写入,主线程立即可见
}
_cacheReady = true; // 通知主线程
});
thread.IsBackground = true; // 应用程序退出时自动终止
thread.Start();
}
// ══════════════════════════════════════════════════════════════════════
// Mel 三角滤波器组
// ══════════════════════════════════════════════════════════════════════
/// <summary>构建 Mel 三角滤波器组。
/// 在 Mel 尺度上均匀放置 MEL_BINS 个三角滤波器,每个覆盖 3 个 Mel 点(左/中心/右)。
/// 频率范围0 ~ sampleRate/2 Hz。
/// 三角峰值为 1归一化相邻滤波器间交叠 ≈ 50%。</summary>
private void BuildMelFilterBank()
{
int half = FFT_SIZE / 2;
float freqMax = _sampleRate / 2f; // Nyquist 频率
float melMin = FreqToMel(0f);
float melMax = FreqToMel(freqMax);
// 每个 FFT bin 的频率Hz
float[] binFreq = new float[half];
for (int i = 0; i < half; i++)
binFreq[i] = (float)i / FFT_SIZE * _sampleRate;
_melFilters = new float[MEL_BINS][];
for (int m = 0; m < MEL_BINS; m++)
{
// 在 Mel 尺度上等距取三个点:左 / 中心 / 右
float mL = melMin + (melMax - melMin) * (m) / (MEL_BINS + 1);
float mC = melMin + (melMax - melMin) * (m + 1) / (MEL_BINS + 1);
float mR = melMin + (melMax - melMin) * (m + 2) / (MEL_BINS + 1);
float fL = MelToFreq(mL), fC = MelToFreq(mC), fR = MelToFreq(mR);
// 三角滤波器:左→中心上升段,中心→右下降段,其余为 0
var filter = new float[half];
for (int i = 0; i < half; i++)
{
float f = binFreq[i];
if (f >= fL && f < fC)
filter[i] = (f - fL) / (fC - fL); // 上升
else if (f >= fC && f < fR)
filter[i] = (fR - f) / (fR - fC); // 下降
// else: 0
}
_melFilters[m] = filter;
}
}
// ══════════════════════════════════════════════════════════════════════
// FFT — 实序列原位 Cooley-Tukey radix-2 DIT时域抽取
// ══════════════════════════════════════════════════════════════════════
/// <summary>一维实序列 FFT正变换
/// 输入real[i] = 实部时域信号imag[i] = 0。
/// 输出real[i] + j×imag[i] = 复频谱0 ~ sampleRate对称
/// 只需要前 N/2 个 bin0 ~ Nyquist做功率谱计算。
///
/// radix-2 = 输入长度必须为 2 的幂FFT_SIZE = 1024 ✅)。
/// DIT = 先 bit-reversal 重排,再逐级蝶形运算。</summary>
private static void FFT(float[] real, float[] imag)
{
int n = real.Length;
// 1. Bit-reversal 重排:使蝶形运算的输入自然有序
for (int i = 1, j = 0; i < n; i++)
{
int bit = n >> 1;
for (; j >= bit; bit >>= 1) j -= bit;
j += bit;
if (i < j)
{
(real[i], real[j]) = (real[j], real[i]);
(imag[i], imag[j]) = (imag[j], imag[i]);
}
}
// 2. 蝶形运算逐级合并len = 2 → 4 → 8 → ... → n
for (int len = 2; len <= n; len <<= 1)
{
float ang = 2f * Mathf.PI / len; // 旋转因子角度
float wlenR = Mathf.Cos(ang);
float wlenI = -Mathf.Sin(ang); // 负号 = 正变换
for (int i = 0; i < n; i += len)
{
float wr = 1f, wi = 0f; // 初始旋转因子 = 1
int half = len >> 1;
for (int j = 0; j < half; j++)
{
int i1 = i + j; // 偶序号
int i2 = i + j + half; // 奇序号
// t = W × X[i2]
float tr = wr * real[i2] - wi * imag[i2];
float ti = wr * imag[i2] + wi * real[i2];
// 蝶形X[i1] = X[i1] + t, X[i2] = X[i1] - t
real[i2] = real[i1] - tr;
imag[i2] = imag[i1] - ti;
real[i1] += tr;
imag[i1] += ti;
// 更新旋转因子
(wr, wi) = (wr * wlenR - wi * wlenI, wr * wlenI + wi * wlenR);
}
}
}
}
// ══════════════════════════════════════════════════════════════════════
// Mel 尺度转换
// ══════════════════════════════════════════════════════════════════════
/// <summary>频率Hz→ Mel 值。m = 2595 × log10(1 + f/700)。</summary>
private static float FreqToMel(float f) => MEL_A * Mathf.Log10(1f + f / MEL_B);
/// <summary>Mel 值 → 频率Hz。f = 700 × (10^(m/2595) - 1)。</summary>
private static float MelToFreq(float m) => MEL_B * (Mathf.Pow(10f, m / MEL_A) - 1f);
// ══════════════════════════════════════════════════════════════════════
// 纹理 / LUT / Bin 表
// ══════════════════════════════════════════════════════════════════════
/// <summary>创建或重建纹理与辅助表。</summary>
private void InitTexture(int w, int h)
{
// 纹理格式 RGBA32 = 每像素 4 字节R/G/B/A × byte与 Color32 结构一致。
// mipChain: false = 不生成 mipmap节省 GPU 内存和 upload 带宽。
_texture = new Texture2D(w, h, TextureFormat.RGBA32, false);
_texture.filterMode = FilterMode.Point; // 无插值,每格锐利显示频率带
_texture.wrapMode = TextureWrapMode.Clamp; // 边缘不循环
_pixels32 = new Color32[w * h];
var bg = (Color32)Color.clear; // 全透明背景
for (int i = 0; i < _pixels32.Length; i++)
_pixels32[i] = bg;
_texture.SetPixelData(_pixels32, 0);
_texture.Apply(false);
if (targetImage != null)
targetImage.texture = _texture;
// 预计算 y → bin 索引:每行 y 映射到最近的 Mel 滤波器
_binForY = new int[h];
for (int y = 0; y < h; y++)
_binForY[y] = Mathf.Clamp(
Mathf.RoundToInt((float)y / h * MEL_BINS),
0, MEL_BINS - 1);
_appliedGradient = null; // 强制下次重建 LUT
}
/// <summary>构建 256 级颜色 LUT。
/// 用户设置了 Gradient → 每次重建Inspector 修改不改变引用)。
/// 使用默认 Gradient → 缓存到静态引用。</summary>
private void BuildColorLut()
{
var grad = colorGradient != null ? colorGradient : GetDefaultGradient();
// 只在「使用默认 Gradient 且已缓存」时跳过重建
if (colorGradient == null && _appliedGradient == grad && _colorLut != null)
return;
_colorLut = new Color32[256];
for (int i = 0; i < 256; i++)
_colorLut[i] = (Color32)grad.Evaluate(i / 255f);
_appliedGradient = grad;
}
/// <summary>默认半透渐变:低能透明 → 深紫 → 青蓝 → 青绿 → 金黄 → 白。
/// 透明度逐渐升高,叠在曲线下方也不会遮挡。</summary>
private static Gradient _defaultGradient;
private static Gradient GetDefaultGradient()
{
if (_defaultGradient != null) return _defaultGradient;
_defaultGradient = new Gradient();
_defaultGradient.SetKeys(
new GradientColorKey[]
{
new GradientColorKey(new Color(0.4f, 0.1f, 0.6f), 0f),
new GradientColorKey(new Color(0f, 0.6f, 0.8f), 0.35f),
new GradientColorKey(new Color(0.2f, 0.9f, 0.5f), 0.6f),
new GradientColorKey(new Color(1f, 0.85f,0.3f), 0.8f),
new GradientColorKey(Color.white, 1f),
},
new GradientAlphaKey[]
{
new GradientAlphaKey(0f, 0f),
new GradientAlphaKey(0.5f, 0.15f),
new GradientAlphaKey(0.75f,0.4f),
new GradientAlphaKey(0.9f, 0.7f),
new GradientAlphaKey(1f, 1f),
}
);
return _defaultGradient;
}
}
}