Desmond-Dong commited on
Commit
b746881
·
1 Parent(s): 96083ea

Initial commit: Reachy Mini Home Assistant Voice Assistant

Browse files

- Implement ESPHome protocol for Home Assistant integration
- Support multiple wake words (microWakeWord and openWakeWord)
- Integrate Reachy Mini robot actions and feedback
- Include all wake word models and sound files
- Add Hugging Face Spaces deployment support

.gitattributes CHANGED
@@ -1,37 +1,24 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
  *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
36
  "reachy_mini_ha_voice/wakewords/**/*.tflite filter=lfs diff=lfs merge=lfs -text
37
  reachy_mini_ha_voice/sounds/**/*.flac" filter=lfs diff=lfs merge=lfs -text
 
1
+ # Auto detect text files and perform LF normalization
2
+ * text=auto
3
+
4
+ # Source code
5
+ *.py text eol=lf
6
+ *.sh text eol=lf
7
+ *.yaml text eol=lf
8
+ *.yml text eol=lf
9
+ *.json text eol=lf
10
+ *.toml text eol=lf
11
+ *.md text eol=lf
12
+ # Binary files
13
+ *.tflite binary
14
+ *.flac binary
15
+ *.wav binary
16
+ *.mp3 binary
17
+ # Windows scripts
18
+ *.ps1 text eol=crlf
19
+ # Git LFS for large files
 
 
 
 
 
 
 
 
 
20
  *.tflite filter=lfs diff=lfs merge=lfs -text
21
+ *.flac filter=lfs diff=lfs merge=lfs -text
22
+ *.wav filter=lfs diff=lfs merge=lfs -text
 
 
 
 
23
  "reachy_mini_ha_voice/wakewords/**/*.tflite filter=lfs diff=lfs merge=lfs -text
24
  reachy_mini_ha_voice/sounds/**/*.flac" filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ *.so
5
+ .Python
6
+ build/
7
+ develop-eggs/
8
+ dist/
9
+ downloads/
10
+ eggs/
11
+ .eggs/
12
+ lib/
13
+ lib64/
14
+ parts/
15
+ sdist/
16
+ var/
17
+ wheels/
18
+ *.egg-info/
19
+ .installed.cfg
20
+ *.egg
21
+ .venv/
22
+ env/
23
+ venv/
24
+ ENV/
25
+ .ENV
26
+ .pytest_cache/
27
+ .mypy_cache/
28
+ .dmypy.json
29
+ dmypy.json
30
+ .DS_Store
31
+ *.log
32
+ preferences.json
33
+ local/
34
+ *.tflite
35
+ *.flac
36
+ *.wav
37
+ *.mp3
Dockerfile ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use Python 3.11 slim image
2
+ FROM python:3.11-slim
3
+
4
+ # Set working directory
5
+ WORKDIR /app
6
+
7
+ # Install system dependencies
8
+ RUN apt-get update && apt-get install -y \
9
+ portaudio19-dev \
10
+ build-essential \
11
+ libportaudio2 \
12
+ && rm -rf /var/lib/apt/lists/*
13
+
14
+ # Copy requirements first for better caching
15
+ COPY requirements.txt .
16
+
17
+ # Install Python dependencies
18
+ RUN pip install --no-cache-dir -r requirements.txt
19
+
20
+ # Copy the application code
21
+ COPY . .
22
+
23
+ # Install the package
24
+ RUN pip install -e .
25
+
26
+ # Expose the ESPHome API port
27
+ EXPOSE 6053
28
+
29
+ # Set environment variables
30
+ ENV PYTHONUNBUFFERED=1
31
+
32
+ # Default command - can be overridden by Hugging Face Spaces
33
+ CMD ["python", "-m", "reachy_mini_ha_voice", "--name", "ReachyMini"]
README.md CHANGED
@@ -1,10 +1,248 @@
1
- ---
2
- title: Reachy Mini Ha Voice
3
- emoji: 🌖
4
- colorFrom: green
5
- colorTo: gray
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Reachy Mini Home Assistant Voice Assistant
2
+
3
+ 基于 ESPHome 协议的 Reachy Mini 语音助手,用于连接 Home Assistant。可通过 Hugging Face Spaces 一键安装和部署。
4
+
5
+ ## 功能特性
6
+
7
+ - 🎤 唤醒词检测(支持多个唤醒词)
8
+ - 🔊 语音识别和合成
9
+ - 🏠 Home Assistant 指令执行
10
+ - 🤖 Reachy Mini 机器人集成
11
+ - ⏰ 定时器功能
12
+ - 📢 广播通知
13
+
14
+ ## 快速开始
15
+
16
+ ### 通过 Hugging Face Spaces 安装
17
+
18
+ 1. 访问 [Hugging Face Spaces](https://huggingface.co/spaces)
19
+ 2. 创建新的 Space,选择 Docker 模板
20
+ 3. 将此仓库克隆到你的 Space
21
+ 4. 等待构建完成,服务将自动启动
22
+
23
+ ### 本地运行
24
+
25
+ ```bash
26
+ # 克隆仓库
27
+ git clone https://github.com/your-username/reachy_mini_ha_voice.git
28
+ cd reachy_mini_ha_voice
29
+
30
+ # 安装系统依赖(Linux)
31
+ sudo apt-get install portaudio19-dev build-essential libportaudio2
32
+
33
+ # 创建虚拟环境
34
+ python -m venv .venv
35
+ source .venv/bin/activate # Windows: .venv\Scripts\activate
36
+
37
+ # 安装 Python 依赖
38
+ pip install -r requirements.txt
39
+ pip install -e .
40
+
41
+ # 运行
42
+ python -m reachy_mini_ha_voice --name "ReachyMini"
43
+ ```
44
+
45
+ ## 连接到 Home Assistant
46
+
47
+ 1. 在 Home Assistant 中,进入 "设置" -> "设备与服务"
48
+ 2. 点击 "添加集成" 按钮
49
+ 3. 选择 "ESPHome" 然后选择 "设置另一个 ESPHome 实例"
50
+ 4. 输入语音助手的 IP 地址和端口 6053
51
+ 5. 点击 "提交"
52
+
53
+ ## 命令行选项
54
+
55
+ ```
56
+ python -m reachy_mini_ha_voice --help
57
+
58
+ 选项:
59
+ --name NAME 设备名称(必需)
60
+ --host HOST 服务器地址(默认: 0.0.0.0)
61
+ --port PORT 服务器端口(默认: 6053)
62
+ --audio-input-device DEVICE 音频输入设备
63
+ --list-input-devices 列出可用的音频输入设备
64
+ --audio-output-device DEVICE 音频输出设备
65
+ --list-output-devices 列出可用的音频输出设备
66
+ --wake-model MODEL 唤醒词模型(默认: okay_nabu)
67
+ --stop-model MODEL 停止词模型(默认: stop)
68
+ --refractory-seconds SECONDS 唤醒词冷却时间(默认: 2.0)
69
+ --debug 启用调试日志
70
+ ```
71
+
72
+ ## 包含的 Assets
73
+
74
+ 项目已经包含了所有必需的唤醒词模型和声音文件,无需额外下载:
75
+
76
+ ### 唤醒词模型(microWakeWord)
77
+
78
+ - `okay_nabu.tflite` - "Okay Nabu"(默认)
79
+ - `stop.tflite` - "Stop"
80
+ - `alexa.tflite` - "Alexa"
81
+ - `hey_jarvis.tflite` - "Hey Jarvis"
82
+ - `hey_home_assistant.tflite` - "Hey Home Assistant"
83
+ - `hey_luna.tflite` - "Hey Luna"
84
+ - `hey_mycroft.tflite` - "Hey Mycroft"
85
+ - `okay_computer.tflite` - "Okay Computer"
86
+ - `choo_choo_homie.tflite` - "Choo Choo Homie"
87
+
88
+ ### 唤醒词模型(openWakeWord)
89
+
90
+ 在 `wakewords/openWakeWord/` 目录中:
91
+ - `alexa_v0.1.tflite` - Alexa
92
+ - `hey_jarvis_v0.1.tflite` - Hey Jarvis
93
+ - `hey_mycroft_v0.1.tflite` - Hey Mycroft
94
+ - `hey_rhasspy_v0.1.tflite` - Hey Rhasspy
95
+ - `ok_nabu_v0.1.tflite` - Okay Nabu
96
+
97
+ ### 声音文件
98
+
99
+ - `wake_word_triggered.flac` - 唤醒词触发时播放
100
+ - `timer_finished.flac` - 定时器结束时播放
101
+
102
+ ## 音频设备配置
103
+
104
+ ### 查看可用设备
105
+
106
+ ```bash
107
+ # 列出音频输入设备
108
+ python -m reachy_mini_ha_voice --name Test --list-input-devices
109
+
110
+ # 列出音频输出设备
111
+ python -m reachy_mini_ha_voice --name Test --list-output-devices
112
+ ```
113
+
114
+ ### 指定音频设备
115
+
116
+ ```bash
117
+ python -m reachy_mini_ha_voice \
118
+ --name "ReachyMini" \
119
+ --audio-input-device "麦克风名称" \
120
+ --audio-output-device "扬声器名称"
121
+ ```
122
+
123
+ **注意**:麦克风设备必须支持 16KHz 单声道音频。
124
+
125
+ ## 唤醒词
126
+
127
+ ### 默认唤醒词
128
+
129
+ - `okay_nabu`(默认)
130
+
131
+ ### 使用其他唤醒词
132
+
133
+ 项目已经包含了多个唤醒词模型,你可以直接使用:
134
+
135
+ ```bash
136
+ # 使用 "Hey Jarvis"
137
+ python -m reachy_mini_ha_voice --name "ReachyMini" --wake-model hey_jarvis
138
+
139
+ # 使用 "Alexa"
140
+ python -m reachy_mini_ha_voice --name "ReachyMini" --wake-model alexa
141
+
142
+ # 使用 openWakeWord 版本的 "Hey Jarvis"
143
+ python -m reachy_mini_ha_voice --name "ReachyMini" \
144
+ --wake-model hey_jarvis_v0.1 \
145
+ --wake-word-dir wakewords/openWakeWord
146
+ ```
147
+
148
+ ### 添加自定义唤醒词
149
+
150
+ 如果你想添加其他唤醒词:
151
+
152
+ 更多唤醒词模型请访问:[home-assistant-wakewords-collection](https://github.com/fwartner/home-assistant-wakewords-collection)
153
+
154
+ ## Reachy Mini 集成
155
+
156
+ ### 动作反馈
157
+
158
+ 语音助手会根据不同状态触发 Reachy Mini 的动作:
159
+
160
+ - **唤醒时**:头部抬起,眼睛闪烁
161
+ - **监听中**:头部轻微摆动
162
+ - **响应中**:点头或摇头
163
+ - **错误时**:头部倾斜
164
+
165
+ ### 自定义动作
166
+
167
+ 编辑 `reachy_mini_ha_voice/reachy_integration.py` 来自定义 Reachy Mini 的动作反馈。
168
+
169
+ ## 故障排除
170
+
171
+ ### 音频设备问题
172
+
173
+ 如果无法检测��音频设备:
174
+
175
+ ```bash
176
+ # 检查 PulseAudio 服务
177
+ systemctl --user status pulseaudio
178
+
179
+ # 重新加载 PulseAudio
180
+ pulseaudio --kill
181
+ pulseaudio --start
182
+ ```
183
+
184
+ ### 回声消除
185
+
186
+ 启用 PulseAudio 的回声消除模块:
187
+
188
+ ```bash
189
+ pactl load-module module-echo-cancel \
190
+ aec_method=webrtc \
191
+ aec_args="analog_gain_control=0 digital_gain_control=1 noise_suppression=1"
192
+ ```
193
+
194
+ 查看设备:
195
+
196
+ ```bash
197
+ pactl list short sources
198
+ pactl list short sinks
199
+ ```
200
+
201
+ 使用回声消除设备:
202
+
203
+ ```bash
204
+ python -m reachy_mini_ha_voice \
205
+ --name "ReachyMini" \
206
+ --audio-input-device 'Echo-Cancel Source' \
207
+ --audio-output-device 'pipewire/echo-cancel-sink'
208
+ ```
209
+
210
+ ### Hugging Face Spaces 部署问题
211
+
212
+ 如果构建失败:
213
+
214
+ 1. 检查 Dockerfile 中的依赖是否正确
215
+ 2. 确保 requirements.txt 中的版本兼容
216
+ 3. 查看构建日志中的错误信息
217
+ 4. 确保没有使用需要系统特权的功能
218
+
219
+ ## 开发
220
+
221
+ ### 运行测试
222
+
223
+ ```bash
224
+ pip install -e ".[dev]"
225
+ pytest
226
+ ```
227
+
228
+ ### 代码格式化
229
+
230
+ ```bash
231
+ black reachy_mini_ha_voice/
232
+ flake8 reachy_mini_ha_voice/
233
+ ```
234
+
235
+ ## 许可证
236
+
237
+ Apache License 2.0
238
+
239
+ ## 致谢
240
+
241
+ 本项目基于 [OHF-Voice/linux-voice-assistant](https://github.com/OHF-Voice/linux-voice-assistant) 修改而来,适配 Reachy Mini 机器人和 Hugging Face Spaces 环境。
242
+
243
+ ## 相关链接
244
+
245
+ - [Home Assistant](https://www.home-assistant.io/)
246
+ - [ESPHome](https://esphome.io/)
247
+ - [Reachy Mini](https://github.com/pollen-robotics/reachy)
248
+ - [Hugging Face Spaces](https://huggingface.co/spaces)
README_ASSETS.md ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Assets Setup Guide
2
+
3
+ This document explains how to set up the required assets (wake word models and sound files).
4
+
5
+ ## Included Assets
6
+
7
+ All required wake word models and sound files are already included in the repository:
8
+
9
+ ### Wake Word Models (microWakeWord)
10
+
11
+ - `okay_nabu.tflite` - "Okay Nabu" (default)
12
+ - `stop.tflite` - "Stop"
13
+ - `alexa.tflite` - "Alexa"
14
+ - `hey_jarvis.tflite` - "Hey Jarvis"
15
+ - `hey_home_assistant.tflite` - "Hey Home Assistant"
16
+ - `hey_luna.tflite` - "Hey Luna"
17
+ - `hey_mycroft.tflite` - "Hey Mycroft"
18
+ - `okay_computer.tflite` - "Okay Computer"
19
+ - `choo_choo_homie.tflite` - "Choo Choo Homie"
20
+
21
+ ### Wake Word Models (openWakeWord)
22
+
23
+ Located in `wakewords/openWakeWord/`:
24
+ - `alexa_v0.1.tflite` - Alexa
25
+ - `hey_jarvis_v0.1.tflite` - Hey Jarvis
26
+ - `hey_mycroft_v0.1.tflite` - Hey Mycroft
27
+ - `hey_rhasspy_v0.1.tflite` - Hey Rhasspy
28
+ - `ok_nabu_v0.1.tflite` - Okay Nabu
29
+
30
+ ### Sound Files
31
+
32
+ - `wake_word_triggered.flac` - Played when wake word is detected
33
+ - `timer_finished.flac` - Played when timer finishes
34
+
35
+ ## Wake Word Models
36
+
37
+ The application uses two wake word engines:
38
+ - **microWakeWord**: Lightweight, good for embedded systems
39
+ - **openWakeWord**: More accurate, uses more resources
40
+
41
+ ### Adding Custom Wake Words
42
+
43
+ #### microWakeWord Models
44
+
45
+ 1. Download a model from [microWakeWord releases](https://github.com/kahrendt/microWakeWord/releases)
46
+ 2. Place the `.tflite` file in the `wakewords/` directory
47
+ 3. Create a corresponding `.json` file:
48
+
49
+ ```json
50
+ {
51
+ "type": "microWakeWord",
52
+ "wake_word": "Your Wake Word",
53
+ "trained_languages": ["en"]
54
+ }
55
+ ```
56
+
57
+ #### openWakeWord Models
58
+
59
+ 1. Download a model from [home-assistant-wakewords-collection](https://github.com/fwartner/home-assistant-wakewords-collection)
60
+ 2. Place the `.tflite` file in the `wakewords/` directory
61
+ 3. Create a corresponding `.json` file:
62
+
63
+ ```json
64
+ {
65
+ "type": "openWakeWord",
66
+ "wake_word": "Your Wake Word",
67
+ "model": "your_wake_word.tflite",
68
+ "trained_languages": ["en"]
69
+ }
70
+ ```
71
+
72
+ ### Popular Wake Words
73
+
74
+ Here are some popular wake words you can add:
75
+
76
+ - **Hey Jarvis**: [Download](https://github.com/fwartner/home-assistant-wakewords-collection/raw/main/en/hey_jarvis/hey_jarvis.tflite)
77
+ - **Alexa**: [Download](https://github.com/fwartner/home-assistant-wakewords-collection/raw/main/en/alexa/alexa.tflite)
78
+ - **Hey Google**: [Download](https://github.com/fwartner/home-assistant-wakewords-collection/raw/main/en/hey_google/hey_google.tflite)
79
+ - **GLaDOS**: [Download](https://github.com/fwartner/home-assistant-wakewords-collection/raw/main/en/glados/glados.tflite)
80
+
81
+ ## Sound Files
82
+
83
+ The application uses sound files for feedback:
84
+
85
+ ### Included Files
86
+
87
+ 1. **wake_word_triggered.flac** - Played when wake word is detected
88
+ 2. **timer_finished.flac** - Played when timer finishes
89
+
90
+ ### Customizing Sound Files
91
+
92
+ You can replace these files with your own:
93
+
94
+ 1. Keep them short (1-2 seconds)
95
+ 2. Use FLAC or WAV format
96
+ 3. Sample rate: 16kHz or 44.1kHz
97
+ 4. Mono or stereo
98
+
99
+ ### Example Using Online Tools
100
+
101
+ 1. Go to [TTSMP3](https://ttsmp3.com/)
102
+ 2. Enter text like "I'm listening" or "Timer finished"
103
+ 3. Generate and download as MP3
104
+ 4. Convert to FLAC using [Online Audio Converter](https://online-audio-converter.com/)
105
+ 5. Replace the file in `sounds/` directory
106
+
107
+ ## Directory Structure
108
+
109
+ After setup, your directory should look like:
110
+
111
+ ```
112
+ reachy_mini_ha_voice/
113
+ ├── wakewords/
114
+ │ ├── okay_nabu.json
115
+ │ ├── okay_nabu.tflite # Downloaded
116
+ │ ├── stop.json
117
+ │ ├── stop.tflite # Downloaded
118
+ │ ├── hey_jarvis.json # Optional
119
+ │ └── hey_jarvis.tflite # Optional
120
+ └── sounds/
121
+ ├── wake_word_triggered.flac # You provide
122
+ └── timer_finished.flac # You provide
123
+ ```
124
+
125
+ ## Troubleshooting
126
+
127
+ ### Wake Word Not Detected
128
+
129
+ 1. Check that the `.tflite` file exists
130
+ 2. Verify the `.json` configuration is correct
131
+ 3. Try a different wake word
132
+ 4. Check microphone input volume
133
+
134
+ ### Sound Not Playing
135
+
136
+ 1. Verify the sound file exists and is not empty
137
+ 2. Check audio output device is configured
138
+ 3. Try playing the file manually: `aplay sounds/wake_word_triggered.flac`
139
+
140
+ ### Model Loading Errors
141
+
142
+ 1. Ensure the model is compatible with your architecture
143
+ 2. Check that TensorFlow Lite is installed correctly
144
+ 3. Verify the model file is not corrupted
145
+
146
+ ## Additional Resources
147
+
148
+ - [microWakeWord GitHub](https://github.com/kahrendt/microWakeWord)
149
+ - [openWakeWord GitHub](https://github.com/dscripka/openWakeWord)
150
+ - [Home Assistant Wake Words Collection](https://github.com/fwartner/home-assistant-wakewords-collection)
151
+ - [ESPHome Voice Assistant](https://esphome.io/components/voice_assistant.html)
README_HF_SPACES.md ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Reachy Mini Home Assistant Voice Assistant - Hugging Face Spaces
2
+
3
+ 这是一个可以通过 Hugging Face Spaces 一键安装的 Reachy Mini 语音助手应用,用于连接 Home Assistant。
4
+
5
+ ## 🚀 通过 Hugging Face Spaces 安装
6
+
7
+ 1. 访问你的 Reachy Mini 仪表板
8
+ 2. 进入 "Applications" -> "Install from Hugging Face"
9
+ 3. 搜索 "reachy-mini-ha-voice"
10
+ 4. 点击 "Install" 按钮
11
+ 5. 等待安装完成
12
+
13
+ 安装完成后,应用程序将出现在 "Applications" 列表中。
14
+
15
+ ## ⚙️ 配置
16
+
17
+ 安装后,点击应用设置图标进行配置:
18
+
19
+ - **Device Name**: 设备名称(默认: ReachyMini)
20
+ - **Enable Reachy**: 启用 Reachy Mini 机器人集成(默认: true)
21
+ - **Audio Input Device**: 音频输入设备索引
22
+ - **Audio Output Device**: 音频输出设备索引
23
+ - **Wake Word**: 唤醒词(默认: okay_nabu)
24
+
25
+ ## 🔌 连接到 Home Assistant
26
+
27
+ 1. 在 Home Assistant 中,进入 "设置" -> "设备与服务"
28
+ 2. 点击 "添加集成" 按钮
29
+ 3. 选择 "ESPHome" 然后选择 "设置另一个 ESPHome 实例"
30
+ 4. 输入 Reachy Mini 的 IP 地址和端口 6053
31
+ 5. 点击 "提交"
32
+
33
+ ## 📝 使用说明
34
+
35
+ ### 启动应用
36
+
37
+ 在 Reachy Mini 仪表板中:
38
+ 1. 找到 "reachy-mini-ha-voice" 应用
39
+ 2. 点击 "Run" 按钮启动
40
+ 3. 应用将在端口 6053 上运行
41
+
42
+ ### 唤醒词
43
+
44
+ 默认唤醒词是 "Okay Nabu"。你可以说:
45
+ - "Okay Nabu, turn on the lights"
46
+ - "Okay Nabu, what's the weather?"
47
+ - "Okay Nabu, set a timer for 5 minutes"
48
+
49
+ ### Reachy Mini 动作
50
+
51
+ 当启用 Reachy Mini 集成时,机器人会对不同的语音状态做出反应:
52
+ - **唤醒时**: 头部抬起
53
+ - **监听中**: 头部轻微摆动
54
+ - **响应中**: 点头
55
+ - **停止时**: 摇头
56
+
57
+ ## 🔧 故障排除
58
+
59
+ ### 音频设备问题
60
+
61
+ 如果无法检测到音频设备:
62
+
63
+ 1. 在 Reachy Mini 终端中运行:
64
+ ```bash
65
+ python -m reachy_mini_ha_voice --list-input-devices
66
+ python -m reachy_mini_ha_voice --list-output-devices
67
+ ```
68
+
69
+ 2. 在应用配置中设置正确的设备索引
70
+
71
+ ### 连接问题
72
+
73
+ 如果无法连接到 Home Assistant:
74
+
75
+ 1. 检查 Reachy Mini 和 Home Assistant 是否在同一网络
76
+ 2. 确认端口 6053 未被防火墙阻止
77
+ 3. 查看 Home Assistant 日志中的连接错误
78
+
79
+ ### 调试模式
80
+
81
+ 启用调试日志:
82
+
83
+ 在应用配置中添加环境变量:
84
+ ```
85
+ DEBUG=true
86
+ ```
87
+
88
+ ## 📚 更多信息
89
+
90
+ 完整文档请访问: [README.md](README.md)
91
+
92
+ ## 🤝 贡献
93
+
94
+ 欢迎提交 Issue 和 Pull Request!
95
+
96
+ ## 📄 许可证
97
+
98
+ Apache License 2.0
app.yaml ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Reachy Mini App Configuration
2
+ # This file is used by the Reachy Mini app assistant
3
+
4
+ name: reachy-mini-ha-voice
5
+ version: 1.0.0
6
+ description: Home Assistant Voice Assistant for Reachy Mini using ESPHome protocol
7
+ author: Reachy Mini HA Voice Contributors
8
+ license: Apache-2.0
9
+
10
+ # App type: python, javascript, or web
11
+ type: python
12
+
13
+ # Main entry point
14
+ entry_point: reachy_mini_ha_voice.__main__:main
15
+
16
+ # Dependencies
17
+ dependencies:
18
+ - aioesphomeapi==42.7.0
19
+ - numpy>=2,<3
20
+ - pymicro-wakeword>=2,<3
21
+ - pyopen-wakeword>=1,<2
22
+ - pyaudio>=0.2.11
23
+ - zeroconf<1
24
+
25
+ # Optional dependencies for Reachy Mini integration
26
+ optional_dependencies:
27
+ - reachy-sdk
28
+
29
+ # System requirements
30
+ system_requirements:
31
+ - portaudio19-dev
32
+ - build-essential
33
+ - libportaudio2
34
+
35
+ # Configuration options
36
+ config:
37
+ - name: device_name
38
+ type: string
39
+ default: "ReachyMini"
40
+ description: "Device name for Home Assistant"
41
+ - name: enable_reachy
42
+ type: boolean
43
+ default: true
44
+ description: "Enable Reachy Mini robot integration"
45
+ - name: audio_input_device
46
+ type: integer
47
+ default: null
48
+ description: "Audio input device index"
49
+ - name: audio_output_device
50
+ type: integer
51
+ default: null
52
+ description: "Audio output device index"
53
+ - name: wake_word
54
+ type: string
55
+ default: "okay_nabu"
56
+ description: "Wake word to use"
57
+
58
+ # Ports exposed by the app
59
+ ports:
60
+ - 6053
61
+
62
+ # Tags for categorization
63
+ tags:
64
+ - voice
65
+ - home-assistant
66
+ - esphome
67
+ - assistant
pyproject.toml ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=62.3"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "reachy-mini-ha-voice"
7
+ version = "1.0.0"
8
+ license = {text = "Apache-2.0"}
9
+ description = "Reachy Mini voice assistant for Home Assistant using ESPHome protocol"
10
+ readme = "README.md"
11
+ authors = [
12
+ {name = "Reachy Mini HA Voice Contributors", email = "hello@reachy-mini.org"}
13
+ ]
14
+ keywords = ["home", "assistant", "voice", "esphome", "reachy", "robot"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
19
+ "License :: OSI Approved :: Apache Software License",
20
+ "Programming Language :: Python :: 3.9",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ ]
24
+ requires-python = ">=3.9.0"
25
+ dependencies = [
26
+ "aioesphomeapi==42.7.0",
27
+ "numpy>=2,<3",
28
+ "pymicro-wakeword>=2,<3",
29
+ "pyopen-wakeword>=1,<2",
30
+ "pyaudio>=0.2.11",
31
+ "zeroconf<1",
32
+ ]
33
+
34
+ [project.optional-dependencies]
35
+ dev = [
36
+ "black",
37
+ "flake8",
38
+ "mypy",
39
+ "pylint",
40
+ "pytest",
41
+ ]
42
+
43
+ [project.urls]
44
+ "Source Code" = "https://github.com/your-username/reachy_mini_ha_voice"
45
+
46
+ [tool.setuptools]
47
+ platforms = ["any"]
48
+ zip-safe = true
49
+ include-package-data = true
50
+
51
+ [tool.setuptools.packages.find]
52
+ include = ["reachy_mini_ha_voice"]
53
+ exclude = ["tests", "tests.*"]
reachy_mini_ha_voice/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """Reachy Mini Home Assistant Voice Assistant."""
2
+
3
+ __version__ = "1.0.0"
reachy_mini_ha_voice/__main__.py ADDED
@@ -0,0 +1,449 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Main entry point for Reachy Mini Home Assistant Voice Assistant."""
3
+
4
+ import argparse
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import sys
9
+ import threading
10
+ import time
11
+ from pathlib import Path
12
+ from queue import Queue
13
+ from typing import Dict, List, Optional, Set, Union
14
+
15
+ import numpy as np
16
+ import pyaudio
17
+ from pymicro_wakeword import MicroWakeWord, MicroWakeWordFeatures
18
+ from pyopen_wakeword import OpenWakeWord, OpenWakeWordFeatures
19
+
20
+ from .models import AvailableWakeWord, Preferences, ServerState, WakeWordType, AudioPlayer
21
+ from .satellite import VoiceSatelliteProtocol
22
+ from .util import get_mac
23
+ from .zeroconf import HomeAssistantZeroconf
24
+ from .reachy_integration import ReachyMiniIntegration
25
+
26
+ _LOGGER = logging.getLogger(__name__)
27
+ _MODULE_DIR = Path(__file__).parent
28
+ _REPO_DIR = _MODULE_DIR.parent
29
+ _WAKEWORDS_DIR = _REPO_DIR / "wakewords"
30
+ _SOUNDS_DIR = _REPO_DIR / "sounds"
31
+
32
+
33
+ async def main() -> None:
34
+ """Main entry point."""
35
+ parser = argparse.ArgumentParser(
36
+ description="Reachy Mini Voice Assistant for Home Assistant"
37
+ )
38
+ parser.add_argument("--name", required=True, help="Device name")
39
+ parser.add_argument(
40
+ "--audio-input-device",
41
+ type=int,
42
+ help="Audio input device index (see --list-input-devices)",
43
+ )
44
+ parser.add_argument(
45
+ "--list-input-devices",
46
+ action="store_true",
47
+ help="List audio input devices and exit",
48
+ )
49
+ parser.add_argument(
50
+ "--audio-output-device",
51
+ type=int,
52
+ help="Audio output device index (see --list-output-devices)",
53
+ )
54
+ parser.add_argument(
55
+ "--list-output-devices",
56
+ action="store_true",
57
+ help="List audio output devices and exit",
58
+ )
59
+ parser.add_argument(
60
+ "--wake-word-dir",
61
+ default=[_WAKEWORDS_DIR],
62
+ action="append",
63
+ help="Directory with wake word models (.tflite) and configs (.json)",
64
+ )
65
+ parser.add_argument(
66
+ "--wake-model", default="okay_nabu", help="Id of active wake model"
67
+ )
68
+ parser.add_argument("--stop-model", default="stop", help="Id of stop model")
69
+ parser.add_argument(
70
+ "--download-dir",
71
+ default=_REPO_DIR / "local",
72
+ help="Directory to download custom wake word models, etc.",
73
+ )
74
+ parser.add_argument(
75
+ "--refractory-seconds",
76
+ default=2.0,
77
+ type=float,
78
+ help="Seconds before wake word can be activated again",
79
+ )
80
+ parser.add_argument(
81
+ "--wakeup-sound", default=str(_SOUNDS_DIR / "wake_word_triggered.flac")
82
+ )
83
+ parser.add_argument(
84
+ "--timer-finished-sound", default=str(_SOUNDS_DIR / "timer_finished.flac")
85
+ )
86
+ parser.add_argument("--preferences-file", default=_REPO_DIR / "preferences.json")
87
+ parser.add_argument(
88
+ "--host",
89
+ default="0.0.0.0",
90
+ help="Address for ESPHome server (default: 0.0.0.0)",
91
+ )
92
+ parser.add_argument(
93
+ "--port", type=int, default=6053, help="Port for ESPHome server (default: 6053)"
94
+ )
95
+ parser.add_argument(
96
+ "--debug", action="store_true", help="Print DEBUG messages to console"
97
+ )
98
+ parser.add_argument(
99
+ "--enable-reachy",
100
+ action="store_true",
101
+ help="Enable Reachy Mini integration",
102
+ )
103
+ args = parser.parse_args()
104
+
105
+ # List devices and exit
106
+ if args.list_input_devices:
107
+ p = pyaudio.PyAudio()
108
+ print("Input devices")
109
+ print("=" * 13)
110
+ for i in range(p.get_device_count()):
111
+ info = p.get_device_info_by_index(i)
112
+ if info["maxInputChannels"] > 0:
113
+ print(f"[{i}] {info['name']}")
114
+ p.terminate()
115
+ return
116
+
117
+ if args.list_output_devices:
118
+ p = pyaudio.PyAudio()
119
+ print("Output devices")
120
+ print("=" * 14)
121
+ for i in range(p.get_device_count()):
122
+ info = p.get_device_info_by_index(i)
123
+ if info["maxOutputChannels"] > 0:
124
+ print(f"[{i}] {info['name']}")
125
+ p.terminate()
126
+ return
127
+
128
+ # Setup logging
129
+ logging.basicConfig(
130
+ level=logging.DEBUG if args.debug else logging.INFO,
131
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
132
+ )
133
+ _LOGGER.debug(args)
134
+
135
+ # Create directories
136
+ args.download_dir = Path(args.download_dir)
137
+ args.download_dir.mkdir(parents=True, exist_ok=True)
138
+
139
+ # Initialize Reachy Mini integration
140
+ reachy_integration = ReachyMiniIntegration()
141
+ if args.enable_reachy:
142
+ reachy_integration.connect()
143
+ else:
144
+ _LOGGER.info("Reachy Mini integration disabled")
145
+
146
+ # Load available wake words
147
+ wake_word_dirs = [Path(ww_dir) for ww_dir in args.wake_word_dir]
148
+ wake_word_dirs.append(args.download_dir / "external_wake_words")
149
+ available_wake_words: Dict[str, AvailableWakeWord] = {}
150
+
151
+ for wake_word_dir in wake_word_dirs:
152
+ if not wake_word_dir.exists():
153
+ continue
154
+
155
+ for model_config_path in wake_word_dir.glob("*.json"):
156
+ model_id = model_config_path.stem
157
+ if model_id == args.stop_model:
158
+ continue
159
+
160
+ try:
161
+ with open(model_config_path, "r", encoding="utf-8") as model_config_file:
162
+ model_config = json.load(model_config_file)
163
+ model_type = WakeWordType(model_config.get("type", "microWakeWord"))
164
+ if model_type == WakeWordType.OPEN_WAKE_WORD:
165
+ wake_word_path = model_config_path.parent / model_config["model"]
166
+ else:
167
+ wake_word_path = model_config_path
168
+
169
+ available_wake_words[model_id] = AvailableWakeWord(
170
+ id=model_id,
171
+ type=WakeWordType(model_type),
172
+ wake_word=model_config["wake_word"],
173
+ trained_languages=model_config.get("trained_languages", []),
174
+ wake_word_path=wake_word_path,
175
+ )
176
+ except Exception as e:
177
+ _LOGGER.error("Error loading wake word config %s: %s", model_config_path, e)
178
+
179
+ _LOGGER.debug("Available wake words: %s", list(sorted(available_wake_words.keys())))
180
+
181
+ # Load preferences
182
+ preferences_path = Path(args.preferences_file)
183
+ if preferences_path.exists():
184
+ try:
185
+ with open(preferences_path, "r", encoding="utf-8") as preferences_file:
186
+ preferences_dict = json.load(preferences_file)
187
+ preferences = Preferences(**preferences_dict)
188
+ except Exception as e:
189
+ _LOGGER.error("Error loading preferences: %s", e)
190
+ preferences = Preferences()
191
+ else:
192
+ preferences = Preferences()
193
+
194
+ # Load wake/stop models
195
+ active_wake_words: Set[str] = set()
196
+ wake_models: Dict[str, Union[MicroWakeWord, OpenWakeWord]] = {}
197
+
198
+ if preferences.active_wake_words:
199
+ for wake_word_id in preferences.active_wake_words:
200
+ wake_word = available_wake_words.get(wake_word_id)
201
+ if wake_word is None:
202
+ _LOGGER.warning("Unrecognized wake word id: %s", wake_word_id)
203
+ continue
204
+
205
+ try:
206
+ _LOGGER.debug("Loading wake model: %s", wake_word_id)
207
+ wake_models[wake_word_id] = wake_word.load()
208
+ active_wake_words.add(wake_word_id)
209
+ except Exception as e:
210
+ _LOGGER.error("Error loading wake model %s: %s", wake_word_id, e)
211
+
212
+ if not wake_models:
213
+ wake_word_id = args.wake_model
214
+ if wake_word_id in available_wake_words:
215
+ try:
216
+ wake_word = available_wake_words[wake_word_id]
217
+ _LOGGER.debug("Loading wake model: %s", wake_word_id)
218
+ wake_models[wake_word_id] = wake_word.load()
219
+ active_wake_words.add(wake_word_id)
220
+ except Exception as e:
221
+ _LOGGER.error("Error loading default wake model: %s", e)
222
+ else:
223
+ _LOGGER.error("Default wake word not found: %s", wake_word_id)
224
+
225
+ # Load stop model
226
+ stop_model: Optional[MicroWakeWord] = None
227
+ for wake_word_dir in wake_word_dirs:
228
+ stop_config_path = wake_word_dir / f"{args.stop_model}.json"
229
+ if not stop_config_path.exists():
230
+ continue
231
+
232
+ try:
233
+ _LOGGER.debug("Loading stop model: %s", stop_config_path)
234
+ stop_model = MicroWakeWord.from_config(stop_config_path)
235
+ break
236
+ except Exception as e:
237
+ _LOGGER.error("Error loading stop model: %s", e)
238
+
239
+ if stop_model is None:
240
+ _LOGGER.warning("Stop model not loaded")
241
+
242
+ # Create audio players
243
+ music_player = AudioPlayer(device=args.audio_output_device)
244
+ tts_player = AudioPlayer(device=args.audio_output_device)
245
+
246
+ # Create server state
247
+ state = ServerState(
248
+ name=args.name,
249
+ mac_address=get_mac(),
250
+ audio_queue=Queue(),
251
+ entities=[],
252
+ available_wake_words=available_wake_words,
253
+ wake_words=wake_models,
254
+ active_wake_words=active_wake_words,
255
+ stop_word=stop_model,
256
+ music_player=music_player,
257
+ tts_player=tts_player,
258
+ wakeup_sound=args.wakeup_sound,
259
+ timer_finished_sound=args.timer_finished_sound,
260
+ preferences=preferences,
261
+ preferences_path=preferences_path,
262
+ refractory_seconds=args.refractory_seconds,
263
+ download_dir=args.download_dir,
264
+ reachy_integration=reachy_integration,
265
+ )
266
+
267
+ # Start audio processing thread
268
+ process_audio_thread = threading.Thread(
269
+ target=process_audio,
270
+ args=(state, args.audio_input_device),
271
+ daemon=True,
272
+ )
273
+ process_audio_thread.start()
274
+
275
+ # Start ESPHome server
276
+ loop = asyncio.get_running_loop()
277
+ server = await loop.create_server(
278
+ lambda: VoiceSatelliteProtocol(state), host=args.host, port=args.port
279
+ )
280
+
281
+ # Auto discovery (zeroconf, mDNS)
282
+ discovery = HomeAssistantZeroconf(port=args.port, name=args.name)
283
+ await discovery.register_server()
284
+
285
+ try:
286
+ async with server:
287
+ _LOGGER.info("Server started (host=%s, port=%s)", args.host, args.port)
288
+ if reachy_integration.is_connected():
289
+ _LOGGER.info("Reachy Mini integration enabled")
290
+ await server.serve_forever()
291
+ except KeyboardInterrupt:
292
+ _LOGGER.info("Shutting down...")
293
+ finally:
294
+ state.audio_queue.put_nowait(None)
295
+ process_audio_thread.join(timeout=5)
296
+ if reachy_integration.is_connected():
297
+ reachy_integration.disconnect()
298
+ music_player.close()
299
+ tts_player.close()
300
+ await discovery.unregister_server()
301
+
302
+ _LOGGER.debug("Server stopped")
303
+
304
+
305
+ def process_audio(state: ServerState, input_device: Optional[int]) -> None:
306
+ """Process audio chunks from the microphone."""
307
+ import pyaudio
308
+
309
+ p = pyaudio.PyAudio()
310
+
311
+ # Get input device
312
+ if input_device is not None:
313
+ device_index = input_device
314
+ else:
315
+ # Try to find default input device
316
+ device_index = None
317
+ for i in range(p.get_device_count()):
318
+ info = p.get_device_info_by_index(i)
319
+ if info["maxInputChannels"] > 0 and info["isDefaultInputDevice"]:
320
+ device_index = i
321
+ break
322
+
323
+ if device_index is None:
324
+ _LOGGER.error("No default input device found")
325
+ return
326
+
327
+ device_info = p.get_device_info_by_index(device_index)
328
+ _LOGGER.info(
329
+ "Using audio input device: %s (index: %s)", device_info["name"], device_index
330
+ )
331
+
332
+ # Audio parameters
333
+ CHUNK = 1024
334
+ FORMAT = pyaudio.paInt16
335
+ CHANNELS = 1
336
+ RATE = 16000
337
+
338
+ try:
339
+ stream = p.open(
340
+ format=FORMAT,
341
+ channels=CHANNELS,
342
+ rate=RATE,
343
+ input=True,
344
+ input_device_index=device_index,
345
+ frames_per_buffer=CHUNK,
346
+ )
347
+
348
+ wake_words: List[Union[MicroWakeWord, OpenWakeWord]] = []
349
+ micro_features: Optional[MicroWakeWordFeatures] = None
350
+ micro_inputs: List[np.ndarray] = []
351
+
352
+ oww_features: Optional[OpenWakeWordFeatures] = None
353
+ oww_inputs: List[np.ndarray] = []
354
+ has_oww = False
355
+
356
+ last_active: Optional[float] = None
357
+
358
+ _LOGGER.info("Audio processing started")
359
+
360
+ while True:
361
+ try:
362
+ # Read audio chunk
363
+ data = stream.read(CHUNK, exception_on_overflow=False)
364
+ audio_array = np.frombuffer(data, dtype=np.int16).astype(np.float32) / 32768.0
365
+
366
+ # Send to satellite if connected
367
+ if state.satellite is not None:
368
+ state.satellite.handle_audio(data)
369
+
370
+ # Update wake word models
371
+ if (not wake_words) or (state.wake_words_changed and state.wake_words):
372
+ state.wake_words_changed = False
373
+ wake_words = [
374
+ ww
375
+ for ww in state.wake_words.values()
376
+ if ww.id in state.active_wake_words
377
+ ]
378
+
379
+ has_oww = False
380
+ for wake_word in wake_words:
381
+ if isinstance(wake_word, OpenWakeWord):
382
+ has_oww = True
383
+
384
+ if micro_features is None:
385
+ micro_features = MicroWakeWordFeatures()
386
+
387
+ if has_oww and (oww_features is None):
388
+ oww_features = OpenWakeWordFeatures.from_builtin()
389
+
390
+ # Process wake words
391
+ if wake_words:
392
+ assert micro_features is not None
393
+ micro_inputs.clear()
394
+ micro_inputs.extend(micro_features.process_streaming(data))
395
+
396
+ if has_oww:
397
+ assert oww_features is not None
398
+ oww_inputs.clear()
399
+ oww_inputs.extend(oww_features.process_streaming(data))
400
+
401
+ for wake_word in wake_words:
402
+ activated = False
403
+ if isinstance(wake_word, MicroWakeWord):
404
+ for micro_input in micro_inputs:
405
+ if wake_word.process_streaming(micro_input):
406
+ activated = True
407
+ elif isinstance(wake_word, OpenWakeWord):
408
+ for oww_input in oww_inputs:
409
+ for prob in wake_word.process_streaming(oww_input):
410
+ if prob > 0.5:
411
+ activated = True
412
+
413
+ if activated:
414
+ now = time.monotonic()
415
+ if (last_active is None) or (
416
+ (now - last_active) > state.refractory_seconds
417
+ ):
418
+ state.satellite.wakeup(wake_word)
419
+ if state.reachy_integration.is_connected():
420
+ state.reachy_integration.on_wake_word_detected()
421
+ last_active = now
422
+
423
+ # Process stop word
424
+ if state.stop_word is not None:
425
+ stopped = False
426
+ for micro_input in micro_inputs:
427
+ if state.stop_word.process_streaming(micro_input):
428
+ stopped = True
429
+
430
+ if stopped and (state.stop_word.id in state.active_wake_words):
431
+ state.satellite.stop()
432
+ if state.reachy_integration.is_connected():
433
+ state.reachy_integration.on_stop()
434
+
435
+ except Exception as e:
436
+ _LOGGER.error("Error processing audio: %s", e)
437
+ time.sleep(0.1)
438
+
439
+ except Exception as e:
440
+ _LOGGER.error("Error opening audio stream: %s", e)
441
+ finally:
442
+ stream.stop_stream()
443
+ stream.close()
444
+ p.terminate()
445
+ _LOGGER.info("Audio processing stopped")
446
+
447
+
448
+ if __name__ == "__main__":
449
+ asyncio.run(main())
reachy_mini_ha_voice/api_server.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """API server for Home Assistant integration."""
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ from typing import Dict, List, Optional
7
+
8
+ from .models import ServerState
9
+
10
+ _LOGGER = logging.getLogger(__name__)
11
+
12
+
13
+ class APIServer:
14
+ """API server for Home Assistant."""
15
+
16
+ def __init__(self, state: ServerState):
17
+ """Initialize API server."""
18
+ self.state = state
19
+ self._handlers: Dict[str, callable] = {
20
+ "hello": self._handle_hello,
21
+ "list_entities": self._handle_list_entities,
22
+ "get_state": self._handle_get_state,
23
+ "subscribe_states": self._handle_subscribe_states,
24
+ }
25
+
26
+ async def handle_request(self, command: str, payload: dict) -> dict:
27
+ """Handle an API request."""
28
+ handler = self._handlers.get(command)
29
+ if handler:
30
+ try:
31
+ return await handler(payload)
32
+ except Exception as e:
33
+ _LOGGER.error("Error handling request %s: %s", command, e)
34
+ return {"error": str(e)}
35
+ else:
36
+ return {"error": f"Unknown command: {command}"}
37
+
38
+ async def _handle_hello(self, payload: dict) -> dict:
39
+ """Handle hello request."""
40
+ return {
41
+ "name": self.state.name,
42
+ "mac_address": self.state.mac_address,
43
+ "version": "1.0.0",
44
+ }
45
+
46
+ async def _handle_list_entities(self, payload: dict) -> dict:
47
+ """Handle list_entities request."""
48
+ entities = []
49
+ for entity in self.state.entities:
50
+ entities.append(
51
+ {
52
+ "key": entity.key,
53
+ "name": entity.name,
54
+ "state": entity.state,
55
+ "attributes": entity.attributes,
56
+ }
57
+ )
58
+ return {"entities": entities}
59
+
60
+ async def _handle_get_state(self, payload: dict) -> dict:
61
+ """Handle get_state request."""
62
+ key = payload.get("key")
63
+ entity = next((e for e in self.state.entities if e.key == key), None)
64
+ if entity:
65
+ return {
66
+ "key": entity.key,
67
+ "state": entity.state,
68
+ "attributes": entity.attributes,
69
+ }
70
+ return {"error": "Entity not found"}
71
+
72
+ async def _handle_subscribe_states(self, payload: dict) -> dict:
73
+ """Handle subscribe_states request."""
74
+ return {"result": "ok"}
reachy_mini_ha_voice/entity.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Entity management for Home Assistant."""
2
+
3
+ import logging
4
+ from typing import Dict, List
5
+
6
+ from .models import Entity
7
+
8
+ _LOGGER = logging.getLogger(__name__)
9
+
10
+
11
+ class EntityManager:
12
+ """Manage Home Assistant entities."""
13
+
14
+ def __init__(self):
15
+ """Initialize entity manager."""
16
+ self._entities: Dict[str, Entity] = {}
17
+
18
+ def add_entity(self, entity: Entity) -> None:
19
+ """Add an entity."""
20
+ self._entities[entity.key] = entity
21
+ _LOGGER.debug("Added entity: %s", entity.key)
22
+
23
+ def update_entity(self, key: str, state: str, attributes: Dict[str, str]) -> None:
24
+ """Update an entity."""
25
+ if key in self._entities:
26
+ self._entities[key].state = state
27
+ self._entities[key].attributes.update(attributes)
28
+ _LOGGER.debug("Updated entity: %s", key)
29
+
30
+ def get_entity(self, key: str) -> Entity:
31
+ """Get an entity by key."""
32
+ return self._entities.get(key)
33
+
34
+ def list_entities(self) -> List[Entity]:
35
+ """List all entities."""
36
+ return list(self._entities.values())
reachy_mini_ha_voice/models.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Data models for the voice assistant."""
2
+
3
+ import logging
4
+ from dataclasses import dataclass, field
5
+ from enum import Enum
6
+ from pathlib import Path
7
+ from queue import Queue
8
+ from typing import Dict, List, Optional, Set, Union
9
+
10
+ import numpy as np
11
+
12
+ _LOGGER = logging.getLogger(__name__)
13
+
14
+
15
+ class WakeWordType(str, Enum):
16
+ """Type of wake word model."""
17
+
18
+ MICRO_WAKE_WORD = "microWakeWord"
19
+ OPEN_WAKE_WORD = "openWakeWord"
20
+
21
+
22
+ @dataclass
23
+ class AvailableWakeWord:
24
+ """Information about an available wake word model."""
25
+
26
+ id: str
27
+ type: WakeWordType
28
+ wake_word: str
29
+ trained_languages: List[str] = field(default_factory=list)
30
+ wake_word_path: Optional[Path] = None
31
+
32
+ def load(self):
33
+ """Load the wake word model."""
34
+ if self.type == WakeWordType.MICRO_WAKE_WORD:
35
+ from pymicro_wakeword import MicroWakeWord
36
+
37
+ return MicroWakeWord.from_config(self.wake_word_path)
38
+ elif self.type == WakeWordType.OPEN_WAKE_WORD:
39
+ from pyopen_wakeword import OpenWakeWord
40
+
41
+ return OpenWakeWord.from_config(self.wake_word_path)
42
+ else:
43
+ raise ValueError(f"Unknown wake word type: {self.type}")
44
+
45
+
46
+ @dataclass
47
+ class Preferences:
48
+ """User preferences."""
49
+
50
+ active_wake_words: List[str] = field(default_factory=list)
51
+
52
+
53
+ @dataclass
54
+ class ServerState:
55
+ """Shared server state."""
56
+
57
+ name: str
58
+ mac_address: str
59
+ audio_queue: Queue
60
+ entities: List["Entity"]
61
+ available_wake_words: Dict[str, AvailableWakeWord]
62
+ wake_words: Dict[str, Union["MicroWakeWord", "OpenWakeWord"]]
63
+ active_wake_words: Set[str]
64
+ stop_word: "MicroWakeWord"
65
+ music_player: "AudioPlayer"
66
+ tts_player: "AudioPlayer"
67
+ wakeup_sound: str
68
+ timer_finished_sound: str
69
+ preferences: Preferences
70
+ preferences_path: Path
71
+ refractory_seconds: float
72
+ download_dir: Path
73
+ satellite: Optional["VoiceSatelliteProtocol"] = None
74
+ wake_words_changed: bool = True
75
+ reachy_integration: Optional["ReachyMiniIntegration"] = None
76
+
77
+
78
+ @dataclass
79
+ class Entity:
80
+ """A Home Assistant entity."""
81
+
82
+ key: str
83
+ name: str
84
+ state: str
85
+ attributes: Dict[str, str] = field(default_factory=dict)
86
+
87
+
88
+ class AudioPlayer:
89
+ """Simple audio player using PyAudio."""
90
+
91
+ def __init__(self, device: Optional[int] = None):
92
+ """Initialize audio player."""
93
+ self.device = device
94
+ self._stream = None
95
+ self._pyaudio = None
96
+
97
+ def play(self, audio_data: bytes) -> None:
98
+ """Play audio data."""
99
+ import pyaudio
100
+
101
+ if self._pyaudio is None:
102
+ self._pyaudio = pyaudio.PyAudio()
103
+
104
+ # Assume 16-bit PCM, 16kHz, mono
105
+ if self._stream is None:
106
+ self._stream = self._pyaudio.open(
107
+ format=pyaudio.paInt16,
108
+ channels=1,
109
+ rate=16000,
110
+ output=True,
111
+ output_device_index=self.device,
112
+ )
113
+
114
+ self._stream.write(audio_data)
115
+
116
+ def close(self) -> None:
117
+ """Close the audio player."""
118
+ if self._stream is not None:
119
+ self._stream.close()
120
+ self._stream = None
121
+ if self._pyaudio is not None:
122
+ self._pyaudio.terminate()
123
+ self._pyaudio = None
reachy_mini_ha_voice/reachy_integration.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Reachy Mini integration module."""
2
+
3
+ import logging
4
+ from typing import Optional
5
+
6
+ _LOGGER = logging.getLogger(__name__)
7
+
8
+
9
+ class ReachyMiniIntegration:
10
+ """Integration with Reachy Mini robot."""
11
+
12
+ def __init__(self):
13
+ """Initialize Reachy Mini integration."""
14
+ self._reachy = None
15
+ self._connected = False
16
+
17
+ def connect(self) -> bool:
18
+ """Connect to Reachy Mini."""
19
+ try:
20
+ # Import reachy-mini SDK
21
+ # This will be installed when running on Reachy Mini
22
+ from reachy_sdk import ReachySDK
23
+
24
+ self._reachy = ReachySDK()
25
+ self._connected = True
26
+ _LOGGER.info("Connected to Reachy Mini")
27
+ return True
28
+ except ImportError:
29
+ _LOGGER.warning("Reachy SDK not available, running in standalone mode")
30
+ return False
31
+ except Exception as e:
32
+ _LOGGER.error("Failed to connect to Reachy Mini: %s", e)
33
+ return False
34
+
35
+ def disconnect(self) -> None:
36
+ """Disconnect from Reachy Mini."""
37
+ if self._reachy:
38
+ try:
39
+ self._reachy.disconnect()
40
+ self._connected = False
41
+ _LOGGER.info("Disconnected from Reachy Mini")
42
+ except Exception as e:
43
+ _LOGGER.error("Error disconnecting from Reachy Mini: %s", e)
44
+
45
+ def on_wake_word_detected(self) -> None:
46
+ """Handle wake word detection."""
47
+ if not self._connected:
48
+ return
49
+
50
+ try:
51
+ # Make Reachy Mini look up
52
+ self._reachy.head.tilt.goto(20, duration=0.5, wait=True)
53
+ self._reachy.head.pan.goto(0, duration=0.5, wait=True)
54
+ _LOGGER.debug("Reachy Mini: Look up on wake word")
55
+ except Exception as e:
56
+ _LOGGER.error("Error moving Reachy Mini on wake word: %s", e)
57
+
58
+ def on_listening(self) -> None:
59
+ """Handle listening state."""
60
+ if not self._connected:
61
+ return
62
+
63
+ try:
64
+ # Subtle head movement to indicate listening
65
+ self._reachy.head.pan.goto(10, duration=0.3, wait=True)
66
+ self._reachy.head.pan.goto(-10, duration=0.6, wait=True)
67
+ self._reachy.head.pan.goto(0, duration=0.3, wait=True)
68
+ _LOGGER.debug("Reachy Mini: Listening head movement")
69
+ except Exception as e:
70
+ _LOGGER.error("Error moving Reachy Mini while listening: %s", e)
71
+
72
+ def on_response(self) -> None:
73
+ """Handle response state."""
74
+ if not self._connected:
75
+ return
76
+
77
+ try:
78
+ # Nod to acknowledge
79
+ self._reachy.head.tilt.goto(10, duration=0.3, wait=True)
80
+ self._reachy.head.tilt.goto(-10, duration=0.6, wait=True)
81
+ self._reachy.head.tilt.goto(0, duration=0.3, wait=True)
82
+ _LOGGER.debug("Reachy Mini: Nod on response")
83
+ except Exception as e:
84
+ _LOGGER.error("Error moving Reachy Mini on response: %s", e)
85
+
86
+ def on_error(self) -> None:
87
+ """Handle error state."""
88
+ if not self._connected:
89
+ return
90
+
91
+ try:
92
+ # Tilt head to indicate error
93
+ self._reachy.head.tilt.goto(-20, duration=0.5, wait=True)
94
+ _LOGGER.debug("Reachy Mini: Tilt head on error")
95
+ except Exception as e:
96
+ _LOGGER.error("Error moving Reachy Mini on error: %s", e)
97
+
98
+ def on_stop(self) -> None:
99
+ """Handle stop command."""
100
+ if not self._connected:
101
+ return
102
+
103
+ try:
104
+ # Shake head to indicate stop
105
+ self._reachy.head.pan.goto(-15, duration=0.3, wait=True)
106
+ self._reachy.head.pan.goto(15, duration=0.6, wait=True)
107
+ self._reachy.head.pan.goto(0, duration=0.3, wait=True)
108
+ _LOGGER.debug("Reachy Mini: Shake head on stop")
109
+ except Exception as e:
110
+ _LOGGER.error("Error moving Reachy Mini on stop: %s", e)
111
+
112
+ def is_connected(self) -> bool:
113
+ """Check if connected to Reachy Mini."""
114
+ return self._connected
reachy_mini_ha_voice/satellite.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Voice satellite protocol implementation for ESPHome."""
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ import struct
7
+ from typing import Optional, Union
8
+
9
+ from .models import ServerState
10
+
11
+ _LOGGER = logging.getLogger(__name__)
12
+
13
+
14
+ class VoiceSatelliteProtocol(asyncio.Protocol):
15
+ """ESPHome voice satellite protocol implementation."""
16
+
17
+ def __init__(self, state: ServerState):
18
+ """Initialize protocol."""
19
+ self.state = state
20
+ self.transport: Optional[asyncio.Transport] = None
21
+ self._buffer = bytearray()
22
+ self._connected = False
23
+
24
+ def connection_made(self, transport: asyncio.Transport) -> None:
25
+ """Handle new connection."""
26
+ self.transport = transport
27
+ self.state.satellite = self
28
+ self._connected = True
29
+ _LOGGER.info("Client connected: %s", transport.get_extra_info("peername"))
30
+
31
+ def connection_lost(self, exc: Optional[Exception]) -> None:
32
+ """Handle connection loss."""
33
+ self._connected = False
34
+ self.state.satellite = None
35
+ _LOGGER.info("Client disconnected")
36
+ if exc:
37
+ _LOGGER.error("Connection error: %s", exc)
38
+
39
+ def data_received(self, data: bytes) -> None:
40
+ """Handle incoming data."""
41
+ self._buffer.extend(data)
42
+
43
+ while len(self._buffer) >= 3:
44
+ # Parse message header
45
+ msg_type = self._buffer[0]
46
+ msg_length = struct.unpack(">H", self._buffer[1:3])[0]
47
+
48
+ if len(self._buffer) < 3 + msg_length:
49
+ # Need more data
50
+ break
51
+
52
+ # Extract message
53
+ msg_data = bytes(self._buffer[3 : 3 + msg_length])
54
+ self._buffer = self._buffer[3 + msg_length :]
55
+
56
+ # Process message
57
+ asyncio.create_task(self._process_message(msg_type, msg_data))
58
+
59
+ async def _process_message(self, msg_type: int, msg_data: bytes) -> None:
60
+ """Process a message."""
61
+ try:
62
+ if msg_type == 0x01: # Hello
63
+ await self._handle_hello(msg_data)
64
+ elif msg_type == 0x02: # Voice Assistant Start
65
+ await self._handle_voice_assistant_start(msg_data)
66
+ elif msg_type == 0x03: # Voice Assistant End
67
+ await self._handle_voice_assistant_end(msg_data)
68
+ elif msg_type == 0x04: # TTS Audio
69
+ await self._handle_tts_audio(msg_data)
70
+ else:
71
+ _LOGGER.warning("Unknown message type: %s", msg_type)
72
+ except Exception as e:
73
+ _LOGGER.error("Error processing message: %s", e)
74
+
75
+ async def _handle_hello(self, data: bytes) -> None:
76
+ """Handle hello message."""
77
+ _LOGGER.debug("Received hello message")
78
+ # Send hello response
79
+ response = self._build_message(0x01, json.dumps({"name": self.state.name}))
80
+ self._send_message(response)
81
+
82
+ async def _handle_voice_assistant_start(self, data: bytes) -> None:
83
+ """Handle voice assistant start message."""
84
+ _LOGGER.info("Voice assistant started")
85
+ # Play wake sound
86
+ try:
87
+ with open(self.state.wakeup_sound, "rb") as f:
88
+ self.state.tts_player.play(f.read())
89
+ except Exception as e:
90
+ _LOGGER.error("Error playing wake sound: %s", e)
91
+
92
+ async def _handle_voice_assistant_end(self, data: bytes) -> None:
93
+ """Handle voice assistant end message."""
94
+ _LOGGER.info("Voice assistant ended")
95
+
96
+ async def _handle_tts_audio(self, data: bytes) -> None:
97
+ """Handle TTS audio message."""
98
+ try:
99
+ self.state.tts_player.play(data)
100
+ except Exception as e:
101
+ _LOGGER.error("Error playing TTS audio: %s", e)
102
+
103
+ def handle_audio(self, audio_chunk: bytes) -> None:
104
+ """Handle audio chunk from microphone."""
105
+ if self._connected and self.transport:
106
+ # Send audio data to Home Assistant
107
+ message = self._build_message(0x10, audio_chunk)
108
+ self._send_message(message)
109
+
110
+ def wakeup(self, wake_word) -> None:
111
+ """Handle wake word detection."""
112
+ _LOGGER.info("Wake word detected: %s", wake_word.id)
113
+ # Send wake notification to Home Assistant
114
+ message = self._build_message(
115
+ 0x11, json.dumps({"wake_word": wake_word.wake_word})
116
+ )
117
+ self._send_message(message)
118
+
119
+ def stop(self) -> None:
120
+ """Handle stop word detection."""
121
+ _LOGGER.info("Stop word detected")
122
+ # Send stop notification to Home Assistant
123
+ message = self._build_message(0x12, json.dumps({"action": "stop"}))
124
+ self._send_message(message)
125
+
126
+ def _build_message(self, msg_type: int, data: Union[str, bytes]) -> bytes:
127
+ """Build a message."""
128
+ if isinstance(data, str):
129
+ data = data.encode("utf-8")
130
+ length = len(data)
131
+ return bytes([msg_type]) + struct.pack(">H", length) + data
132
+
133
+ def _send_message(self, message: bytes) -> None:
134
+ """Send a message."""
135
+ if self._connected and self.transport:
136
+ self.transport.write(message)
reachy_mini_ha_voice/util.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Utility functions."""
2
+
3
+ import logging
4
+ import socket
5
+
6
+
7
+ _LOGGER = logging.getLogger(__name__)
8
+
9
+
10
+ def get_mac() -> str:
11
+ """Get the MAC address of the first network interface."""
12
+ try:
13
+ mac = ":".join(
14
+ f"{byte:02x}" for byte in socket.gethostbyname(socket.gethostname()).encode()
15
+ )
16
+ except Exception:
17
+ mac = "00:00:00:00:00:00"
18
+
19
+ return mac
reachy_mini_ha_voice/zeroconf.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Zeroconf/mDNS service discovery for Home Assistant."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import socket
6
+ from typing import Optional
7
+
8
+ from zeroconf import IPVersion, ServiceInfo, Zeroconf
9
+
10
+ _LOGGER = logging.getLogger(__name__)
11
+
12
+
13
+ class HomeAssistantZeroconf:
14
+ """Zeroconf service discovery for Home Assistant."""
15
+
16
+ def __init__(self, port: int, name: str):
17
+ """Initialize zeroconf discovery."""
18
+ self.port = port
19
+ self.name = name
20
+ self._zeroconf: Optional[Zeroconf] = None
21
+ self._service_info: Optional[ServiceInfo] = None
22
+
23
+ async def register_server(self) -> None:
24
+ """Register the server with zeroconf."""
25
+ try:
26
+ self._zeroconf = Zeroconf(ip_version=IPVersion.V4Only)
27
+
28
+ # Get local IP address
29
+ hostname = socket.gethostname()
30
+ local_ip = socket.gethostbyname(hostname)
31
+
32
+ # Create service info
33
+ service_type = "_esphomelib._tcp.local."
34
+ service_name = f"{self.name}._esphomelib._tcp.local."
35
+
36
+ self._service_info = ServiceInfo(
37
+ service_type,
38
+ name=service_name,
39
+ addresses=[socket.inet_aton(local_ip)],
40
+ port=self.port,
41
+ properties={
42
+ "version": "1.0",
43
+ "platform": "ReachyMini",
44
+ },
45
+ server=f"{hostname}.local.",
46
+ )
47
+
48
+ await asyncio.get_event_loop().run_in_executor(
49
+ None, self._zeroconf.register_service, self._service_info
50
+ )
51
+
52
+ _LOGGER.info(
53
+ "Registered zeroconf service: %s at %s:%s",
54
+ service_name,
55
+ local_ip,
56
+ self.port,
57
+ )
58
+ except Exception as e:
59
+ _LOGGER.error("Failed to register zeroconf service: %s", e)
60
+
61
+ async def unregister_server(self) -> None:
62
+ """Unregister the server from zeroconf."""
63
+ if self._zeroconf and self._service_info:
64
+ await asyncio.get_event_loop().run_in_executor(
65
+ None, self._zeroconf.unregister_service, self._service_info
66
+ )
67
+ self._zeroconf.close()
68
+ _LOGGER.info("Unregistered zeroconf service")
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ aioesphomeapi==42.7.0
2
+ numpy>=2,<3
3
+ pymicro-wakeword>=2,<3
4
+ pyopen-wakeword>=1,<2
5
+ pyaudio>=0.2.11
6
+ zeroconf<1
script/download_assets.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Download wake word models and sound files."""
3
+
4
+ import os
5
+ import urllib.request
6
+ from pathlib import Path
7
+
8
+
9
+ def download_file(url: str, dest: Path) -> None:
10
+ """Download a file from URL to destination."""
11
+ print(f"Downloading {url}...")
12
+ dest.parent.mkdir(parents=True, exist_ok=True)
13
+ urllib.request.urlretrieve(url, dest)
14
+ print(f"Saved to {dest}")
15
+
16
+
17
+ def main():
18
+ """Main function."""
19
+ base_dir = Path(__file__).parent.parent
20
+ wakewords_dir = base_dir / "wakewords"
21
+ sounds_dir = base_dir / "sounds"
22
+
23
+ print("Downloading wake word models...")
24
+
25
+ # Download okay_nabu model (microWakeWord)
26
+ download_file(
27
+ "https://github.com/kahrendt/microWakeWord/releases/download/v2.0.0/okay_nabu.tflite",
28
+ wakewords_dir / "okay_nabu.tflite",
29
+ )
30
+
31
+ # Download stop model (microWakeWord)
32
+ download_file(
33
+ "https://github.com/kahrendt/microWakeWord/releases/download/v2.0.0/stop.tflite",
34
+ wakewords_dir / "stop.tflite",
35
+ )
36
+
37
+ print("\nDownloading sound files...")
38
+
39
+ # Note: These are placeholder URLs. You may need to replace them with actual sound files
40
+ # or provide your own sound files.
41
+
42
+ print("\nSound files need to be provided manually.")
43
+ print("Please add the following files to the 'sounds' directory:")
44
+ print(" - wake_word_triggered.flac (played when wake word is detected)")
45
+ print(" - timer_finished.flac (played when timer finishes)")
46
+ print("\nYou can use any short audio file in FLAC or WAV format.")
47
+ print("For now, creating placeholder files...")
48
+
49
+ # Create empty placeholder files
50
+ (sounds_dir / "wake_word_triggered.flac").touch()
51
+ (sounds_dir / "timer_finished.flac").touch()
52
+
53
+ print("\nDownload complete!")
54
+ print("\nTo add more wake words, visit:")
55
+ print(" - https://github.com/kahrendt/microWakeWord (microWakeWord models)")
56
+ print(" - https://github.com/dscripka/openWakeWord (openWakeWord models)")
57
+ print(" - https://github.com/fwartner/home-assistant-wakewords-collection (collection)")
58
+
59
+
60
+ if __name__ == "__main__":
61
+ main()
setup.ps1 ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Setup script for Reachy Mini Home Assistant Voice Assistant (Windows)
2
+
3
+ Write-Host "Setting up Reachy Mini Home Assistant Voice Assistant..." -ForegroundColor Green
4
+
5
+ # Create virtual environment
6
+ if (-not (Test-Path ".venv")) {
7
+ Write-Host "Creating virtual environment..." -ForegroundColor Yellow
8
+ python -m venv .venv
9
+ }
10
+
11
+ # Activate virtual environment
12
+ .venv\Scripts\Activate.ps1
13
+
14
+ # Upgrade pip
15
+ Write-Host "Upgrading pip..." -ForegroundColor Yellow
16
+ pip install --upgrade pip setuptools wheel
17
+
18
+ # Install dependencies
19
+ Write-Host "Installing Python dependencies..." -ForegroundColor Yellow
20
+ pip install -r requirements.txt
21
+
22
+ # Install package
23
+ Write-Host "Installing package..." -ForegroundColor Yellow
24
+ pip install -e .
25
+
26
+ Write-Host ""
27
+ Write-Host "Setup complete!" -ForegroundColor Green
28
+ Write-Host ""
29
+ Write-Host "All wake word models and sound files are already included." -ForegroundColor Cyan
30
+ Write-Host ""
31
+ Write-Host "Run the application:" -ForegroundColor White
32
+ Write-Host " python -m reachy_mini_ha_voice --name 'ReachyMini' --enable-reachy" -ForegroundColor Gray
33
+ Write-Host ""
34
+ Write-Host "For more information, see README.md" -ForegroundColor Gray
setup.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Setup script for Reachy Mini Home Assistant Voice Assistant."""
2
+
3
+ from setuptools import setup, find_packages
4
+
5
+ setup(
6
+ name="reachy-mini-ha-voice",
7
+ version="1.0.0",
8
+ packages=find_packages(),
9
+ install_requires=[
10
+ "aioesphomeapi==42.7.0",
11
+ "numpy>=2,<3",
12
+ "pymicro-wakeword>=2,<3",
13
+ "pyopen-wakeword>=1,<2",
14
+ "pyaudio>=0.2.11",
15
+ "zeroconf<1",
16
+ ],
17
+ python_requires=">=3.9",
18
+ entry_points={
19
+ "console_scripts": [
20
+ "reachy-mini-ha-voice=reachy_mini_ha_voice.__main__:main",
21
+ ],
22
+ },
23
+ )
setup.sh ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # Setup script for Reachy Mini Home Assistant Voice Assistant
3
+
4
+ set -e
5
+
6
+ echo "Setting up Reachy Mini Home Assistant Voice Assistant..."
7
+
8
+ # Create virtual environment
9
+ if [ ! -d ".venv" ]; then
10
+ echo "Creating virtual environment..."
11
+ python3 -m venv .venv
12
+ fi
13
+
14
+ # Activate virtual environment
15
+ source .venv/bin/activate
16
+
17
+ # Upgrade pip
18
+ echo "Upgrading pip..."
19
+ pip install --upgrade pip setuptools wheel
20
+
21
+ # Install dependencies
22
+ echo "Installing Python dependencies..."
23
+ pip install -r requirements.txt
24
+
25
+ # Install package
26
+ echo "Installing package..."
27
+ pip install -e .
28
+
29
+ echo ""
30
+ echo "Setup complete!"
31
+ echo ""
32
+ echo "All wake word models and sound files are already included."
33
+ echo ""
34
+ echo "Run the application:"
35
+ echo " python -m reachy_mini_ha_voice --name 'ReachyMini' --enable-reachy"
36
+ echo ""
37
+ echo "For more information, see README.md"
wakewords/alexa.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "type": "micro",
3
+ "wake_word": "Alexa",
4
+ "author": "Kevin Ahrendt",
5
+ "website": "https://www.kevinahrendt.com/",
6
+ "model": "alexa.tflite",
7
+ "trained_languages": ["en"],
8
+ "version": 2,
9
+ "micro": {
10
+ "probability_cutoff": 0.9,
11
+ "sliding_window_size": 5,
12
+ "feature_step_size": 10,
13
+ "tensor_arena_size": 22348,
14
+ "minimum_esphome_version": "2024.7.0"
15
+ }
16
+ }
wakewords/choo_choo_homie.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "type": "micro",
3
+ "wake_word": "Choo Choo Homie",
4
+ "author": "Michael Hansen",
5
+ "website": "https://www.home-assistant.io",
6
+ "model": "choo_choo_homie.tflite",
7
+ "trained_languages": [
8
+ "en"
9
+ ],
10
+ "version": 2,
11
+ "micro": {
12
+ "probability_cutoff": 0.97,
13
+ "feature_step_size": 10,
14
+ "sliding_window_size": 5,
15
+ "tensor_arena_size": 30000,
16
+ "minimum_esphome_version": "2024.7.0"
17
+ }
18
+ }
wakewords/hey_home_assistant.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "type": "micro",
3
+ "wake_word": "Hey Home Assistant",
4
+ "author": "Michael Hansen",
5
+ "website": "https://www.home-assistant.io",
6
+ "model": "hey_home_assistant.tflite",
7
+ "trained_languages": ["en"],
8
+ "version": 2,
9
+ "micro": {
10
+ "probability_cutoff": 0.97,
11
+ "feature_step_size": 10,
12
+ "sliding_window_size": 5,
13
+ "tensor_arena_size": 30000,
14
+ "minimum_esphome_version": "2024.7.0"
15
+ }
16
+ }
wakewords/hey_jarvis.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "type": "micro",
3
+ "wake_word": "Hey Jarvis",
4
+ "author": "Kevin Ahrendt",
5
+ "website": "https://www.kevinahrendt.com/",
6
+ "model": "hey_jarvis.tflite",
7
+ "trained_languages": ["en"],
8
+ "version": 2,
9
+ "micro": {
10
+ "probability_cutoff": 0.97,
11
+ "feature_step_size": 10,
12
+ "sliding_window_size": 5,
13
+ "tensor_arena_size": 22860,
14
+ "minimum_esphome_version": "2024.7.0"
15
+ }
16
+ }
wakewords/hey_luna.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "type": "micro",
3
+ "wake_word": "hey_luna",
4
+ "author": "adamlonsdale",
5
+ "website": "https://github.com/adamlonsdale",
6
+ "model": "hey_luna.tflite",
7
+ "version": 2,
8
+ "trained_languages": ["en"],
9
+ "micro": {
10
+ "probability_cutoff": 0.63,
11
+ "sliding_window_size": 5,
12
+ "feature_step_size": 10,
13
+ "tensor_arena_size": 22860,
14
+ "minimum_esphome_version": "2024.7.0"
15
+ }
16
+ }
wakewords/hey_mycroft.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "type": "micro",
3
+ "wake_word": "Hey Mycroft",
4
+ "author": "Kevin Ahrendt",
5
+ "website": "https://www.kevinahrendt.com/",
6
+ "model": "hey_mycroft.tflite",
7
+ "trained_languages": ["en"],
8
+ "version": 2,
9
+ "micro": {
10
+ "probability_cutoff": 0.95,
11
+ "sliding_window_size": 5,
12
+ "feature_step_size": 10,
13
+ "tensor_arena_size": 23628,
14
+ "minimum_esphome_version": "2024.7.0"
15
+ }
16
+ }
wakewords/okay_computer.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "type": "micro",
3
+ "wake_word": "Okay Computer",
4
+ "author": "Michael Hansen",
5
+ "website": "https://www.home-assistant.io",
6
+ "model": "okay_computer.tflite",
7
+ "trained_languages": [
8
+ "en"
9
+ ],
10
+ "version": 2,
11
+ "micro": {
12
+ "probability_cutoff": 0.97,
13
+ "feature_step_size": 10,
14
+ "sliding_window_size": 5,
15
+ "tensor_arena_size": 30000,
16
+ "minimum_esphome_version": "2024.7.0"
17
+ }
18
+ }
wakewords/okay_nabu.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "type": "micro",
3
+ "wake_word": "Okay Nabu",
4
+ "author": "Kevin Ahrendt",
5
+ "website": "https://www.kevinahrendt.com/",
6
+ "model": "okay_nabu.tflite",
7
+ "trained_languages": ["en","nl","fr","de","it","es","sv"],
8
+ "version": 2,
9
+ "micro": {
10
+ "probability_cutoff": 0.85,
11
+ "feature_step_size": 10,
12
+ "sliding_window_size": 5,
13
+ "tensor_arena_size": 37000,
14
+ "minimum_esphome_version": "2024.7.0"
15
+ }
16
+ }
wakewords/openWakeWord/alexa_v0.1.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "type": "openWakeWord",
3
+ "wake_word": "Alexa",
4
+ "model": "alexa_v0.1.tflite"
5
+ }
wakewords/openWakeWord/hey_jarvis_v0.1.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "type": "openWakeWord",
3
+ "wake_word": "Hey Jarvis",
4
+ "model": "hey_jarvis_v0.1.tflite"
5
+ }
wakewords/openWakeWord/hey_mycroft_v0.1.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "type": "openWakeWord",
3
+ "wake_word": "Hey Mycroft",
4
+ "model": "hey_mycroft_v0.1.tflite"
5
+ }
wakewords/openWakeWord/hey_rhasspy_v0.1.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "type": "openWakeWord",
3
+ "wake_word": "Hey Rhasspy",
4
+ "model": "hey_rhasspy_v0.1.tflite"
5
+ }
wakewords/openWakeWord/ok_nabu_v0.1.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "type": "openWakeWord",
3
+ "wake_word": "Okay Nabu",
4
+ "model": "ok_nabu_v0.1.tflite"
5
+ }
wakewords/stop.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "type": "micro",
3
+ "wake_word": "Stop",
4
+ "author": "Kevin Ahrendt",
5
+ "website": "https://www.kevinahrendt.com/",
6
+ "model": "stop.tflite",
7
+ "trained_languages": ["en"],
8
+ "version": 2,
9
+ "micro": {
10
+ "probability_cutoff": 0.5,
11
+ "feature_step_size": 10,
12
+ "sliding_window_size": 5,
13
+ "tensor_arena_size": 21000,
14
+ "minimum_esphome_version": "2024.7.0"
15
+ }
16
+ }