@@ -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 ( ) ;