For agentic workers: REQUIRED SUB-SKILL: Use
executing-plansto implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.ZhctTrayBindingMachine只允许只读 Git 检查,因此计划中的阶段检查不包含暂存或提交。
Goal: 为消费模块增加面向就餐人员的动作与业务结果语音,准确区分未扣款失败和扣款结果待确认,同时把读卡器异常文字移动到屏幕左侧镜像位置。
Architecture: 使用纯 Java ConsumptionVoicePolicy 将业务事件映射为语音及优先级,使用纯 Java ConsumptionVoicePlaybackGate 管理去重和抢占,再由 Android ConsumptionVoiceController 负责语音开关、本地资源播放和生命周期。ConsumptionActivity 只在订单、交易分类和硬件状态发生业务语义变化时发出事件,不直接按读卡/写卡技术回调拼接语音。
Tech Stack: Java 8、Android MediaPlayer、MMKV 语音开关、JUnit 4、Android XML、本地 PCM WAV、macOS say、FFmpeg/FFprobe、Gradle。
app/src/main/java/com/zhct/traybinding/consumption/voice/ConsumptionVoicePolicy.java:定义业务事件、用户语音提示和优先级,并提供纯 Java 映射。app/src/main/java/com/zhct/traybinding/consumption/voice/ConsumptionVoicePlaybackGate.java:按业务会话去重,判断跳过、直接播放或中断低优先级语音。app/src/main/java/com/zhct/traybinding/consumption/voice/ConsumptionVoiceController.java:检查语音开关,将语音提示映射到 res/raw,控制 MediaPlayer 播放、抢占、完成和释放。app/src/test/java/com/zhct/traybinding/consumption/voice/ConsumptionVoicePolicyTest.java:覆盖所有事件到提示的映射及优先级。app/src/test/java/com/zhct/traybinding/consumption/voice/ConsumptionVoicePlaybackGateTest.java:覆盖重复提示、优先级抢占、播放完成和新会话复位。app/src/main/java/com/zhct/traybinding/main/ConsumptionActivity.java:发出业务语音事件,移除页面内零散的 SoundPoolPlayer 和单一放卡标记。app/src/main/res/layout/activity_consumption.xml:将 consumption_reader_status 从 End 对齐改为 Start 对齐,外边距继续复用现有尺寸。全部放入 app/src/main/res/raw/:
consumption_place_card_payment.wavconsumption_payment_processing.wavconsumption_no_pending_order.wavconsumption_payment_success.wavconsumption_card_read_failed.wavconsumption_payment_unknown.wavconsumption_reader_unavailable.wavconsumption_place_card_verify.wavconsumption_verify_success.wavconsumption_balance_insufficient.wavdocs/2026-08-29-consumption-voice-prompt-strategy-implementation-plan.md/.html:本实施计划。change_records/2026-08-29-consumption-voice-prompt-strategy.md/.html:代码、资源、测试和音频基线的独立变更记录。当前消费页使用的 please_place_card.wav 经核验为:
Tingting。175;在当前系统语音版本下,160 至 180 生成相同 PCM,本计划固定使用 175。-18.1dB,峰值约 -3.5dB。Files:
Create: app/src/test/java/com/zhct/traybinding/consumption/voice/ConsumptionVoicePolicyTest.java
Create: app/src/main/java/com/zhct/traybinding/consumption/voice/ConsumptionVoicePolicy.java
Step 1:先写失败测试
测试必须逐项断言:等待支付放卡、支付处理中、无订单、支付成功、首次读卡失败、余额不足、支付结果待确认、读卡器不可用、等待核验放卡和核验成功。并断言 PAYMENT_UNKNOWN 高于失败、成功、动作和硬件提示。
assertEquals(Prompt.PAYMENT_UNKNOWN,
ConsumptionVoicePolicy.resolve(Event.PAYMENT_UNKNOWN));
assertTrue(Prompt.PAYMENT_UNKNOWN.getPriority()
> Prompt.PAYMENT_SUCCEEDED.getPriority());
assertTrue(Prompt.PAYMENT_SUCCEEDED.getPriority()
> Prompt.PLACE_PAYMENT_CARD.getPriority());
./gradlew :app:testDebugUnitTest \
--tests com.zhct.traybinding.consumption.voice.ConsumptionVoicePolicyTest
预期:测试编译失败,提示 ConsumptionVoicePolicy 不存在。
事件与提示保持一对一、具名且无资源 ID:
public enum Event {
PLACE_PAYMENT_CARD,
PAYMENT_PROCESSING,
NO_PENDING_ORDER,
PAYMENT_SUCCEEDED,
CARD_READ_FAILED,
BALANCE_INSUFFICIENT,
PAYMENT_UNKNOWN,
READER_UNAVAILABLE,
PLACE_VERIFICATION_CARD,
VERIFICATION_SUCCEEDED
}
public enum Prompt {
READER_UNAVAILABLE(10),
PLACE_PAYMENT_CARD(20),
PLACE_VERIFICATION_CARD(20),
PAYMENT_PROCESSING(30),
PAYMENT_SUCCEEDED(40),
VERIFICATION_SUCCEEDED(40),
CARD_READ_FAILED(50),
BALANCE_INSUFFICIENT(50),
NO_PENDING_ORDER(50),
PAYMENT_UNKNOWN(60);
}
resolve(Event) 必须显式 switch 全部事件;null 输入抛出 IllegalArgumentException,不使用默认静默回退。
运行 Step 2 命令,预期全部通过。
Files:
Create: app/src/test/java/com/zhct/traybinding/consumption/voice/ConsumptionVoicePlaybackGateTest.java
Create: app/src/main/java/com/zhct/traybinding/consumption/voice/ConsumptionVoicePlaybackGate.java
Step 1:先写失败测试
覆盖以下输入输出:
assertEquals(Decision.PLAY,
gate.request(Prompt.PLACE_PAYMENT_CARD, "flow-1:wait"));
assertEquals(Decision.SKIP_DUPLICATE,
gate.request(Prompt.PLACE_PAYMENT_CARD, "flow-1:wait"));
assertEquals(Decision.INTERRUPT_AND_PLAY,
gate.request(Prompt.PAYMENT_UNKNOWN, "flow-1:unknown"));
assertEquals(Decision.SKIP_LOWER_PRIORITY,
gate.request(Prompt.READER_UNAVAILABLE, "flow-1:reader:1"));
gate.onPlaybackCompleted(Prompt.PAYMENT_UNKNOWN);
assertEquals(Decision.PLAY,
gate.request(Prompt.READER_UNAVAILABLE, "flow-1:reader:1"));
gate.resetSession();
assertEquals(Decision.PLAY,
gate.request(Prompt.PLACE_PAYMENT_CARD, "flow-1:wait"));
./gradlew :app:testDebugUnitTest \
--tests com.zhct.traybinding.consumption.voice.ConsumptionVoicePlaybackGateTest
预期:测试编译失败,提示门控类不存在。
public Decision request(Prompt prompt, String dedupKey) {
requirePromptAndKey(prompt, dedupKey);
if (playedKeys.contains(dedupKey)) return Decision.SKIP_DUPLICATE;
if (activePrompt != null
&& prompt.getPriority() <= activePrompt.getPriority()) {
return Decision.SKIP_LOWER_PRIORITY;
}
Decision decision = activePrompt == null
? Decision.PLAY : Decision.INTERRUPT_AND_PLAY;
playedKeys.add(dedupKey);
activePrompt = prompt;
return decision;
}
onPlaybackCompleted 只允许当前提示清空活动状态;resetSession 清空去重集合和活动提示。类注释说明它不播放音频,只维护会话状态。
./gradlew :app:testDebugUnitTest \
--tests 'com.zhct.traybinding.consumption.voice.*Test'
预期:两个测试类全部通过。
Files:
Create: app/src/main/res/raw/consumption_place_card_payment.wav
Create: app/src/main/res/raw/consumption_payment_processing.wav
Create: app/src/main/res/raw/consumption_no_pending_order.wav
Create: app/src/main/res/raw/consumption_payment_success.wav
Create: app/src/main/res/raw/consumption_card_read_failed.wav
Create: app/src/main/res/raw/consumption_payment_unknown.wav
Create: app/src/main/res/raw/consumption_reader_unavailable.wav
Create: app/src/main/res/raw/consumption_place_card_verify.wav
Create: app/src/main/res/raw/consumption_verify_success.wav
Create: app/src/main/res/raw/consumption_balance_insufficient.wav
Step 1:在临时目录合成 AIFF
使用 mktemp -d 建立临时目录,固定 Tingting 和语速 175。文本与资源名如下:
consumption_place_card_payment=请将卡片放在读卡区
consumption_payment_processing=正在支付,请勿移动卡片
consumption_no_pending_order=没有待支付订单,请取走餐盘
consumption_payment_success=支付成功,请取走卡片和餐盘
consumption_card_read_failed=读卡失败,请取走卡片后重新操作
consumption_payment_unknown=支付结果待确认,请勿重复刷卡,请联系工作人员
consumption_reader_unavailable=读卡器暂不可用,请联系工作人员
consumption_place_card_verify=请将卡片放在读卡区进行核验
consumption_verify_success=核验完成,请取走卡片和餐盘
consumption_balance_insufficient=余额不足,请更换卡片或选择其他支付方式
对每条执行:
say -v Tingting -r 175 -o "$tmp_dir/<resource>.aiff" '<text>'
ffmpeg -v error -y -i "$tmp_dir/<resource>.aiff" \
-ar 22050 -ac 1 -c:a pcm_s16le \
"app/src/main/res/raw/<resource>.wav"
不做额外响度归一化,因为参考文件与同链路合成结果 PCM 完全一致;额外归一化反而会改变现有声音大小。
使用 ffprobe 验证每个新文件均为 pcm_s16le、22050Hz、单声道、16-bit;使用 volumedetect 记录平均值和峰值,检查无削波;使用 silencedetect 检查无明显静音长尾。
重新合成“请放卡片进行支付”并转换为 PCM 流,比较 SHA-256;预期与现有 please_place_card.wav 的 PCM SHA-256 一致。
Files:
Create: app/src/main/java/com/zhct/traybinding/consumption/voice/ConsumptionVoiceController.java
Step 1:实现资源集中映射
使用一个 @RawRes 方法集中映射,Activity 不得引用新语音资源 ID:
@RawRes
private int resourceFor(Prompt prompt) {
switch (prompt) {
case PLACE_PAYMENT_CARD:
return R.raw.consumption_place_card_payment;
case PAYMENT_PROCESSING:
return R.raw.consumption_payment_processing;
case NO_PENDING_ORDER:
return R.raw.consumption_no_pending_order;
case PAYMENT_SUCCEEDED:
return R.raw.consumption_payment_success;
case CARD_READ_FAILED:
return R.raw.consumption_card_read_failed;
case BALANCE_INSUFFICIENT:
return R.raw.consumption_balance_insufficient;
case PAYMENT_UNKNOWN:
return R.raw.consumption_payment_unknown;
case READER_UNAVAILABLE:
return R.raw.consumption_reader_unavailable;
case PLACE_VERIFICATION_CARD:
return R.raw.consumption_place_card_verify;
case VERIFICATION_SUCCEEDED:
return R.raw.consumption_verify_success;
default:
throw new IllegalStateException("Unsupported prompt: " + prompt);
}
}
play(Event event, String scopeKey) 的顺序固定为:校验未释放和非空会话键;读取 SETTING_VOICE_SWITCH;解析提示;向门控请求;需要抢占时释放当前 MediaPlayer;创建并开始本地资源播放器;完成后通知门控并释放。创建失败时记录错误并清除活动提示,允许后续状态继续。
resetSession() 中止上一会话语音并清空门控;release() 永久释放。类注释明确它不判断支付结果,只执行已分类的语音事件。
./gradlew :app:compileDebugJavaWithJavac
预期:编译成功,无缺失资源和 Android API 错误。
Files:
Modify: app/src/main/java/com/zhct/traybinding/main/ConsumptionActivity.java
Step 1:替换页面内播放器状态
移除 SoundPoolPlayer soundPlayer、placeCardPromptPlayed 及 promptForCardOnce();新增 ConsumptionVoiceController voiceController 和 int readerRecoveryEpoch。onCreate 初始化控制器,onDestroy 调用 release()。
private void playVoice(ConsumptionVoicePolicy.Event event, String scope) {
if (flowId == null || scope == null) return;
voiceController.play(event, flowId + ':' + scope);
}
private void promptForCard() {
playVoice(isZeroAmountOrder()
? Event.PLACE_VERIFICATION_CARD : Event.PLACE_PAYMENT_CARD,
"place-card");
}
新餐盘成功建立会话时调用 voiceController.resetSession();armCurrentOrder 和 onSearching 都可以调用 promptForCard(),相同会话键保证只播一次。
Step 3:接入订单和硬件事件
接口 B 明确返回 ORDER_NOT_FOUND_CODE 时,在页面错误文案准备完成后发出 NO_PENDING_ORDER。
onTransactionStage(BEFORE_READ) 且金额大于 0 时发出 PAYMENT_PROCESSING。
onDeviceNotFound、onPermissionDenied、onDisconnected 和 onError 只在业务状态为 WAITING_CARD 时发出 READER_UNAVAILABLE。
每次 onSearching 表示读卡器恢复,递增 readerRecoveryEpoch;异常键使用 reader-<epoch>,同一连续异常只播一次。
armConsumption 失败时使用 READER_UNAVAILABLE,不错误播报“读卡失败”。
Step 4:接入正金额结果分类
交易成功完成本地前读、扣款和后读一致性校验后,发出 PAYMENT_SUCCEEDED,再执行接口 C 上报。接口 C 上报失败不得改播支付失败。
错误分类使用:
boolean insufficient = isInsufficientBalance(outcome);
boolean attempted = outcome != null
&& outcome.wasConsumptionAttempted() && !insufficient;
Event event = insufficient ? Event.BALANCE_INSUFFICIENT
: attempted ? Event.PAYMENT_UNKNOWN : Event.CARD_READ_FAILED;
isInsufficientBalance 只接受 ReaderError.INSUFFICIENT_BALANCE。该错误由现有 ConsumptionPlanner 在任何写卡指令之前根据完整卡片余额抛出,因此可安全归类为未扣款;其他扣款阶段异常仍保持结果待确认。
Step 5:接入零金额结果
零金额首次完整读卡成功后发出 VERIFICATION_SUCCEEDED,再执行上报。
零金额读卡失败后发出 CARD_READ_FAILED。
零金额流程不发出 PAYMENT_PROCESSING、PAYMENT_SUCCEEDED 或 PAYMENT_UNKNOWN。
Step 6:运行消费相关测试和编译
./gradlew :app:testDebugUnitTest \
--tests 'com.zhct.traybinding.consumption.*' \
--tests 'com.zhct.traybinding.onecard.reader.Consumption*'
./gradlew :app:compileDebugJavaWithJavac
预期:全部通过。
Files:
Modify: app/src/main/res/layout/activity_consumption.xml
Step 1:只替换水平方向属性
android:layout_alignParentStart="true"
android:layout_marginStart="@dimen/common_title_bar_horizontal_margin"
删除 layout_alignParentEnd 和 layout_marginEnd。不得修改 layout_below、layout_marginTop、文字样式、颜色、字号或初始可见性。
rg -n -A12 'consumption_reader_status' \
app/src/main/res/layout/activity_consumption.xml
预期:只出现 Start 对齐和 Start 外边距;继续复用 common_title_bar_horizontal_margin。
Files:
Create: change_records/2026-08-29-consumption-voice-prompt-strategy.md
Create: change_records/2026-08-29-consumption-voice-prompt-strategy.html
Step 1:运行完整单元测试
./gradlew :app:testDebugUnitTest
预期:BUILD SUCCESSFUL。
./gradlew :app:assembleDebug
预期:BUILD SUCCESSFUL,生成 Debug APK。
Step 3:执行资源和硬编码检查
新音频全部在 app/src/main/res/raw/。
Activity 不直接引用新语音资源 ID。
不存在运行时 TTS 或网络语音依赖。
ConsumptionVoicePolicy 和控制器包含责任与边界注释。
读卡器状态显示逻辑仍是仅 Tone.ERROR 可见。
只将读卡器文字从 End 改为 Start,没有新增绝对坐标。
Step 4:执行只读差异检查
git diff --check
git status --short
git diff -- app/src/main/java/com/zhct/traybinding/consumption/voice \
app/src/main/java/com/zhct/traybinding/main/ConsumptionActivity.java \
app/src/main/res/layout/activity_consumption.xml \
app/src/main/res/raw
只读取差异,不暂存、不提交、不推送。
记录:业务语音策略、音频声线和参数复现证据、交易安全分类、页面镜像调整、自动化测试、构建结果及真机待验项。Markdown 与 HTML 标题、结论和验收结果必须一致。