/* ═════ 语音播报:my.createInnerAudioContext 播放远程 MP3 ═════ * 文案/文件名契约:docs/语音播报内容设计文档_V1.0.md(A 步骤引导 / B 姿态提示 / C 流程音) * 播放策略与 H5 一致:单通道;流程音排队顺序播不互截;姿态提示同条 4s 去重、不打断流程音; * 资源远程化:{ASSET_BASE}voice/.mp3,清单 manifest.json 缺失时默认全量尝试(404 静默) */ var api = require('./api.js'); // A/B/C 三类清单(与 H5 app.js VOICE 常量一致) var VOICE = { step: ['step1-front', 'step2-rightface', 'step3-leftface'], // A. 步骤引导 hint: { // B. 姿态提示(手动快门模式下基本不用,保留契约) '未检测到人脸': 'hint-noface', '请靠近一点': 'hint-near', '请远一点': 'hint-far', '请正对屏幕': 'hint-front', '请向左转头': 'hint-turn-left', '请向右转头': 'hint-turn-right', '保持不动': 'hint-hold' }, countdown: 'countdown-321', shot: 'shot-done', // C. 拍摄与流程 analyzing: 'analyzing', fail: 'analyze-fail' }; var DEDUPE_MS = 4000; // 同条提示 4s 内不重复播 var audio = null; // InnerAudioContext 单实例 var files = null; // null=清单未加载;{}=清单不可用(默认全量尝试) var lastAt = {}; // id → 上次开播时间(去重) var cur = null; // { id, kind:'flow'|'hint', token } var queue = []; // 排队中的流程音 var pendingHint = null; // 流程音期间挂起的姿态提示(只留最新) var waiters = []; // afterCurrent 回调:全部播报收尾后执行 // 拉取远程清单;失败则放行全量(语音缺失时 InnerAudioContext onError 静默跳过) function init() { if (audio) return; if (typeof my.createInnerAudioContext !== 'function') return; try { audio = my.createInnerAudioContext(); } catch (_) { return; } audio.onEnded(function () { drain(); }); audio.onError(function () { drain(); }); // 单条加载失败:跳过,继续队列 files = {}; // Known voice IDs; unavailable files are skipped by onError. } function ensure() { if (!audio) init(); } function playable(id) { return !!id && (files === null || !Object.keys(files).length || files[id]); } function start(id, kind) { ensure(); if (!audio) { drain(); return; } lastAt[id] = Date.now(); cur = { id: id, kind: kind, token: {} }; var token = cur.token; try { audio.stop(); audio.src = api.assetUrl('voice/' + id + '.mp3'); audio.play(); } catch (_) { // 资源域名不可用、平台拒绝非 HTTPS src 等情况:静默跳过。 // 语音只是辅助,绝不能让异常冒泡成页面崩溃(真机曾整页白屏)。 cur = null; drain(); return; } // 播放失败/结束时 cur 清理放在 drain 前,避免排队卡死 cur.token = token; } function stopCur() { if (!cur) return; cur = null; if (audio) { try { audio.stop(); } catch (e) {} } } function drain() { cur = null; if (queue.length) { start(queue.shift(), 'flow'); return; } if (pendingHint) { var h = pendingHint; pendingHint = null; start(h, 'hint'); return; } var w = waiters; waiters = []; w.forEach(function (fn) { fn(); }); } function busyWithFlow() { return (cur && cur.kind === 'flow') || queue.length > 0; } /** * 播报一条语音 * @param {string} id 文件名(去 .mp3) * @param {object} [opts] hint:姿态提示(4s 去重);cut:打断在播与排队(失败提示用) */ function play(id, opts) { opts = opts || {}; if (!playable(id)) return; ensure(); if (opts.hint) { if (lastAt[id] && Date.now() - lastAt[id] < DEDUPE_MS) return; if (busyWithFlow()) { pendingHint = id; return; } start(id, 'hint'); return; } if (opts.cut) { queue = []; pendingHint = null; stopCur(); start(id, 'flow'); return; } if (busyWithFlow()) { if (queue.indexOf(id) < 0) queue.push(id); return; } start(id, 'flow'); } function dropHint() { pendingHint = null; } // 换步/提示清空:挂起的旧提示作废 // 流程切走:作废排队/挂起/等待;hard 连在播一并掐断(退出拍照页),否则让在播自然收尾(出报告) function stop(hard) { queue = []; pendingHint = null; waiters = []; if (hard) stopCur(); } // 全部播报(含排队与挂起)自然收尾后执行 cb;空闲则立即执行 function afterCurrent(cb) { if (!cur && !queue.length && !pendingHint) { cb(); return; } waiters.push(cb); } module.exports = { VOICE: VOICE, init: init, play: play, dropHint: dropHint, stop: stop, afterCurrent: afterCurrent };