diff --git a/app.js b/app.js index b873cf7..58cf59b 100644 --- a/app.js +++ b/app.js @@ -37,7 +37,7 @@ const btnExpand = document.getElementById("btn-expand"); const btnPrev = document.getElementById("btn-prev"); const btnNext = document.getElementById("btn-next"); const btnLoop = document.getElementById("btn-loop"); -const btnBgm = document.getElementById("btn-bgm"); +const bgmSelect = document.getElementById("bgm-select"); const timerSelect = document.getElementById("timer-select"); const rateSelect = document.getElementById("rate-select"); const btnNight = document.getElementById("btn-night"); @@ -51,7 +51,7 @@ let playToken = 0; // 播放会话令牌,防止旧异步任务串扰 let playing = false; let inflight = null; // 进行中的 fetch AbortController let loopMode = false; // 循环诵读开关:播完全文回到开头继续 -let bgmOn = false; // 背景音乐开关 +let bgmTrack = ""; // 背景音乐曲目(""=关,对应 bgm/<曲目>.m4a) let sleepEndAt = 0; // 定时停止的截止时间戳(毫秒),0 = 未开启 let sleepTimer = null; // 定时器句柄(前台兜底;后台由 onTimeUpdate 判定截止时间) let loopAutoByTimer = false; // 循环是否为设定定时时自动打开的(是则定时结束一并关回) @@ -77,273 +77,45 @@ let currentSeg = -1; // 正在朗读的段号 let lastUserScroll = 0; // 用户最近一次手动滚动经文的时间戳(用于暂缓跟读强拽) let cursorRaf = null; // 逐字光标 rAF 句柄 -// ===== 背景音乐:Web Audio 实时合成颂钵泛音 drone(零资源文件、无缝循环) ===== -let bgmCtx = null; -let bgmGain = null; -let bgmNodes = []; -let bgmBellTimer = null; -let bgmDrumTimer = null; -let bgmChimeTimer = null; -let bgmGlideTimer = null; -let bgmOscs = []; // {osc, ratio},供基频滑变统一改频率 -const BGM_ROOTS = [55, 65.41, 73.42, 82.41, 98]; // A1 C2 D2 E2 G2,低音区垫底 -let bgmRoot = 73.42; +// ===== 背景音乐:bgm/ 目录寺院氛围音频文件循环播放,手动淡入淡出 ===== +const BGM_FILES = { bell: "bgm/bell.m4a", valley: "bgm/valley.m4a" }; // 可选曲目 +const bgmAudio = new Audio(); +bgmAudio.loop = true; +bgmAudio.volume = 0; +const BGM_VOL = 0.25; // 目标音量,压在人声之下 +let bgmFadeTimer = null; -/** 基频慢移:只滑向相邻根音、16 秒缓慢过渡,变化若有似无,避免警报式的滑音。 */ -function bgmGlide() { - const idx = BGM_ROOTS.indexOf(bgmRoot); - const neighbors = [idx - 1, idx + 1].filter((i) => i >= 0 && i < BGM_ROOTS.length); - bgmRoot = BGM_ROOTS[neighbors[Math.floor(Math.random() * neighbors.length)]]; - const t = bgmCtx.currentTime; - for (const { osc, ratio } of bgmOscs) { - osc.frequency.cancelScheduledValues(t); - osc.frequency.setValueAtTime(osc.frequency.value, t); - osc.frequency.exponentialRampToValueAtTime(bgmRoot * ratio, t + 16); - } - bgmGlideTimer = setTimeout(() => { if (bgmGain) bgmGlide(); }, 50000 + Math.random() * 40000); +/** 背景音量从当前值线性渐变到 target,ms 毫秒内完成;渐变到 0 时暂停播放。 */ +function bgmFadeTo(target, ms) { + if (bgmFadeTimer) clearInterval(bgmFadeTimer); + const from = bgmAudio.volume; + const start = performance.now(); + bgmFadeTimer = setInterval(() => { + const p = Math.min(1, (performance.now() - start) / ms); + bgmAudio.volume = Math.max(0, Math.min(1, from + (target - from) * p)); + if (p >= 1) { + clearInterval(bgmFadeTimer); + bgmFadeTimer = null; + if (target === 0) bgmAudio.pause(); + } + }, 50); } -/** 给打击声部接随机方位的输出链,返回挂到主增益下的 GainNode。 */ -function bgmStrikeOut(panRange) { - const out = bgmCtx.createGain(); - if (bgmCtx.createStereoPanner) { - const pan = bgmCtx.createStereoPanner(); - pan.pan.value = (Math.random() - 0.5) * 2 * panRange; - out.connect(pan); - pan.connect(bgmGain); - } else { - out.connect(bgmGain); - } - return out; -} - -/** 晨钟一声:大钟的非谐泛音簇,毫秒级起音,泛音越高衰减越快(长达十余秒)。 */ -function bgmBell() { - const now = bgmCtx.currentTime; - const root = [98, 110, 123.47][Math.floor(Math.random() * 3)]; // G2 / A2 / B2 附近的大钟 - const bellGain = bgmStrikeOut(0.8); - bellGain.gain.setValueAtTime(0, now); - bellGain.gain.linearRampToValueAtTime(0.5, now + 0.02); - // 大钟泛音列:哼音 0.5、基音 1、小三度 1.19、五度 1.5、名义音 2、上泛音 2.74 - // [频率比, 振幅, 衰减秒数] —— 低频泛音余韵更长 - [[0.5, 0.5, 15], [1, 1, 13], [1.19, 0.3, 9], [1.5, 0.24, 7], [2.0, 0.18, 5], [2.74, 0.1, 3.5]].forEach(([ratio, amp, dur]) => { - const o = bgmCtx.createOscillator(); - o.type = "sine"; - o.frequency.value = root * ratio; - o.detune.value = (Math.random() - 0.5) * 5; - const og = bgmCtx.createGain(); - og.gain.setValueAtTime(amp, now); - og.gain.exponentialRampToValueAtTime(0.0001, now + dur); - o.connect(og); - og.connect(bellGain); - o.start(now); - o.stop(now + dur + 0.5); - bgmNodes.push(o); - }); -} - -/** 磬声一声:高音引磬,快起音 + 约 6 秒衰减的小泛音簇,稀疏点缀。 */ -function bgmChime() { - const now = bgmCtx.currentTime; - const freq = [392, 523.25, 659.25, 783.99][Math.floor(Math.random() * 4)]; // 五声音阶附近 - const chimeGain = bgmStrikeOut(0.6); - chimeGain.gain.setValueAtTime(0, now); - chimeGain.gain.linearRampToValueAtTime(0.35, now + 0.03); - chimeGain.gain.exponentialRampToValueAtTime(0.0001, now + 6); - [[1, 1], [2.76, 0.28], [5.4, 0.08]].forEach(([ratio, amp]) => { - const o = bgmCtx.createOscillator(); - o.type = "sine"; - o.frequency.value = freq * ratio; - o.detune.value = (Math.random() - 0.5) * 6; - const og = bgmCtx.createGain(); - og.gain.value = amp; - o.connect(og); - og.connect(chimeGain); - o.start(now); - o.stop(now + 6.5); - bgmNodes.push(o); - }); -} - -/** 暮鼓一记:低频膜鸣(72→44Hz 下滑)+ 短促噪声击边,钝而沉。 */ -function bgmDrum() { - const now = bgmCtx.currentTime; - const drumGain = bgmStrikeOut(0.5); - drumGain.gain.setValueAtTime(0, now); - drumGain.gain.linearRampToValueAtTime(0.55, now + 0.012); - drumGain.gain.exponentialRampToValueAtTime(0.0001, now + 1.8); - - // 鼓皮基音:起振约 72Hz,迅速下滑到 44Hz - const o = bgmCtx.createOscillator(); - o.type = "sine"; - o.frequency.setValueAtTime(72, now); - o.frequency.exponentialRampToValueAtTime(44, now + 0.3); - o.connect(drumGain); - o.start(now); - o.stop(now + 2); - bgmNodes.push(o); - - // 击边噪声:0.08s 噪声过 260Hz 带通,模拟鼓槌触皮的一声"扑" - const len = Math.floor(bgmCtx.sampleRate * 0.08); - const buf = bgmCtx.createBuffer(1, len, bgmCtx.sampleRate); - const data = buf.getChannelData(0); - for (let i = 0; i < len; i += 1) data[i] = Math.random() * 2 - 1; - const noise = bgmCtx.createBufferSource(); - noise.buffer = buf; - const bp = bgmCtx.createBiquadFilter(); - bp.type = "bandpass"; - bp.frequency.value = 260; - bp.Q.value = 0.8; - const ng = bgmCtx.createGain(); - ng.gain.setValueAtTime(0.4, now); - ng.gain.exponentialRampToValueAtTime(0.0001, now + 0.15); - noise.connect(bp); - bp.connect(ng); - ng.connect(drumGain); - noise.start(now); - bgmNodes.push(noise); -} - -/** - * 开始播放背景音乐:低频颂钵式泛音 drone 垫底(基频缓慢游走), - * 叠稀疏的晨钟(大钟非谐泛音簇、长余韵)、暮鼓(低频膜鸣 + 击边噪声) - * 与引磬(高音泛音点缀),三种寺院打击声部随机方位错开出现, - * 方位随机、音量压在人声之下;2.5 秒淡入。需在用户手势内调用。 - */ +/** 开始播放背景音乐:按当前曲目加载音频,2.5 秒淡入;需在用户手势上下文内调用。 */ function startBgm() { - if (!bgmCtx) bgmCtx = new (window.AudioContext || window.webkitAudioContext)(); - if (bgmCtx.state === "suspended") bgmCtx.resume(); - const now = bgmCtx.currentTime; - const g = bgmCtx.createGain(); - g.gain.setValueAtTime(0, now); - // 低通只做轻微软化,保留高频空气感(太低会发闷) - const lp = bgmCtx.createBiquadFilter(); - lp.type = "lowpass"; - lp.frequency.value = 3200; - lp.Q.value = 0.4; - g.connect(lp); - lp.connect(bgmCtx.destination); - - const base = bgmRoot; // 初始根音,之后由 bgmGlide 慢移 - const partials = [[1, 0.45], [2.02, 0.2], [3.01, 0.13], [4.16, 0.08], [5.43, 0.05], [7.2, 0.025]]; - const nodes = []; - bgmOscs = []; - partials.forEach(([ratio, amp], pi) => { - const og = bgmCtx.createGain(); - og.gain.value = amp; - // 泛音左右宽展开 + 极慢声像漂移,空间缓慢呼吸 - if (bgmCtx.createStereoPanner) { - const pan = bgmCtx.createStereoPanner(); - const side = pi % 2 === 0 ? -1 : 1; - pan.pan.value = side * 0.6; - og.connect(pan); - pan.connect(g); - const plfo = bgmCtx.createOscillator(); - plfo.frequency.value = 0.02 + Math.random() * 0.03; - const plg = bgmCtx.createGain(); - plg.gain.value = 0.3; - plfo.connect(plg); - plg.connect(pan.pan); - plfo.start(now + pi * 0.5); - nodes.push(plfo); - } else { - og.connect(g); - } - // 双振荡器 ±3 音分失谐,产生颂钵的拍频颤动 - for (const det of [-3, 3]) { - const osc = bgmCtx.createOscillator(); - osc.type = "sine"; - osc.frequency.value = base * ratio; - osc.detune.value = det + (Math.random() - 0.5) * 2; - osc.connect(og); - osc.start(now + Math.random() * 0.3); - nodes.push(osc); - bgmOscs.push({ osc, ratio }); - } - // 每个泛音带独立慢速呼吸,避免死板 - const lfo = bgmCtx.createOscillator(); - lfo.frequency.value = 0.04 + Math.random() * 0.07; - const lg = bgmCtx.createGain(); - lg.gain.value = amp * 0.4; - lfo.connect(lg); - lg.connect(og.gain); - lfo.start(now); - nodes.push(lfo); - }); - g.gain.linearRampToValueAtTime(0.07, now + 2.5); // 淡入,drone 压低垫底 - bgmGain = g; - bgmNodes = nodes; - - // 晨钟:淡入后 4 秒先撞一声,之后每 30~55 秒一声 - const scheduleBell = () => { - bgmBellTimer = setTimeout(() => { - if (!bgmGain) return; - bgmBell(); - scheduleBell(); - }, 30000 + Math.random() * 25000); - }; - bgmBellTimer = setTimeout(() => { - if (!bgmGain) return; - bgmBell(); - scheduleBell(); - }, 4000); - - // 暮鼓:每 45~80 秒一记,偶尔连击两记(间隔约 2.4s) - const scheduleDrum = () => { - bgmDrumTimer = setTimeout(() => { - if (!bgmGain) return; - bgmDrum(); - if (Math.random() < 0.45) setTimeout(() => { if (bgmGain) bgmDrum(); }, 2400); - scheduleDrum(); - }, 45000 + Math.random() * 35000); - }; - scheduleDrum(); - - // 引磬:最稀疏,每 60~100 秒一声清越点缀 - const scheduleChime = () => { - bgmChimeTimer = setTimeout(() => { - if (!bgmGain) return; - bgmChime(); - scheduleChime(); - }, 60000 + Math.random() * 40000); - }; - scheduleChime(); - - // 首次基频滑变:约 50 秒后 - bgmGlideTimer = setTimeout(() => { if (bgmGain) bgmGlide(); }, 50000); + const src = BGM_FILES[bgmTrack]; + if (!src) return; + if (!bgmAudio.src.endsWith(src)) { + bgmAudio.src = src; + bgmAudio.currentTime = 0; + } + if (bgmAudio.paused) bgmAudio.play().catch(() => {}); + bgmFadeTo(BGM_VOL, 2500); } -/** 停止背景音乐:1.5 秒淡出后停掉振荡器、钟鼓调度器并断开节点。 */ +/** 停止背景音乐:1.5 秒淡出后暂停。 */ function stopBgm() { - if (bgmBellTimer) { - clearTimeout(bgmBellTimer); - bgmBellTimer = null; - } - if (bgmDrumTimer) { - clearTimeout(bgmDrumTimer); - bgmDrumTimer = null; - } - if (bgmChimeTimer) { - clearTimeout(bgmChimeTimer); - bgmChimeTimer = null; - } - if (bgmGlideTimer) { - clearTimeout(bgmGlideTimer); - bgmGlideTimer = null; - } - if (!bgmCtx || !bgmGain) return; - const g = bgmGain; - const nodes = bgmNodes; - const now = bgmCtx.currentTime; - g.gain.cancelScheduledValues(now); - g.gain.setValueAtTime(g.gain.value, now); - g.gain.linearRampToValueAtTime(0, now + 1.5); - bgmGain = null; - bgmNodes = []; - setTimeout(() => { - nodes.forEach((n) => { try { n.stop(); } catch { /* 已停止 */ } }); - g.disconnect(); - }, 1700); + bgmFadeTo(0, 1500); } let sutra = null; // 当前经 @@ -851,7 +623,7 @@ async function playFrom(index) { await audioEl.play(); btnPlay.classList.remove("preparing"); btnPlay.querySelector(".play-label").textContent = "停 止"; - if (bgmOn) startBgm(); // play() 成功后起步背景音乐(用户手势上下文内) + if (bgmTrack) startBgm(); // play() 成功后起步背景音乐(用户手势上下文内) updateMediaSession(); cursorRaf = requestAnimationFrame(cursorTick); // 启动逐字光标 setStatus(`正在朗读 · ${currentVoice()}`); @@ -923,14 +695,14 @@ timerSelect.addEventListener("change", () => { }); /** 背景音乐开关:播放中开/关即时淡入淡出;未播放时只记录状态,下次播放生效。 */ -btnBgm.addEventListener("click", () => { - bgmOn = !bgmOn; - btnBgm.classList.toggle("active", bgmOn); - btnBgm.setAttribute("aria-pressed", String(bgmOn)); - savePref("bgm", bgmOn); - if (bgmOn && playing) startBgm(); - if (!bgmOn) stopBgm(); - setStatus(bgmOn ? "背景音乐:开" : "背景音乐:关"); +bgmSelect.addEventListener("change", () => { + bgmTrack = bgmSelect.value; + savePref("bgm", bgmTrack); + if (playing) { + if (bgmTrack) startBgm(); + else stopBgm(); + } + setStatus(bgmTrack ? `背景音乐:${bgmSelect.options[bgmSelect.selectedIndex].text}` : "背景音乐:关"); }); /** 语速切换:直接改已合并音频的 playbackRate,立即生效。 */ @@ -1116,6 +888,7 @@ attachSheet(sutraSelect, "选择经典"); attachSheet(voiceSelect, "诵读音色"); attachSheet(timerSelect, "定时停止"); attachSheet(rateSelect, "诵读语速"); +attachSheet(bgmSelect, "背景音乐"); // 锁屏/控制中心按键:上一段/下一段、暂停、停止 if ("mediaSession" in navigator) { @@ -1136,10 +909,14 @@ if (prefs.voice && [...voiceSelect.options].some((o) => o.value === prefs.voice) sheetSyncFns.get(voiceSelect)?.(); if (prefs.size) setFontSize(prefs.size); if (prefs.loop) setLoopMode(true); -if (prefs.bgm) { - bgmOn = true; - btnBgm.classList.add("active"); - btnBgm.setAttribute("aria-pressed", "true"); +// 旧版偏好是布尔值 true(开),迁移为默认曲目"晨钟" +{ + const track = prefs.bgm === true ? "bell" : String(prefs.bgm || ""); + if (track && [...bgmSelect.options].some((o) => o.value === track)) { + bgmSelect.value = track; + bgmTrack = track; + sheetSyncFns.get(bgmSelect)?.(); + } } if (prefs.rate && [...rateSelect.options].some((o) => o.value === String(prefs.rate))) { rateSelect.value = prefs.rate; diff --git a/bgm/bell.m4a b/bgm/bell.m4a new file mode 100644 index 0000000..0782881 Binary files /dev/null and b/bgm/bell.m4a differ diff --git a/bgm/valley.m4a b/bgm/valley.m4a new file mode 100644 index 0000000..e1bb145 Binary files /dev/null and b/bgm/valley.m4a differ diff --git a/deploy.sh b/deploy.sh index 200c878..6af91c9 100755 --- a/deploy.sh +++ b/deploy.sh @@ -27,6 +27,7 @@ echo "==> 同步文件到 $USER@$HOST:$DEST/" rsync -az --timeout=30 -e "$SSH_CMD" "${FILES[@]}" "$USER@$HOST:$DEST/" # 经文数据目录整目录同步(删除远端多余文件,加新经只需往 sutras/ 丢 json) rsync -az --timeout=30 --delete -e "$SSH_CMD" sutras/ "$USER@$HOST:$DEST/sutras/" +rsync -az --timeout=30 --delete -e "$SSH_CMD" bgm/ "$USER@$HOST:$DEST/bgm/" echo "==> 重启 fj.service" $SSH_CMD "$USER@$HOST" 'systemctl restart fj.service && sleep 1 && systemctl is-active fj.service' diff --git a/index.html b/index.html index 7f56fc0..6d3dfde 100644 --- a/index.html +++ b/index.html @@ -13,7 +13,7 @@ - + @@ -61,8 +61,15 @@
- +
+ + +