/** * 《暗码》运行时摩尔斯合成模块(19-cipher.md §2.1)。 * * 电文/候选/重听/慢放的滴答声全部经 WebAudio oscillator 现场排程——不走音频文件: * ①时值精确是玩法命根(H···· vs S··· 只差一个 80ms dit,有损编码的 ms 级抖动与 * 预卷直接否决文件方案);②三档速度 × 双频率 × 13 字母的组合用文件不可维护; * ③零资产缺失风险(guide earcon 运行时合成先例)。 * * 时值(u=单位 ms):dit=1u 音、dah=3u 音、字内隙 1u、字母隙 3u(慢放 6u、候选 5u); * 原速 u=80(≈15 WPM)、候选档 u=110、慢放档 u=160。**u 是常量,不随 settings.speed** * (边界登记:2.2× 语速档下电文仍原速,慢放兜底)。 * 音色:正弦 + 5ms cos 边沿,gain 0.5;电文 750Hz / 候选 620Hz(频率差=「电波里的」 * vs「密码本上的」,防听感混淆);lead-in 400ms、结尾 400ms 静音由排程自然包含。 * * 事件钩子 onElement({type:'dit'|'dah', letter, at}) 供 playtest 固定字符断言(§9)。 * morseGate(§3.4 R1)由游戏层负责:只在 !audio.isVoicePlaying() 时起振,在播则 * 挂队列、句间轮询补发(abyss sonarGate 同款)。 */ /** 标准摩尔斯表(本作电文只用 13 字母,模块内置全 A-Z 表)。 */ export const MORSE = { A: '·−', B: '−···', C: '−·−·', D: '−··', E: '·', F: '··−·', G: '−−·', H: '····', I: '··', J: '·−−−', K: '−·−', L: '·−··', M: '−−', N: '−·', O: '−−−', P: '·−−·', Q: '−−·−', R: '·−·', S: '···', T: '−', U: '··−', V: '···−', W: '·−−', X: '−··−', Y: '−·−−', Z: '−−··', }; export const MORSE_PRESET = { /** 电文原速/重听:750Hz,u=80ms,字母隙 3u。 */ tx: { hz: 750, unit: 80, letterGapUnits: 3 }, /** 慢放 0.5×:750Hz,u=160ms,字母隙 6u。 */ slow: { hz: 750, unit: 160, letterGapUnits: 6 }, /** 候选对照档:620Hz,u=110ms,字母隙 5u(密码本「念给你听」的质感)。 */ cand: { hz: 620, unit: 110, letterGapUnits: 5 }, }; const LEAD_MS = 400; // 起振前静音(排程器自然包含) const TAIL_MS = 400; // 收尾静音 const EDGE_S = 0.005; // 5ms cos 边沿 /** * 排程布局(play 与 morseDurationMs 共用,保证断言口径一致): * 返回元素起始时刻(相对词首音,已含 lead-in)与最后一个音的结束时刻。 * 口径:dit=1u / dah=3u 音,字内隙 1u(末元素无),字母隙 = letterGapUnits×u。 */ function layout(word, { unit, letterGapUnits }) { const starts = []; // {type, letter, atMs} let t = LEAD_MS; let toneEnd = t; const letters = word.toUpperCase().split(''); letters.forEach((ch, li) => { const code = MORSE[ch]; if (!code) return; let first = true; for (const sym of code) { if (!first) t += unit; // 字内隙 1u(前一元素之后) first = false; const dur = (sym === '−' ? 3 : 1) * unit; starts.push({ type: sym === '−' ? 'dah' : 'dit', letter: ch, atMs: t }); t += dur; toneEnd = t; } if (li < letters.length - 1) t += letterGapUnits * unit; // 字母隙 }); return { starts, endAtMs: toneEnd + TAIL_MS }; } /** 词的总时长(含首尾静音),毫秒。 */ export function morseDurationMs(word, preset) { return layout(word, preset).endAtMs; } export class MorsePlayer { /** @param audio WebAudioEngine(用其 ctx/_sfxBus 直挂 sfx 总线,guide earcon 先例)。 */ constructor(audio) { this.audio = audio; this._active = []; // 在途 oscillator 句柄(stop 可撤) } /** * 排程一个词。baseSec 缺省 = ctx.currentTime(虚拟时钟可复现)。 * @returns {{cancel(): void, endAt: number}} 句柄(endAt = 排程结束时刻 s)。 */ play(word, { hz = 750, unit = 80, letterGapUnits = 3, onElement = null, baseSec = null } = {}) { const ctx = this.audio && this.audio.ctx; this.cancel(); if (!ctx || !this.audio._sfxBus) return { cancel() {}, endAt: 0 }; const base = baseSec != null ? baseSec : ctx.currentTime; const { starts, endAtMs } = layout(word, { unit, letterGapUnits }); const handles = []; const gainPeak = 0.5; for (const el of starts) { const t = base + el.atMs / 1000; const dur = (el.type === 'dah' ? 3 : 1) * unit / 1000; const osc = ctx.createOscillator(); osc.type = 'sine'; osc.frequency.value = hz; const g = ctx.createGain(); // 5ms cos 边沿(MockParam 静默 no-op,真机防爆音) g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(gainPeak, t + EDGE_S); g.gain.setValueAtTime(gainPeak, t + dur - EDGE_S); g.gain.linearRampToValueAtTime(0, t + dur); osc.connect(g); g.connect(this.audio._sfxBus); osc.start(t); osc.stop(t + dur); handles.push({ osc, g }); if (onElement) onElement({ type: el.type, letter: el.letter, at: t }); } const endAt = base + endAtMs / 1000; const h = { endAt, cancel: () => { for (const x of handles) { try { x.osc.stop(); } catch (_) { /* 已停止 */ } try { x.osc.disconnect(); x.g.disconnect(); } catch (_) { /* 静默 */ } } }, }; this._active.push(h); return h; } /** 停掉一切在途排程(stop()/菜单/新链顶掉时,radio 实现修复 #6 同款)。 */ cancel() { for (const h of this._active) { try { h.cancel(); } catch (_) { /* 静默 */ } } this._active = []; } }