Files

79 lines
7.6 KiB
HTML
Raw Permalink 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.
<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>声音验证 · Rhine Lab</title>
<style>body{max-width:980px;margin:40px auto;padding:0 24px;background:#eee9df;color:#232721;font:16px system-ui;line-height:1.6}button{padding:12px 24px;margin:8px 12px 8px 0}pre{white-space:pre-wrap;font-size:13px}audio{width:100%}video{width:100%;max-height:380px}a{color:inherit}</style>
<h1>声音验证与试听</h1><p>原创配乐「观测室」· 72 BPM · 53.33 秒循环。下方为固定混音;主程序随浏览、阅读和模型查看调整三条声部。</p>
<p><a href="boot-audio.html">播放完整开场(含原片输入音效) →</a></p>
<p>卡片动作使用玻璃轻碰;窗口、标志、权限和整块文字使用系统电子反馈;逐字输入使用原片短音。可先比较「打开窗口」「启动标志」「文字显现」,再听卡片动作。</p>
<section aria-label="逐字输入对照"><h2>逐字输入 · 原片复刻</h2><p>原片输入片段(6.84–7.39 秒,已匹配试听音量)</p><audio controls aria-label="原片逐字输入片段" src="/reference/typing-original.wav"></audio><p>复刻版(原片三个短音轮替,两个输入串与中间停顿)</p><audio controls aria-label="复刻逐字输入" src="/audio/typing-preview.wav"></audio></section>
<h2>背景音乐与单项音效</h2>
<audio controls src="/audio/observatory-preview.mp3"></audio>
<p id="audition" aria-label="音效试听"></p>
<button id="test">运行音频检查</button><button id="stop">停止测试声音</button><a href="/?scene=archive">进入终端</a>
<pre id="result">等待检查。点击后会解码音乐并在离线音频上下文中检查信号。</pre>
<details><summary>原片对照(6.76–40 秒)</summary><video controls preload="metadata" src="/《明日方舟》特别映像 [莱茵生命:访问] [BV1rr4y1b7sz].mp4#t=6.76,40"></video></details>
<script type="module">
import {TerminalAudio,synthesizeSound,BOOT_CUES} from '/src/audio.ts';
import {TYPING_FRAMES,hasTypingBetween} from '/src/typing-rhythm.ts';
let terminal=new TerminalAudio();
const output=document.querySelector('#result');let running=false;
const sounds={'page-open':'打开窗口','page-close':'关闭窗口','ui-tick':'系统操作',brand:'启动标志','text-reveal':'文字显现',confirm:'权限确认',scan:'权限扫描',welcome:'欢迎页',inspect:'读取标记',key:'逐字输入',tick:'切换档案',column:'切换列',open:'抽取档案',back:'归位返回',array:'阵列进入',explode:'拆解',assemble:'重组'};
document.querySelector('#audition').innerHTML=Object.entries(sounds).map(([key,name])=>`<button data-cue="${key}">${name}</button>`).join('');
document.querySelector('#audition').onclick=async e=>{const cue=e.target.dataset.cue;if(!cue||running)return;terminal.configure({sound:true,music:false,soundVolume:.55,musicVolume:0});await delay(40);terminal.play(cue);};
const delay=ms=>new Promise(r=>setTimeout(r,ms));
const line=s=>output.textContent+='\n'+s;
const check=(ok,text)=>{if(!ok)throw Error(text);line('PASS '+text);};
document.querySelector('#stop').onclick=()=>{terminal.configure({sound:false,music:false,soundVolume:.55,musicVolume:.5});};
document.querySelector('#test').onclick=async()=>{
if(running)return;running=true;output.textContent='正在验证…';
try{
terminal.configure({sound:true,music:true,soundVolume:0,musicVolume:0});
for(let i=0;i<200&&!terminal.stats().loaded&&!terminal.stats().error;i++)await delay(50);
check(terminal.stats().loaded, '音乐加载成功 '+terminal.stats().error);
check(terminal.stats().tracks===3,'三条声部同时播放');
for(const sound of Object.keys(sounds)){
const c=new OfflineAudioContext(2,48000*3,48000);synthesizeSound(c,c.destination,sound,.02,.4);
const b=await c.startRendering();let peak=0,sum=0,last=0,delta=0;
for(const x of b.getChannelData(0)){checkFinite(x);peak=Math.max(peak,Math.abs(x));sum+=x*x;delta=Math.max(delta,Math.abs(x-last));last=x;}
check(peak>.001&&peak<.8&&sum>0&&Math.abs(last)<.00001&&delta<.2,`${sound} 有效信号、无削波、尾端归零(峰值 ${peak.toFixed(3)})`);
}
const rapidContext=new OfflineAudioContext(2,48000*4,48000);
for(let i=0;i<40;i++)synthesizeSound(rapidContext,rapidContext.destination,i%2?'tick':'column',.02+i*.055,i%2?.4:-.4);
const rapidBuffer=await rapidContext.startRendering();let rapidPeak=0;
for(let channel=0;channel<2;channel++)for(const x of rapidBuffer.getChannelData(channel)){checkFinite(x);rapidPeak=Math.max(rapidPeak,Math.abs(x));}
check(rapidPeak<.3,`40 次连续玻璃碰击保留输出余量(峰值 ${rapidPeak.toFixed(3)})`);
const typingContext=new OfflineAudioContext(2,48000*2,48000);
for(let i=0;i<25;i++)synthesizeSound(typingContext,typingContext.destination,'key',.02+i*.04);
const typingBuffer=await typingContext.startRendering();let typingPeak=0;
for(const x of typingBuffer.getChannelData(0)){checkFinite(x);typingPeak=Math.max(typingPeak,Math.abs(x));}
check(typingPeak>.01&&typingPeak<.15,`25Hz 连续输入无削波(峰值 ${typingPeak.toFixed(3)})`);
for(const start of [6.8,11.28,12.8,14.68,16.92,17.96])check(hasTypingBetween(start-.01,start),`字段首字 ${start}s 有输入反馈`);
check(!hasTypingBetween(18.9,19.48),'闪动后文字恢复不产生输入声');
for(const name of ['atmosphere','motif','pulse']){
const c=new OfflineAudioContext(2,48000,48000),b=await c.decodeAudioData(await(await fetch(`/audio/${name}.ogg`)).arrayBuffer());
check(Math.abs(b.duration-160/3)<.002,`${name} 循环时长准确`);
for(let channel=0;channel<2;channel++){
const x=b.getChannelData(channel);let peak=0;for(const sample of x){checkFinite(sample);peak=Math.max(peak,Math.abs(sample));}
check(peak<.9&&Math.abs(x[0]-x[x.length-1])<.01,`${name} 声道 ${channel+1} 无削波、接缝 < 0.01`);
}
}
const cueSounds=[];const tracker=new TerminalAudio();tracker.play=s=>cueSounds.push(s);
for(let t=1.76;t<35;t+=.02)tracker.updateBoot(t);
check(BOOT_CUES.every(c=>cueSounds.includes(c.sound)),'启动关键事件全部触发');
check(cueSounds.filter(s=>s==='key').length===TYPING_FRAMES.length,'逐字显示帧无漏触发或重复触发');
const count=cueSounds.length;tracker.updateBoot(1.76);tracker.updateBoot(29,true);tracker.updateBoot(10,true);
check(cueSounds.length===count,'倒退、跳转、冻结不补播历史声音');tracker.dispose();
for(let i=0;i<100;i++)terminal.play(i%2?'tick':'column');
check(terminal.stats().voices<=10,'连续输入的音效数量受限');
terminal.setScene('detail');check(terminal.stats().tracks===3&&terminal.stats().scene==='detail','详情切换保留音乐轨道');
terminal.configure({sound:true,music:false,soundVolume:0,musicVolume:0});check(terminal.stats().tracks===0,'关闭音乐保留独立音效');
for(let i=0;i<8;i++) {terminal.configure({sound:false,music:true,soundVolume:0,musicVolume:0});terminal.configure({sound:false,music:false,soundVolume:0,musicVolume:0});}
await delay(150);check(terminal.stats().tracks===0,'快速开关不产生残留音乐');
terminal.configure({sound:true,music:true,soundVolume:0,musicVolume:0});await delay(200);
check(terminal.stats().tracks===3,'重新开启恰好恢复三轨');
terminal.configure({sound:false,music:false,soundVolume:0,musicVolume:0});await delay(100);
check(terminal.stats().state==='suspended'&&terminal.stats().voices===0,'全静音暂停上下文并清空音效');
line('全部检查通过。听感仍请用耳机和扬声器试听确认。');
}catch(e){line('FAIL '+e.message);}finally{running=false;terminal.configure({sound:false,music:false,soundVolume:0,musicVolume:0});}
};
function checkFinite(x){if(!Number.isFinite(x))throw Error('非有限音频样本');}
</script></html>