/* ═════ 语音播报:my.createInnerAudioContext 播放远程 MP3 ═════ * 文案/文件名契约:docs/语音播报内容设计文档_V1.0.md(A 步骤引导 / B 姿态提示 / C 流程音) * 播放策略与 H5 一致:单通道;流程音排队顺序播不互截;姿态提示同条 4s 去重、不打断流程音; * 资源远程化:{ASSET_BASE}voice/.mp3;播放失败跳过,不阻断拍照。 */ 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; // At most one live context; stopped contexts are never reused. var lastAt = {}; // id → 上次开播时间(去重) var cur = null; // { id, kind:'flow'|'hint', audio } var queue = []; // 排队中的流程音 var pendingHint = null; // 流程音期间挂起的姿态提示(只留最新) var waiters = []; // afterCurrent 回调:全部播报收尾后执行 var status = { version: 'VOICE-20261102-04', stage: 'idle', operation: 'none', errorType: 'none', id: '', code: 'NONE', category: 'none', reason: 'none', attempts: 0, canPlayEvents: 0, playEvents: 0, errorEvents: 0 }; function trace(stage, id, error) { status.stage = stage; status.id = id || ''; if (error) { status.errorEvents++; status.errorType = /^(TypeError|ReferenceError|RangeError|Error)$/.test(error.name || '') ? error.name : 'NativeError'; var code = error.errCode !== undefined ? error.errCode : error.errorCode !== undefined ? error.errorCode : error.error; status.code = typeof code === 'number' || typeof code === 'string' && /^[A-Za-z0-9_-]{1,32}$/.test(code) ? String(code) : 'UNAVAILABLE'; var message = String(error.message || error.errorMessage || error.errMsg || ''); status.reason = /appxDomain/i.test(message) ? 'APPX_DOMAIN_CONTEXT' : /white.?list|域名.*(允许|合法|白名单)|domain.*(allow|denied|invalid)/i.test(message) ? 'DOMAIN_REJECTED' : /TypeError|undefined|null/i.test(message) ? 'JS_CONTEXT_ERROR' : /not.support|unsupported|不支持/i.test(message) ? 'API_UNSUPPORTED' : 'UNCLASSIFIED'; status.category = /domain|whitelist|域名/i.test(message) ? 'domain' : /permission|权限/i.test(message) ? 'permission' : /format|codec|decode|格式/i.test(message) ? 'decode' : /network|timeout|网络/i.test(message) ? 'network' : /unsupported|not.support|不支持/i.test(message) ? 'unsupported' : 'unknown'; } // Fixed public clip IDs and numeric error codes only; never log returned // objects, media URLs, account identifiers, raw images or authorization data. try { if (typeof console !== 'undefined' && typeof console.info === 'function') console.info('[voice] ' + status.version + ' stage=' + stage + '; operation=' + status.operation + '; errorType=' + status.errorType + '; id=' + status.id + '; code=' + status.code + '; category=' + status.category + '; reason=' + status.reason); } catch (_) {} } function init() { if (audio) return; if (typeof my === 'undefined' || typeof my.createInnerAudioContext !== 'function') { trace('unavailable', '', { error: 'API_MISSING' }); return; } status.operation = 'create'; try { audio = my.createInnerAudioContext(); } catch (error) { trace('create_failed', '', error); return; } if (!audio) return; // Hosts differ in writable properties. An unsupported property must never // prevent playback or escape into the camera page. try { audio.autoplay = false; } catch (_) {} try { audio.loop = false; } catch (_) {} try { audio.volume = 1; } catch (_) {} // Taobao's audio specification explicitly does not support obeyMuteSwitch. } function ensure() { if (!audio) init(); } function playable(id) { return typeof id === 'string' && /^[a-z0-9-]+$/.test(id); } function release(context, stop) { if (!context) return; if (audio === context) audio = null; if (stop) { try { if (typeof context.stop === 'function') context.stop(); } catch (_) {} } try { if (typeof context.destroy === 'function') context.destroy(); } catch (_) {} } function finish(record) { // Late ended/error notifications from an interrupted clip cannot finish the // current clip or drain its queue. if (cur !== record) return; cur = null; release(record.audio, false); drain(); } function start(id, kind) { stopCur(); ensure(); if (!audio) { drain(); return; } lastAt[id] = Date.now(); var context = audio, record = { id: id, kind: kind, audio: context, assigned: false, ready: false, requested: false }; cur = record; status.attempts++; status.code = 'NONE'; status.category = 'none'; status.reason = 'none'; status.errorType = 'none'; trace('loading', id); function failed(error) { if (cur !== record) return; trace('error', id, error || { error: 'UNKNOWN' }); finish(record); } function requestPlay() { if (cur !== record || !record.assigned || !record.ready || record.requested) return; record.requested = true; status.operation = 'play'; trace('play_requested', id); try { var result = context.play(); if (result && typeof result.catch === 'function') result.catch(failed); } catch (error) { failed(error); } } try { status.operation = 'listen_ended'; context.onEnded(function () { if (cur === record) trace('ended', id); finish(record); }); status.operation = 'listen_error'; context.onError(failed); status.operation = 'listen_play'; if (typeof context.onPlay === 'function') context.onPlay(function () { if (cur !== record) return; record.requested = true; status.playEvents++; trace('playing', id); }); var readyEvent = typeof context.onCanPlay === 'function' ? context.onCanPlay : context.onCanplay; status.operation = 'listen_canplay'; if (typeof readyEvent === 'function') readyEvent.call(context, function () { if (cur !== record) return; record.ready = true; status.canPlayEvents++; trace('canplay', id); requestPlay(); }); else record.ready = true; status.operation = 'set_source'; context.src = api.assetUrl('voice/' + id + '.mp3'); record.assigned = true; requestPlay(); } catch (error) { failed(error); } } function stopCur() { if (!cur) return; var record = cur; cur = null; release(record.audio, true); } 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; if (opts.hint) { if (Object.prototype.hasOwnProperty.call(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(); if (audio) release(audio, true); } } // 全部播报(含排队与挂起)自然收尾后执行 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, diagnostics: function () { return Object.assign({}, status); } };