消费模块语音提示与读卡器状态镜像实施计划

For agentic workers: REQUIRED SUB-SKILL: Use executing-plans to 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。


1. 文件结构与职责

新建源文件

新建测试文件

修改文件

新建语音资源

全部放入 app/src/main/res/raw/

文档与记录

2. 音频基线

当前消费页使用的 please_place_card.wav 经核验为:

3. 实施任务

Task 1:建立语音业务映射

Files:

测试必须逐项断言:等待支付放卡、支付处理中、无订单、支付成功、首次读卡失败、余额不足、支付结果待确认、读卡器不可用、等待核验放卡和核验成功。并断言 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 命令,预期全部通过。

Task 2:建立播放去重与优先级门控

Files:

覆盖以下输入输出:

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'

预期:两个测试类全部通过。

Task 3:生成同声线本地语音

Files:

使用 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_s16le22050Hz、单声道、16-bit;使用 volumedetect 记录平均值和峰值,检查无削波;使用 silencedetect 检查无明显静音长尾。

重新合成“请放卡片进行支付”并转换为 PCM 流,比较 SHA-256;预期与现有 please_place_card.wav 的 PCM SHA-256 一致。

Task 4:实现 Android 播放控制器

Files:

使用一个 @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 错误。

Task 5:把语音事件接入消费状态机

Files:

移除 SoundPoolPlayer soundPlayerplaceCardPromptPlayedpromptForCardOnce();新增 ConsumptionVoiceController voiceControllerint readerRecoveryEpochonCreate 初始化控制器,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()armCurrentOrderonSearching 都可以调用 promptForCard(),相同会话键保证只播一次。

交易成功完成本地前读、扣款和后读一致性校验后,发出 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 在任何写卡指令之前根据完整卡片余额抛出,因此可安全归类为未扣款;其他扣款阶段异常仍保持结果待确认。

./gradlew :app:testDebugUnitTest \
  --tests 'com.zhct.traybinding.consumption.*' \
  --tests 'com.zhct.traybinding.onecard.reader.Consumption*'
./gradlew :app:compileDebugJavaWithJavac

预期:全部通过。

Task 6:移动读卡器异常提示到左侧镜像位置

Files:

android:layout_alignParentStart="true"
android:layout_marginStart="@dimen/common_title_bar_horizontal_margin"

删除 layout_alignParentEndlayout_marginEnd。不得修改 layout_belowlayout_marginTop、文字样式、颜色、字号或初始可见性。

rg -n -A12 'consumption_reader_status' \
  app/src/main/res/layout/activity_consumption.xml

预期:只出现 Start 对齐和 Start 外边距;继续复用 common_title_bar_horizontal_margin

Task 7:完整验证与变更记录

Files:

./gradlew :app:testDebugUnitTest

预期:BUILD SUCCESSFUL

./gradlew :app:assembleDebug

预期:BUILD SUCCESSFUL,生成 Debug APK。

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 标题、结论和验收结果必须一致。

4. 最终真机验收

5. 实施边界