You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

70 lines
2.4 KiB

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;
}
}