import 'dart:math' as math; import 'dart:typed_data'; /// AI 对话的提示音:唤醒「叮」、结束「咚」。 /// /// 现合成而不是打包音频资源,理由有三: /// - 这两个音要**在耳机里**响,得走 AI 下行通道以 16k/16bit/mono 的裸 PCM 推下去, /// 打包成 mp3/wav 还得先解码,白绕一圈; /// - 两个正弦加包络一共几十行,比维护资源文件省事; /// - 采样率跟着 [sampleRate] 走,将来下行换采样率不用重新导资源。 /// /// 包络两端都做了淡入淡出——直接给方波般的起止会有"咔"的爆音, /// 在这条链路上很容易被误当成杂音。 class CueTone { CueTone._(); /// 唤醒提示音:短促、偏高,一听就是"开始了" static Uint8List ding({int sampleRate = 16000}) => _tone( sampleRate: sampleRate, freqHz: 1180, durationMs: 150, // 叠一个高八度的泛音,听感更"清脆",不然纯正弦发闷 overtoneHz: 2360, overtoneGain: 0.28, ); /// 结束提示音:低一些、稍长,收尾感 static Uint8List dong({int sampleRate = 16000}) => _tone( sampleRate: sampleRate, freqHz: 520, durationMs: 230, overtoneHz: 1040, overtoneGain: 0.18, ); /// [amplitude] 取 0.35:提示音不该盖过对话本身,也别小到听不见 static Uint8List _tone({ required int sampleRate, required double freqHz, required int durationMs, double overtoneHz = 0, double overtoneGain = 0, double amplitude = 0.35, }) { final total = sampleRate * durationMs ~/ 1000; final bytes = Uint8List(total * 2); final view = ByteData.view(bytes.buffer); // 5ms 淡入避免起始爆音;衰减用指数,听起来像敲击而不是被硬切 final fadeIn = math.max(1, sampleRate * 5 ~/ 1000); for (var i = 0; i < total; i++) { final t = i / sampleRate; final progress = i / total; var sample = math.sin(2 * math.pi * freqHz * t); if (overtoneHz > 0) { sample += overtoneGain * math.sin(2 * math.pi * overtoneHz * t); sample /= (1 + overtoneGain); } var env = math.exp(-4.5 * progress); if (i < fadeIn) env *= i / fadeIn; final v = (sample * env * amplitude * 32767).round().clamp(-32768, 32767); view.setInt16(i * 2, v, Endian.little); } return bytes; } }