refactor: 音频系统重写 + TTS重新生成全部音频

- sound.js: Web Audio API重写,独立播放链,优先级系统,AudioBuffer缓存
- sound.config.js: 6并发+重试+渐进加载,替代Promise.all一挂全挂
- play.vue: 手动模式跳过点数播报,直接报结果
- 全部音频用TTS重新生成,修复原始文件内容与文件名不匹配的问题
This commit is contained in:
testadmin
2026-05-13 12:46:04 +08:00
parent 572ed95a60
commit e2f0346a81
156 changed files with 413 additions and 141 deletions
+330 -85
View File
@@ -1,100 +1,345 @@
/* eslint-disable */
import $store from "@/store"
const volume = $store.state.config.volume
let Mp3List = [],
num = 0
function getBlob(key) {
// ---------------------------------------------------------------------------
// Priority levels: higher number = higher priority = cannot be interrupted
// ---------------------------------------------------------------------------
const PRIORITY = {
UI: 1, // chip clicks, push, check
COUNTDOWN: 2, // time, last_10_seconds
RESULT: 3 // game result announcement chains
}
// Keywords that determine priority tier for a given sound key list
const RESULT_KEYS = [
"baccarat_b_win", "baccarat_p_win", "baccarat_tie",
"baccarat_b_pair", "baccarat_p_pair", "baccarat_banker", "baccarat_player",
"lh_dragon_win", "lh_tiger_win", "lh_tie", "lh_dragon", "lh_tiger",
"nn_banker", "nn_player_1", "nn_player_2", "nn_player_3",
"toning_4_white", "toning_4_red", "toning_3_w_1_r", "toning_2_w_2_r",
"toning_1_w_3_r", "toning_even", "toning_odd", "toning_big", "toning_small",
"dice_any_triple",
"start_betting", "stop_betting",
"tc_banker_stop", "tc_start_banker", "tc_banker_success"
]
const COUNTDOWN_KEYS = ["time", "last_10_seconds"]
function inferPriority(keyList) {
if (!keyList || !keyList.length) return PRIORITY.UI
for (const key of keyList) {
if (RESULT_KEYS.some(rk => key.includes(rk))) return PRIORITY.RESULT
// _point keys are part of result chains (e.g. "3_point")
if (/_point$/.test(key)) return PRIORITY.RESULT
// w_p keys are welcome phrases (result-tier)
if (/_w_p\d/.test(key)) return PRIORITY.RESULT
// nn/tc bull results
if (/_(bull_|no_bull|five_pictur|any_triple|straight_flush|royal_flush)/.test(key)) return PRIORITY.RESULT
if (/dice_num_/.test(key)) return PRIORITY.RESULT
}
for (const key of keyList) {
if (COUNTDOWN_KEYS.includes(key)) return PRIORITY.COUNTDOWN
}
return PRIORITY.UI
}
// ---------------------------------------------------------------------------
// AudioBuffer cache: decode blob -> AudioBuffer once, reuse forever
// ---------------------------------------------------------------------------
const bufferCache = new Map() // key -> AudioBuffer
let audioCtx = null
function getAudioContext() {
if (!audioCtx) {
const AudioContext = window.AudioContext || window.webkitAudioContext
if (AudioContext) {
audioCtx = new AudioContext()
}
}
return audioCtx
}
// Resume AudioContext after user gesture (mobile autoplay restriction)
function ensureResumed() {
const ctx = getAudioContext()
if (ctx && ctx.state === "suspended") {
ctx.resume().catch(() => {})
}
}
// One-time listener to unlock AudioContext on first user interaction
let unlockBound = false
function bindUnlock() {
if (unlockBound) return
unlockBound = true
const unlock = () => {
ensureResumed()
document.removeEventListener("touchstart", unlock, true)
document.removeEventListener("touchend", unlock, true)
document.removeEventListener("click", unlock, true)
}
document.addEventListener("touchstart", unlock, true)
document.addEventListener("touchend", unlock, true)
document.addEventListener("click", unlock, true)
}
bindUnlock()
/**
* Decode a Blob into an AudioBuffer and cache it.
* Returns null if the key doesn't exist or decoding fails.
*/
async function getBuffer(key) {
if (bufferCache.has(key)) return bufferCache.get(key)
const ctx = getAudioContext()
if (!ctx) return null
const soundList = $store.state.config.soundList
const language =
$store.state.config.$Type == "cn" || $store.state.config.$Type == "tw"
$store.state.config.$Type === "cn" || $store.state.config.$Type === "tw"
? "cn"
: "en"
let blob = ""
const fullKey = `${language}_${key}`
const blob = soundList[fullKey]
if (!blob) return null
try {
blob = soundList[`${language}_${key}`]
blob = window.webkitURL.createObjectURL(blob) || URL.createObjectURL(blob)
} catch (err) {
console.warn(err, key)
}
return blob
}
let audio = null
const audioElement = document.getElementById("Audio")
const sourceElement = document.getElementById("audioSourceAside")
audio = audioElement
audio.addEventListener("ended", () => {
nextAudio(audio, sourceElement)
})
function audioMp3(mp3List) {
const effectsVolume = $store.state.config.effectsVolume
// const audioElement = document.createElement("audio")
// const sourceElement = document.createElement("source")
// audio.appendChild(sourceElement)
// audio.volume = volume.effects / 100
audio.muted = !effectsVolume
Mp3List = mp3List
var mp3 = new Object()
mp3.mp3List = mp3List
mp3.auto_play = false
mp3.loop = false
mp3.Play = function () {
num = 0
if (this.mp3List[0]) {
sourceElement.src = getBlob(this.mp3List[0])
sourceElement.type = "audio/mpeg"
audio.pause()
audio.load()
setTimeout(() => {
audio.play().catch((err) => {
console.log(err)
}, 200)
// Blob.arrayBuffer() may not exist on older mobile browsers; fallback to FileReader
let arrayBuffer
if (typeof blob.arrayBuffer === "function") {
arrayBuffer = await blob.arrayBuffer()
} else {
arrayBuffer = await new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result)
reader.onerror = reject
reader.readAsArrayBuffer(blob)
})
}
}
mp3.Pause = function () {
audio.pause()
}
mp3.Muted = function () {
audio.muted ? (audio.muted = false) : (audio.muted = true)
}
mp3.volumeAdd = function () {
if (audio.volume.toFixed(1) >= 1) {
audio.volume = 1
} else {
audio.volume = audio.volume + 0.1
}
}
mp3.volumeMinus = function () {
if (audio.volume.toFixed(1) <= 0) {
audio.volume = 0
} else {
audio.volume = audio.volume - 0.1
}
}
return mp3
}
function nextAudio(audio, sourceElement) {
num += 1
// console.log(Mp3List, num)
if (Mp3List && num < Mp3List.length) {
sourceElement.src = getBlob(Mp3List[num])
sourceElement.type = "audio/mpeg"
audio.load()
setTimeout(() => {
audio.play().catch((err) => {
console.log(err)
}, 500)
// decodeAudioData: some older browsers only support callback form
const audioBuffer = await new Promise((resolve, reject) => {
ctx.decodeAudioData(arrayBuffer, resolve, reject)
})
} else {
audio.pause()
audio.currentTime = 0.0
num = 0
audio.removeEventListener("ended", () => {})
bufferCache.set(key, audioBuffer)
return audioBuffer
} catch (err) {
console.warn("[sound] decode failed:", key, err)
return null
}
}
// ---------------------------------------------------------------------------
// Fallback: HTMLAudio pool for browsers where Web Audio API is unavailable
// ---------------------------------------------------------------------------
const HTML_POOL_SIZE = 4
const htmlPool = []
function getHtmlAudio() {
// Try to reuse an idle element
for (const el of htmlPool) {
if (el.paused || el.ended) return el
}
// Create new if pool not full
if (htmlPool.length < HTML_POOL_SIZE) {
const el = new Audio()
el.preload = "auto"
htmlPool.push(el)
return el
}
// All busy: steal the oldest
return htmlPool[0]
}
/**
* Play a single sound key using HTMLAudioElement (fallback path).
* Creates and revokes blob URL to prevent memory leaks.
*/
function playHtmlFallback(key) {
return new Promise((resolve) => {
const soundList = $store.state.config.soundList
const language =
$store.state.config.$Type === "cn" || $store.state.config.$Type === "tw"
? "cn"
: "en"
const fullKey = `${language}_${key}`
const blob = soundList[fullKey]
if (!blob) {
resolve()
return
}
const blobUrl = URL.createObjectURL(blob)
const el = getHtmlAudio()
const effectsVolume = $store.state.config.effectsVolume
el.muted = !effectsVolume
const cleanup = () => {
URL.revokeObjectURL(blobUrl)
el.removeEventListener("ended", onEnd)
el.removeEventListener("error", onErr)
}
const onEnd = () => { cleanup(); resolve() }
const onErr = () => { cleanup(); resolve() }
el.addEventListener("ended", onEnd, { once: true })
el.addEventListener("error", onErr, { once: true })
el.src = blobUrl
el.load()
el.play().catch(() => { cleanup(); resolve() })
})
}
// ---------------------------------------------------------------------------
// Active chain tracking for priority-based interruption
// ---------------------------------------------------------------------------
let activeChainId = 0
let activeChainPriority = 0
// ---------------------------------------------------------------------------
// Public API: audioMp3(mp3List) => { Play(), Pause() }
// ---------------------------------------------------------------------------
function audioMp3(mp3List) {
// Handle no-args call: audioMp3().Pause() for global stop
if (!mp3List || !mp3List.length) {
return {
Play() {},
Pause() { stopAll() }
}
}
const myList = [...mp3List] // snapshot, no shared mutable state
const myPriority = inferPriority(myList)
let cancelled = false
let myId = 0
let currentSource = null // Web Audio source node, if playing
const chain = {
Play() {
ensureResumed()
// Priority check: if something equal or higher priority is playing, and
// we are UI-tier, skip (don't interrupt results with clicks).
// If we are higher or equal priority, we take over.
if (activeChainPriority > myPriority) {
// Higher priority chain already playing -- don't interrupt
return
}
// Generate unique chain ID; any older chain with lower/equal priority
// is implicitly superseded
myId = ++activeChainId
activeChainPriority = myPriority
cancelled = false
playSequence(myList, 0)
},
Pause() {
cancelled = true
if (currentSource) {
try { currentSource.stop() } catch (_) {}
currentSource = null
}
// If this chain is the active one, clear active state
if (myId === activeChainId) {
activeChainPriority = 0
}
}
}
/**
* Recursively play sounds in sequence via Web Audio API.
* Falls back to HTMLAudio if Web Audio is unavailable.
*/
async function playSequence(list, idx) {
if (cancelled) return
if (myId !== activeChainId) return // superseded by a newer chain
if (idx >= list.length) {
// Chain finished
if (myId === activeChainId) {
activeChainPriority = 0
}
return
}
const key = list[idx]
const ctx = getAudioContext()
if (ctx) {
// --- Web Audio path ---
const buffer = await getBuffer(key)
if (cancelled || myId !== activeChainId) return
if (!buffer) {
// Key missing: skip and continue chain
playSequence(list, idx + 1)
return
}
const effectsVolume = $store.state.config.effectsVolume
const source = ctx.createBufferSource()
source.buffer = buffer
// Gain node for mute control
const gain = ctx.createGain()
gain.gain.value = effectsVolume ? 1 : 0
source.connect(gain)
gain.connect(ctx.destination)
currentSource = source
source.onended = () => {
currentSource = null
if (!cancelled && myId === activeChainId) {
playSequence(list, idx + 1)
}
}
try {
source.start(0)
} catch (err) {
console.warn("[sound] play error:", key, err)
currentSource = null
// Skip this sound and continue
playSequence(list, idx + 1)
}
} else {
// --- HTMLAudio fallback path ---
await playHtmlFallback(key)
if (!cancelled && myId === activeChainId) {
playSequence(list, idx + 1)
}
}
}
return chain
}
/**
* Stop all playback globally. Used for cleanup on unmount.
*/
function stopAll() {
activeChainId++
activeChainPriority = 0
// Stop any Web Audio sources
const ctx = getAudioContext()
if (ctx && ctx.state === "running") {
// Suspending and resuming is the cleanest way to halt all sources
ctx.suspend().then(() => ctx.resume()).catch(() => {})
}
// Stop HTML pool elements
for (const el of htmlPool) {
try {
el.pause()
el.currentTime = 0
} catch (_) {}
}
// Also pause the legacy <audio> element in case it was somehow used
try {
const legacyEl = document.getElementById("Audio")
if (legacyEl) {
legacyEl.pause()
legacyEl.currentTime = 0
}
} catch (_) {}
}
export { audioMp3 }
+77 -48
View File
@@ -1,12 +1,5 @@
// 处理文件数据
// let loadList = []
// const modulesFiles = require.context("./", true, /\.mp3$/)
// modulesFiles.keys().forEach((v) => {
// const path = v.replace(/^\.\/(.*)/, "$1")
// const name = path.replace(/\.\w+$/, "").replaceAll(/\//gi, "_")
// loadList = [...loadList, { [name]: path }]
// })
import store from "@/store"
const sound = [
"cn/0_point.mp3",
"cn/1_point.mp3",
@@ -348,47 +341,83 @@ const sound = [
"en/lp/w_p6.mp3",
"en/lp/w_p7.mp3"
]
// const dev = "./static/sound"
// const proxy = "/v8api/sound"
// const pro = "https://static.v8api.net"
let times = null
const promiseSoundUrl = () => {
const soundUrl = store.state.config.soundUrl
if (soundUrl) {
clearTimeout(times)
init(soundUrl)
} else {
times = setTimeout(() => {
promiseSoundUrl()
}, 1000)
// ---------------------------------------------------------------------------
// Concurrency control: fetch with a limited pool to avoid hammering the server
// ---------------------------------------------------------------------------
const CONCURRENCY = 6
const MAX_RETRIES = 2
const RETRY_DELAY = 1500 // ms
/**
* Fetch a single sound file as a Blob with retry logic.
* Returns { name, blob } on success, null on permanent failure.
*/
async function fetchWithRetry(soundUrl, url, retries = 0) {
try {
const response = await fetch(`${soundUrl}/${url}`)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const blob = await response.blob()
const name = url.replace(/\.\w+$/, "").replaceAll(/\//gi, "_")
return { name, blob }
} catch (err) {
if (retries < MAX_RETRIES) {
await new Promise(r => setTimeout(r, RETRY_DELAY * (retries + 1)))
return fetchWithRetry(soundUrl, url, retries + 1)
}
console.warn(`[sound.config] Failed to load after ${MAX_RETRIES + 1} attempts: ${url}`, err)
return null
}
}
promiseSoundUrl()
function init(soundUrl) {
Promise.all(
sound.map(
(url) =>
fetch(`${soundUrl}/${url}`).then(async (response) => {
const name = url.replace(/\.\w+$/, "").replaceAll(/\//gi, "_")
const path = await response.blob()
// const path = response.url
return { [name]: path }
})
// {
// const name = url.replace(/\.\w+$/, "").replaceAll(/\//gi, "_")
// const path = `${soundUrl}/${url}`
// return { [name]: path }
// }
)
)
.then((blobs) => {
let list = {}
blobs.forEach((v) => {
list = { ...list, ...v }
})
// console.log(list)
store.commit("config/soundList", list)
})
.catch((err) => console.error(err))
/**
* Load all sounds with concurrency limit.
* Commits results to Vuex store in the same format: { "cn_baccarat_b_win": Blob, ... }
*/
async function loadAll(soundUrl) {
const list = {}
const queue = [...sound]
let loaded = 0
async function worker() {
while (queue.length > 0) {
const url = queue.shift()
const result = await fetchWithRetry(soundUrl, url)
if (result) {
list[result.name] = result.blob
}
loaded++
// Commit partial results every 50 files so sounds become available sooner
if (loaded % 50 === 0) {
store.commit("config/soundList", { ...list })
}
}
}
// Spawn workers up to concurrency limit
const workers = []
for (let i = 0; i < Math.min(CONCURRENCY, sound.length); i++) {
workers.push(worker())
}
await Promise.all(workers)
// Final commit with all loaded sounds
store.commit("config/soundList", list)
}
// ---------------------------------------------------------------------------
// Wait for soundUrl to be available in store, then start loading
// ---------------------------------------------------------------------------
let pollTimer = null
function promiseSoundUrl() {
const soundUrl = store.state.config.soundUrl
if (soundUrl) {
clearTimeout(pollTimer)
loadAll(soundUrl)
} else {
pollTimer = setTimeout(promiseSoundUrl, 1000)
}
}
promiseSoundUrl()
+6 -8
View File
@@ -558,10 +558,9 @@ export default {
let mp3list = [],
win = [],
text = ""
mp3list.push("baccarat_banker")
if(data.round.banker != null) mp3list.push(`${data.round.banker}_point`)
mp3list.push("baccarat_player")
if(data.round.player != null) mp3list.push(`${data.round.player}_point`)
if(data.round.banker != null && data.round.player != null) {
mp3list.push("baccarat_banker", `${data.round.banker}_point`, "baccarat_player", `${data.round.player}_point`)
}
if (data.round.opening == 1) {
text = Lang.value[Type.value].msg_banker_win
win.push("banker")
@@ -629,10 +628,9 @@ export default {
let mp3list = [],
win = [],
text = ""
mp3list.push("lh_dragon")
if(data.round.banker != null) mp3list.push(`${data.round.banker}_point`)
mp3list.push("lh_tiger")
if(data.round.player != null) mp3list.push(`${data.round.player}_point`)
if(data.round.banker != null && data.round.player != null) {
mp3list.push("lh_dragon", `${data.round.banker}_point`, "lh_tiger", `${data.round.player}_point`)
}
if (data.round.opening == 1) {
text = Lang.value[Type.value].msg_dragon_win
win.push("banker")