# 语音助手聆听态空闲提示实现方案

## 一、需求来源

用户希望语音助手在打开窗口后，当用户还没有说话时，给出更准确的视觉反馈：

| 需求编号 | 描述 |
|---------|------|
| 1 | ASR 已激活但用户尚未开口时，音波动画显示最小状态（与静音时的最小条形一致） |
| 2 | ASR 已激活但用户尚未开口时，下方文字从"正在聆听"变为"请开始说话" |
| 3 | 不引入实时音频检测（VAD）等额外库，仅基于现有 ASR SDK 回调实现 |

## 二、改动前现状分析

### 已实现

- `VoiceWaveWidget` 通过 `setActive(bool)` 控制音波动画：
  - `setActive(true)` → 振幅渐增到 1.0，条形跳动
  - `setActive(false)` → 振幅渐减到 0，条形收缩到 `kMinBarHeight = 3px`（最小状态）
- `updateVoiceStateUi()` 根据 `m_state` 控制 `label_tip` 文字：
  - Idle → "正在聆听"
  - Listening → "正在聆听"
  - Idle + 静音 → "已静音"
- `m_asrListeningRoundActive` 布尔标志已追踪"本轮 ASR 是否已收到有效语音输入"：
  - `false`：ASR 已激活但尚未收到 partial/final
  - `true`：已收到至少一次有效 partial 或 final

### 未实现/有问题

1. **Listening 状态一律激活音波**：`updateVoiceStateUi()` 中 `setActive(!m_muted && m_state == Listening)`，无论用户是否在说话，音波都处于活跃跳动状态
2. **Idle 和 Listening 文字相同**：都显示"正在聆听"，无法区分"ASR 启动中"和"用户正在说话"
3. **缺少"等待用户开口"的视觉反馈**：用户打开窗口后，看到跳动的音波和"正在聆听"，容易误以为助手正在处理

## 三、设计方案

### 3.1 核心思路：利用已有 ASR 回调判断"用户是否在说话"

**不需要引入 VAD 库**。ASR SDK（`kylin-ai/coreai/speech`）内部已内置 VAD，它决定了何时发出 `partialText` 回调。我们只需观察 `partialText` 是否已到达，就能判断用户是否在说话：

```cpp
const bool userSpeaking = m_asrListeningRoundActive || m_asrFinalSubmitTimer->isActive();
```

- `m_asrListeningRoundActive == true`：已收到有效 partial/final → 用户正在说话
- `m_asrFinalSubmitTimer->isActive() == true`：final 后 1.5 秒等待窗口内 → 用户可能还在补充，保持活跃

两个都为 `false`：ASR 已激活但用户尚未开口。

### 3.2 音波动画控制

改动前：

```cpp
ui->widget_voice_logo->setActive(!m_muted && m_state == Listening);
```

改动后：

```cpp
ui->widget_voice_logo->setActive(!m_muted && m_state == Listening && userSpeaking);
```

`userSpeaking == false` 时，`setActive(false)` 使振幅渐减到 0，条形收缩到 `kMinBarHeight = 3px`——与静音时的最小状态一致。`VoiceWaveWidget` 已有的 `fadingOut` 机制确保了平滑过渡，无需新增任何动画状态。

### 3.3 文字提示控制

| 状态 | m_muted | userSpeaking | label_tip 文字 |
|------|---------|-------------|---------------|
| Idle | true | - | "已静音" |
| Idle | false | - | "请开始说话" |
| Listening | - | true | "正在聆听" |
| Listening | - | false | "请开始说话" |

关键改动：Idle 状态文字从 `"Listening"` 改为 `"Please start speaking"`；Listening 状态文字根据 `userSpeaking` 动态切换。

### 3.4 UI 刷新时机

`m_asrListeningRoundActive` 从 `false` 变为 `true` 的时刻（首次收到有效 partial），需要主动刷新 UI。在 `handlePartialText()` 中增加：

```cpp
if (!text.trimmed().isEmpty()) {
    const bool wasIdle = !m_asrListeningRoundActive;
    m_asrListeningRoundActive = true;
    m_lastPartialText = text;
    if (wasIdle) {
        updateVoiceStateUi();
    }
}
```

`m_asrListeningRoundActive` 被重置为 `false` 的时刻，UI 刷新由已有的 `setState()` 调用自然覆盖（`stopRecognition`、`pauseRecognitionForPlayback`、`handleRecognitionError` 等都会调用 `setState` 或 `updateVoiceStateUi`）。

### 3.5 recognitionStarted 回调补刷

ASR 通过 pending start 机制自动重启时，`recognitionStarted` 回调中 `m_state` 可能已经是 `Listening`，导致 `setState(Listening)` 被跳过、`updateVoiceStateUi()` 不被调用。此时 `m_asrListeningRoundActive` 已是 `false`，UI 应显示"请开始说话"，但实际未刷新。

修复：在 `else` 分支补充 `updateVoiceStateUi()` 调用。

### 3.6 设计决策

| 决策 | 理由 |
|------|------|
| 不引入 VAD 库 | ASR SDK 内部已含 VAD，通过 partial 回调有无即可判断 |
| final 后 1.5s 保持活跃 | 用户可能在停顿后继续补充，保持活跃避免音波闪烁，体验更自然 |
| Idle 状态也改为"请开始说话" | Idle 是 ASR 重启过渡态，用户此时也无法说话，显示"请开始说话"更准确 |
| 用 `m_asrListeningRoundActive` 而非新增标志 | 已有标志精准追踪"是否已收到有效语音输入"，无需重复造轮子 |

## 四、文件改动详情

### 4.1 mainwindow.cpp

#### a) recognitionStarted 回调补刷 UI

```cpp
connect(m_speechController, &SpeechRecognitionController::recognitionStarted, this, [this]() {
    qDebug() << "voice-assistant speech recognition started";
    m_discardLateAsrResults = false;
    if (!m_muted && m_state != Listening) {
        setState(Listening);
    } else {
        updateVoiceStateUi();   // 新增：pending start 场景下 m_state 已是 Listening，需补刷
    }
});
```

#### b) updateVoiceStateUi() 改造

```cpp
void MainWindow::updateVoiceStateUi()
{
    if (m_callbackMode) {
        return;
    }

    const bool isReading = (m_state == Reading);

    // 用户正在说话：已收到 partial 或 final 后仍在延迟提交等待窗口内。
    // 两者都为 false 表示 ASR 已激活但用户尚未开口。
    const bool userSpeaking = m_asrListeningRoundActive || m_asrFinalSubmitTimer->isActive();

    ui->widget_voice_logo->setActive(!m_muted && m_state == Listening && userSpeaking);
    ui->widget_voice_logo->setDrawable(!isReading);

    if (isReading) {
        ui->label_tip->hide();
        ui->interruptButton->show();
        return;
    }

    ui->interruptButton->hide();

    if (m_state == Idle && m_muted) {
        ui->label_tip->setText(tr("Muted"));
        ui->label_tip->show();
    } else if (m_state == Idle) {
        ui->label_tip->setText(tr("Please start speaking"));
        ui->label_tip->show();
    } else if (m_state == Listening) {
        ui->label_tip->setText(userSpeaking ? tr("Listening") : tr("Please start speaking"));
        ui->label_tip->show();
    }
}
```

#### c) handlePartialText() 首次 partial 刷新 UI

```cpp
void MainWindow::handlePartialText(const QString &text)
{
    if (m_discardLateAsrResults) {
        return;
    }

    if (m_asrFinalSubmitTimer->isActive()) {
        m_asrFinalSubmitTimer->stop();
    }

    if (!text.trimmed().isEmpty()) {
        const bool wasIdle = !m_asrListeningRoundActive;
        m_asrListeningRoundActive = true;
        m_lastPartialText = text;
        if (wasIdle) {
            updateVoiceStateUi();   // 新增：首次收到 partial，从"请开始说话"切换到"正在聆听"
        }
    }
    ensureUserBubble();
    ui->page_chat->setUserBubbleText(pendingRecognitionTextWith(text));
}
```

### 4.2 voice_assistant_zh_CN.ts

新增翻译条目：

```xml
<message>
    <source>Please start speaking</source>
    <translation>请开始说话</translation>
</message>
```

### 4.3 voice_assistant_zh_TW.ts

新增翻译条目：

```xml
<message>
    <source>Please start speaking</source>
    <translation>請開始說話</translation>
</message>
```

## 五、UI 状态对照（改动后）

| 状态 | 音波动画 | label_tip | interruptButton |
|------|---------|-----------|-----------------|
| Idle（未静音） | 绘制但不动 | **"请开始说话"** | 隐藏 |
| Idle（静音） | 绘制但不动 | "已静音" | 隐藏 |
| Listening（用户未说话） | **最小状态（3px）** | **"请开始说话"** | 隐藏 |
| Listening（用户说话中） | 动画中 | "正在聆听" | 隐藏 |
| Listening（final 后 1.5s） | 动画中 | "正在聆听" | 隐藏 |
| Processing | 绘制但不动 | 保持上态 | 隐藏 |
| Answering | 绘制但不动 | 保持上态 | 隐藏 |
| Reading | 不绘制（占位） | 隐藏 | 显示 |

## 六、关键流程图

### 6.1 窗口打开后等待用户说话

```
窗口打开
  → scheduleStartRecognition(0)
  → startRecognition()
  → setState(Listening)
  → updateVoiceStateUi()
    → m_asrListeningRoundActive == false
    → userSpeaking == false
    → setActive(false)  → 音波最小
    → label_tip = "请开始说话"
```

### 6.2 用户开始说话

```
ASR 检测到语音
  → partialText("你好")
  → handlePartialText()
    → wasIdle == true（首次 partial）
    → m_asrListeningRoundActive = true
    → updateVoiceStateUi()
      → userSpeaking == true
      → setActive(true)  → 音波活跃跳动
      → label_tip = "正在聆听"
```

### 6.3 用户说完后等待提交

```
ASR 检测到停顿
  → finalText("你好")
  → handleFinalText()
    → m_asrListeningRoundActive 仍为 true
    → m_asrFinalSubmitTimer 启动（1.5s）
  → 1.5s 内：userSpeaking 仍为 true（timer 活跃）
    → 音波保持活跃 + "正在聆听"
  → 1.5s 后：
    → submitPendingRecognitionText()
    → m_asrListeningRoundActive = false
    → submitFinalText() → setState(Processing)
```

### 6.4 一轮对话结束后 ASR 重启

```
AI 朗读完成
  → resumeRecognitionAfterPlayback()
  → setState(Idle)
  → scheduleStartRecognition(2100ms)
  → 2100ms 后 startRecognition()
  → setState(Listening)
  → updateVoiceStateUi()
    → m_asrListeningRoundActive == false
    → userSpeaking == false
    → 音波最小 + "请开始说话"
```

### 6.5 pending start 场景下 ASR 自动重启

```
ASR stop() 后 2s timer 回调
  → 自动 start()
  → recognitionStarted 回调
  → m_state 已是 Listening → setState 被跳过
  → else 分支：updateVoiceStateUi()
    → m_asrListeningRoundActive == false
    → userSpeaking == false
    → 音波最小 + "请开始说话"
```

## 七、m_asrListeningRoundActive 刷新路径完整覆盖

| 路径 | m_asrListeningRoundActive 变化 | UI 刷新机制 | 覆盖 |
|------|------|------|------|
| 窗口打开 → startRecognition → setState(Listening) | false → false | setState → updateVoiceStateUi | ✅ |
| 收到首个 partial → handlePartialText | false → true | wasIdle → updateVoiceStateUi | ✅ |
| 收到后续 partial | true → true | 不刷新（无需） | ✅ |
| 收到 final → handleFinalText | true → true | 不刷新（无需） | ✅ |
| final 1.5s 后 → submitPendingRecognitionText | true → false | submitFinalText → setState(Processing) | ✅ |
| recognitionStarted（pending start） | false → false | else 分支 → updateVoiceStateUi | ✅ |
| stopRecognition | any → false | setState(Idle) → updateVoiceStateUi | ✅ |
| stopAudioForHide | any → false | setState(Idle) 或 updateVoiceStateUi | ✅ |
| pauseRecognitionForPlayback | any → false | 后续 setState 刷新 | ✅ |
| handleRecognitionError | any → false | setState(Idle) → updateVoiceStateUi | ✅ |
| 静音（无 partial） | false → false | setState(Idle) → updateVoiceStateUi | ✅ |
| 静音（有 partial） | true → submit | submitPendingRecognitionText → setState(Processing) | ✅ |
| 取消静音 → startRecognition | false → false | setState(Listening) → updateVoiceStateUi | ✅ |
| startRecognitionForLauncherRequest | Idle/隐藏恢复 → false；已 Listening/Processing/Answering/Reading 时不重置 | 空闲隐藏态 setState(Idle) → startRecognition；已在运行时保持当前状态 | ✅ |

## 八、涉及文件汇总

| 文件 | 改动类型 |
|------|---------|
| `mainwindow.cpp` | 核心逻辑修改（3 处） |
| `voice_assistant_zh_CN.ts` | 新增翻译 |
| `voice_assistant_zh_TW.ts` | 新增翻译 |

## 九、风险与缓解

| 风险 | 缓解措施 |
|------|----------|
| 音波在 userSpeaking 变化时闪烁 | `VoiceWaveWidget::setActive()` 内部已有 `kAmplitudeStep = 0.06` 的渐变过渡，不会突然跳变 |
| pending start 场景下 UI 未刷新 | `recognitionStarted` 回调 else 分支补充 `updateVoiceStateUi()` |
| ASR 启动延迟导致"请开始说话"显示过长 | 这是准确的状态反馈，ASR 正在初始化时用户确实无法说话 |
| `m_asrFinalSubmitTimer->isActive()` 在非 Listening 状态下被误读 | `setActive` 条件已包含 `m_state == Listening`，非 Listening 状态下 `userSpeaking` 不影响音波 |
