diff --git a/app.js b/app.js index 30af11b..d7414f4 100644 --- a/app.js +++ b/app.js @@ -103,6 +103,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 pageInfo = document.getElementById("page-info"); /** 音频缓存:key = `${经id}|${音色}|${段序号}`,value = Blob */ @@ -111,9 +112,279 @@ let playToken = 0; // 播放会话令牌,防止旧异步任务串扰 let playing = false; let inflight = null; // 进行中的 fetch AbortController let loopMode = false; // 循环诵读开关:播完全文回到开头继续 +let bgmOn = false; // 背景音乐开关 let segTimes = []; // 合并音频中各段的起始秒 let currentSeg = -1; // 正在朗读的段号 +// ===== 背景音乐: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; + +/** 基频慢移:只滑向相邻根音、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); +} + +/** 给打击声部接随机方位的输出链,返回挂到主增益下的 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 秒淡入。需在用户手势内调用。 + */ +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); +} + +/** 停止背景音乐: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); +} + let sutra = null; // 当前经 let segments = []; // 当前经分段 let currentPage = 0; @@ -286,6 +557,7 @@ function stopPlayback() { audioEl.pause(); audioEl.ontimeupdate = null; audioEl.onended = null; + stopBgm(); clearHighlight(); currentSeg = -1; } @@ -440,10 +712,12 @@ async function playFrom(index) { audioEl.onended = () => { setPlayingUI(false); setStatus("诵读完毕"); + stopBgm(); clearHighlight(); }; audioEl.currentTime = built.times[index]; await audioEl.play(); + if (bgmOn) startBgm(); // play() 成功后起步背景音乐(用户手势上下文内) setStatus(`正在朗读 · ${currentVoice()}`); } catch (err) { if (err.name === "AbortError") return; // 已被停止/新播放取代 @@ -462,6 +736,16 @@ btnLoop.addEventListener("click", () => { setStatus(loopMode ? "循环诵读:开" : "循环诵读:关"); }); +/** 背景音乐开关:播放中开/关即时淡入淡出;未播放时只记录状态,下次播放生效。 */ +btnBgm.addEventListener("click", () => { + bgmOn = !bgmOn; + btnBgm.classList.toggle("active", bgmOn); + btnBgm.setAttribute("aria-pressed", String(bgmOn)); + if (bgmOn && playing) startBgm(); + if (!bgmOn) stopBgm(); + setStatus(bgmOn ? "背景音乐:开" : "背景音乐:关"); +}); + btnPlay.addEventListener("click", () => { if (playing || inflight) { stopPlayback(); diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..286c690 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# 部署佛经诵读站到 fj.all8ai.top(154.8.162.18:/opt/fj/) +# +# 用法: +# ./deploy.sh # 已配置 SSH key 时直接用 +# FJ_PASS=xxx ./deploy.sh # 密码登录(需本机装 sshpass) +# +# 注意:不要在脚本里写死密码;密码走 FJ_PASS 环境变量。 + +set -euo pipefail +cd "$(dirname "$0")" + +HOST=154.8.162.18 +USER=root +DEST=/opt/fj +# 注意:不同步 .env —— 服务器端 PORT=8018 与本地开发配置不同,避免覆盖。 +FILES=(index.html style.css app.js server.py icon.svg icon-180.png README.md run.md) + +SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=15" +SSH_CMD="ssh $SSH_OPTS" +if [ -n "${FJ_PASS:-}" ]; then + command -v sshpass >/dev/null || { echo "缺少 sshpass:brew install sshpass 或改用 SSH key"; exit 1; } + SSH_CMD="sshpass -p $FJ_PASS ssh $SSH_OPTS" +fi + +echo "==> 同步文件到 $USER@$HOST:$DEST/" +rsync -az --timeout=30 -e "$SSH_CMD" "${FILES[@]}" "$USER@$HOST:$DEST/" + +echo "==> 重启 fj.service" +$SSH_CMD "$USER@$HOST" 'systemctl restart fj.service && sleep 1 && systemctl is-active fj.service' + +echo "==> 健康检查" +code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 15 https://fj.all8ai.top/) +echo "https://fj.all8ai.top/ -> $code" +[ "$code" = "200" ] || { echo "健康检查未通过"; exit 1; } +echo "部署完成" diff --git a/index.html b/index.html index a4a062c..0595472 100644 --- a/index.html +++ b/index.html @@ -53,6 +53,7 @@
+