Files
ylhan 35e107c290 助手本体:会话引擎、LLM 路由、记忆、身份、语音适配
从 Obsidian 笔记库剥离为独立仓库。规划文档与 P0 报告留在笔记库,
代码(助手本体、TTS 服务、P0 基准脚本、ESP32 固件)在此。

架构:服务器是助手,本地是客户端
- 助手本体跑在 cadadmin-server,N5095 只做 USB 外设桥与网络桥接
- 起因:实测 N5095 无 AVX2,跑不动可用的语音模型

当前技术栈
- LLM:DeepSeek deepseek-flash(云端,必须关思考)
- TTS:Qwen3-TTS 1.7B(服务器 3090,独立 HTTP 服务)
- STT:Zipformer 中英双语 int8(CPU,中文识别率未测)

实测:端到端 16/16 通过,首音延迟 1545ms
(对比:本地 LLM + vits-zh-ll 是 179ms,但音色不满意——音质与延迟
在此硬件上此消彼长)

设计要点
- MemoryStore 接口按"可能远程"设计(全 async、可超时、可失败)
- user_id / session_id / track_id 三者分开,人脸只作候选不自动切身份
- 记忆查询强制带 owner_user_id,无按 id 单独取的口子
- 自动抽取的记忆是"候选",默认不参与检索
- 异步写记忆绑定创建时的身份,不读"当前用户"
- 引擎与传输解耦,只用文件音频即可端到端测试

模型与音频产物不进版本管理(1.1GB),由 download_models.sh 恢复
2026-09-15 11:05:09 +08:00

142 lines
5.0 KiB
Arduino

// ESP32-S3 N16R8 + ILI9341 + XPT2046-compatible touch.
// Libraries: Adafruit ILI9341, Adafruit GFX Library, XPT2046_Touchscreen.
// LCD: SCK=12 MOSI=11 CS=10 DC=9 RESET=8. LCD SDO disconnected.
// Touch: T_CLK=12 T_DIN=11 T_DO=13 T_CS=7 T_IRQ=6 (polled).
// SD disconnected. Keep the existing working power/backlight connections.
// No startup calibration. Adjust touch mapping constants below if needed.
#include <SPI.h>
#include <Adafruit_GFX.h>
#include <Adafruit_ILI9341.h>
#include <XPT2046_Touchscreen.h>
#include <math.h>
Adafruit_ILI9341 tft(&SPI, 9, 10, 8);
XPT2046_Touchscreen touch(7);
// Small off-screen canvas avoids visible erase/redraw flashes.
GFXcanvas16 eyes(280, 112);
// Approximate raw range after touch.setRotation(1), not calibrated pixels.
constexpr int TOUCH_MIN_X = 200, TOUCH_MAX_X = 3900;
constexpr int TOUCH_MIN_Y = 200, TOUCH_MAX_Y = 3900;
constexpr bool TOUCH_SWAP_XY = false;
constexpr bool TOUCH_FLIP_X = true, TOUCH_FLIP_Y = true;
constexpr uint32_t IDLE_AFTER_MS = 15000;
uint32_t idleBlinkAt = 0, idleLookAt = 0;
bool idle = false, idleLookingAway = false;
float gazeX = 0, gazeY = 0, targetX = 0, targetY = 0;
uint32_t lastTouch = 0, blinkStart = 0, lastFrame = 0, lastDown = 0;
bool looking = false, blinking = false, contact = false;
int previousX = 999, previousY = 999, previousOpen = -1;
void renderEyes(int dx, int dy, int opening) {
eyes.fillScreen(ILI9341_BLACK);
for (int i = 0; i < 2; ++i) {
int x = i == 0 ? 72 : 208;
eyes.fillRoundRect(x-49, 12, 98, 88, 30, ILI9341_WHITE);
eyes.fillCircle(x+dx, 56+dy, 25, ILI9341_CYAN);
eyes.fillCircle(x+dx, 56+dy, 14, ILI9341_BLACK);
eyes.fillCircle(x+dx-7, 49+dy, 5, ILI9341_WHITE);
int cover = (100-opening)*44/100;
if (cover > 0) {
eyes.fillRect(x-50, 12, 100, cover, ILI9341_BLACK);
eyes.fillRect(x-50, 100-cover, 100, cover, ILI9341_BLACK);
}
if (opening < 7) eyes.fillRoundRect(x-39, 54, 78, 4, 2, ILI9341_CYAN);
}
tft.drawRGBBitmap(20, 54, eyes.getBuffer(), 280, 112);
}
void setup() {
Serial.begin(115200);
pinMode(10, OUTPUT); digitalWrite(10, HIGH);
pinMode(7, OUTPUT); digitalWrite(7, HIGH);
pinMode(6, INPUT_PULLUP);
SPI.begin(12, 13, 11, 10);
tft.begin(10000000);
tft.setRotation(1);
touch.begin(SPI);
touch.setRotation(1);
if (!eyes.getBuffer()) {
tft.fillScreen(ILI9341_RED);
Serial.println("Eye canvas allocation failed.");
while (true) delay(1000);
}
randomSeed(micros());
tft.fillScreen(ILI9341_BLACK);
tft.setTextColor(ILI9341_CYAN);
tft.setTextSize(2);
tft.setCursor(46, 207);
tft.print("Touch to look!");
lastFrame = lastTouch = millis();
}
void loop() {
uint32_t now = millis();
if (touch.touched()) {
TS_Point p = touch.getPoint();
float rawX = TOUCH_SWAP_XY ? p.y : p.x;
float rawY = TOUCH_SWAP_XY ? p.x : p.y;
float px = constrain((rawX-TOUCH_MIN_X)*319.0f/(TOUCH_MAX_X-TOUCH_MIN_X), 0.0f, 319.0f);
if (TOUCH_FLIP_X) px = 319.0f-px;
float py = constrain((rawY-TOUCH_MIN_Y)*239.0f/(TOUCH_MAX_Y-TOUCH_MIN_Y), 0.0f, 239.0f);
if (TOUCH_FLIP_Y) py = 239.0f-py;
idle = false;
idleLookingAway = false;
if (!contact) { blinking = true; blinkStart = now; }
contact = true;
lastDown = lastTouch = now;
looking = true;
targetX = (px-159.5f)/159.5f*20.0f;
targetY = (py-119.5f)/119.5f*14.0f;
} else if (contact && now-lastDown > 100) {
contact = false; // Short release debounce prevents repeated blinks.
}
if (looking && now-lastTouch >= 3000) {
looking = false;
targetX = targetY = 0;
}
// "Unattended" currently means no touch, not camera-based presence detection.
if (!contact && !looking && now-lastTouch >= IDLE_AFTER_MS) {
if (!idle) {
idle = true;
idleLookingAway = false;
idleBlinkAt = now + random(400, 1600);
idleLookAt = now + random(900, 2200);
}
if (!blinking && int32_t(now-idleBlinkAt) >= 0) {
blinking = true;
blinkStart = now;
idleBlinkAt = now + random(2500, 6500);
}
if (int32_t(now-idleLookAt) >= 0) {
idleLookingAway = !idleLookingAway;
if (idleLookingAway) {
targetX = random(0, 2) ? random(9, 21) : -random(9, 21);
targetY = random(-12, 13);
idleLookAt = now + random(800, 2000);
} else {
targetX = targetY = 0;
idleLookAt = now + random(1800, 4500);
}
}
}
if (now-lastFrame < 33) { delay(2); return; }
float dt = (now-lastFrame)/1000.0f;
lastFrame = now;
float blend = 1.0f-expf(-12.0f*dt);
gazeX += (targetX-gazeX)*blend;
gazeY += (targetY-gazeY)*blend;
int opening = 100;
if (blinking) {
uint32_t elapsed = now-blinkStart;
if (elapsed < 100) opening = 100-int(elapsed);
else if (elapsed < 170) opening = 0;
else if (elapsed < 330) opening = int((elapsed-170)*100/160);
else blinking = false;
}
int dx = lroundf(gazeX), dy = lroundf(gazeY);
if (dx != previousX || dy != previousY || opening != previousOpen) {
renderEyes(dx, dy, opening);
previousX = dx; previousY = dy; previousOpen = opening;
}
}