szhi
Published © Apache-2.0

Make reCamera Listen, Think, and Speak

The camera captures a gesture to start a conversation; the full workflow is coordinated by Node-RED on the device, calling external models.

IntermediateFull instructions provided1 hour32
Make reCamera Listen, Think, and Speak

Things used in this project

Hardware components

Seeed Studio reCamera 2002 Series (Wi-Fi)
×1

Software apps and online services

Node-RED
Node-RED
iFlytek

Story

Read more

Schematics

Hardware and Specs

Website

Code

STT Function Node — iFlytek Speech-to-Text (WebSocket)

JavaScript
The "iFlytek STT (WebSocket)" Function node sends raw PCM audio to the iFlytek IAT API and returns recognized text. Open the node and fill in your iFlytek credentials:

const APPID = "your_APPID";
const APISecret = "your_APISecret";
const APIKey = "your_APIKey";

This node requires the `ws` and `crypto` libraries (declared in the node's **Setup** tab).
// ===== iFlytek STT: PCM → text =====

const APPID = "your_APPID";
const APISecret = "your_APISecret";
const APIKey = "your_APIKey";

function getAuthUrl(host, path) {
    const date = new Date().toUTCString();
    const signatureOrigin = "host: " + host + "\ndate: " + date + "\nGET " + path + " HTTP/1.1";
    const signature = crypto.createHmac('sha256', APISecret).update(signatureOrigin).digest('base64');
    const authOrigin = "api_key=\"" + APIKey + "\", algorithm=\"hmac-sha256\", headers=\"host date request-line\", signature=\"" + signature + "\"";
    const auth = Buffer.from(authOrigin).toString('base64');
    return "wss://" + host + path + "?authorization=" + encodeURIComponent(auth) + "&date=" + encodeURIComponent(date) + "&host=" + encodeURIComponent(host);
}

const audioData = msg.payload;
const url = getAuthUrl('iat-api.xfyun.cn', '/v2/iat');
const socket = new ws(url);
let resultText = [];
let timeout = setTimeout(function() { try { socket.close(); } catch(e) {} node.error('STT timeout'); }, 15000);

socket.on('open', function() {
    const chunkSize = 8000;
    let offset = 0;
    function sendChunk() {
        if (offset >= audioData.length) return;
        let end = offset + chunkSize;
        let chunk = audioData.slice(offset, end);
        let status;
        if (offset === 0) status = 0;
        else status = 1;
        if (end >= audioData.length) status = 2;
        const frame = {
            common: { app_id: APPID },
            business: { domain: "iat", language: "zh_cn", accent: "mandarin", vinfo: 1, vad_eos: 10000 },
            data: { status: status, format: "audio/L16;rate=16000", audio: chunk.toString('base64'), encoding: "raw" }
        };
        socket.send(JSON.stringify(frame));
        offset = end;
        if (status !== 2) setTimeout(sendChunk, 40);
    }
    sendChunk();
});

socket.on('message', function(data) {
    const m = JSON.parse(data);
    if (m.code !== 0) { node.error("STT Error: " + m.code + " - " + (m.message || 'unknown')); socket.close(); return; }
    const wsData = m.data.result.ws;
    for (const item of wsData) { for (const w of item.cw) { resultText.push(w.w); } }
    if (m.data.status === 2) { socket.close(); }
});

socket.on('close', function() {
    clearTimeout(timeout);
    const text = resultText.join('');
    node.status({ fill: 'green', shape: 'dot', text: "Recognized: " + (text || '(empty)').substring(0, 20) });
    node.send({ payload: text });
});

socket.on('error', function(e) { clearTimeout(timeout); node.error("STT WS Error: " + e.message); });

TTS Function Node — iFlytek Speech Synthesis (WebSocket)

JavaScript
The "TTS (execSync child process)" Function node generates speech from the LLM reply. Because the Node-RED sandbox restricts long-lived WebSocket connections, this node writes a small standalone Node.js script to `/tmp/_tts_runner.js` and launches it via `child_process.execSync`. The script:

1. Connects to the iFlytek TTS WebSocket endpoint.
2. Sends the reply text.
3. Collects the returned PCM audio chunks.
4. Wraps them in a WAV header (16 kHz, mono, 16-bit) and writes `/tmp/reply.wav`.

Fill in iFlytek credentials inside this node as well. This node requires the `crypto`, `fs`, and `child_process` libraries.
// ===== TTS v4: execSync launches independent Node process =====

const APPID = "your_APPID";
const APISecret = "your_APISecret";
const APIKey = "your_APIKey";
const OUT_FILE = "/tmp/reply.wav";
const SCRIPT_PATH = "/tmp/_tts_runner.js";

const host = "cbm01.cn-huabei-1.xf-yun.com";
const apiPath = "/v1/private/mcd9m97e6";
const date = new Date().toUTCString();
const sigOrigin = "host: " + host + "\ndate: " + date + "\nGET " + apiPath + " HTTP/1.1";
const sig = crypto.createHmac('sha256', APISecret).update(sigOrigin).digest('base64');
const authO = "api_key=\"" + APIKey + "\", algorithm=\"hmac-sha256\", headers=\"host date request-line\", signature=\"" + sig + "\"";
const authB64 = Buffer.from(authO).toString('base64');
const ttsUrl = "wss://" + host + apiPath + "?authorization=" + encodeURIComponent(authB64) + "&date=" + encodeURIComponent(date) + "&host=" + encodeURIComponent(host);

const text = msg.payload || "";
if (!String(text).trim()) { node.error('TTS input is empty'); return; }
node.warn('Requesting TTS synthesis: ' + text);

// Build independent script
const script = [
    "const fs = require('fs');",
    "try {",
    "  let WebSocket = null;",
    "  const wsPaths = ['ws', '/usr/lib/node_modules/ws', '/usr/local/lib/node_modules/ws',",
    "    (process.env.HOME || '/root') + '/.node-red/node_modules/ws',",
    "    '/usr/lib/node-red/node_modules/ws', '/opt/node_modules/ws'];",
    "  for (const p of wsPaths) { try { WebSocket = require(p); break; } catch(e) {} }",
    "  if (!WebSocket) { fs.writeFileSync('/tmp/_tts_err', 'Cannot find ws module'); process.exit(1); }",
    "  const url = " + JSON.stringify(ttsUrl) + ";",
    "  const text = " + JSON.stringify(text) + ";",
    "  const appid = " + JSON.stringify(APPID) + ";",
    "  const outFile = " + JSON.stringify(OUT_FILE) + ";",
    "  const chunks = [];",
    "  const sock = new WebSocket(url);",
    "  const timer = setTimeout(function() { try{sock.close();}catch(e){} fs.writeFileSync('/tmp/_tts_err','timeout after 20s'); process.exit(1); }, 20000);",
    "  sock.on('open', function() {",
    "    sock.send(JSON.stringify({",
    "      header: { app_id: appid, status: 2 },",
    "      parameter: { tts: { vcn: 'x5_EnUs_Lila_flow', speed: 50, volume: 50, pitch: 50, audio: { encoding: 'raw', sample_rate: 16000 } } },",
    "      payload: { text: { encoding: 'utf8', status: 2, text: Buffer.from(text).toString('base64') } }",
    "    }));",
    "  });",
    "  sock.on('message', function(d) {",
    "    const m = JSON.parse(d);",
    "    const code = m.header ? m.header.code : -1;",
    "    const status = m.header ? m.header.status : -1;",
    "    if (code !== 0) {",
    "      clearTimeout(timer);",
    "      fs.writeFileSync('/tmp/_tts_err', 'code=' + code + ' ' + (m.header.message || ''));",
    "      process.exit(1);",
    "    }",
    "    if (status === 1 && m.payload && m.payload.audio && m.payload.audio.audio) {",
    "      chunks.push(Buffer.from(m.payload.audio.audio, 'base64'));",
    "    }",
    "    if (status === 2) {",
    "      clearTimeout(timer);",
    "      const pcm = Buffer.concat(chunks);",
    "      const hdr = Buffer.alloc(44);",
    "      hdr.write('RIFF',0); hdr.writeUInt32LE(36+pcm.length,4); hdr.write('WAVE',8); hdr.write('fmt ',12);",
    "      hdr.writeUInt32LE(16,16); hdr.writeUInt16LE(1,20); hdr.writeUInt16LE(1,22);",
    "      hdr.writeUInt32LE(16000,24); hdr.writeUInt32LE(32000,28);",
    "      hdr.writeUInt16LE(2,32); hdr.writeUInt16LE(16,34);",
    "      hdr.write('data',36); hdr.writeUInt32LE(pcm.length,40);",
    "      fs.writeFileSync(outFile, Buffer.concat([hdr, pcm]));",
    "      sock.close();",
    "    }",
    "  });",
    "  sock.on('error', function(e) { clearTimeout(timer); fs.writeFileSync('/tmp/_tts_err', 'ws-error: ' + e.message); process.exit(1); });",
    "} catch(e) { fs.writeFileSync('/tmp/_tts_err', 'top-err: ' + e.message); process.exit(1); }"
].join("\n");

fs.writeFileSync(SCRIPT_PATH, script);

try {
    const cmd = "NODE_PATH=$HOME/.node-red/node_modules:/usr/lib/node_modules:/usr/local/lib/node_modules:/usr/lib/node-red/node_modules node " + SCRIPT_PATH;
    cp.execSync(cmd, { timeout: 25000, stdio: 'pipe' });
    let errMsg = '';
    try { errMsg = fs.readFileSync('/tmp/_tts_err', 'utf8'); } catch(e) {}
    if (errMsg) {
        node.status({ fill: 'red', shape: 'ring', text: 'TTS failed' });
        node.send({ payload: { ok: false, error: errMsg, text: text } });
        return;
    }
    const stat = fs.statSync(OUT_FILE);
    node.status({ fill: 'green', shape: 'dot', text: 'TTS success ' + stat.size + 'B' });
    node.send({ payload: { ok: true, text: text, size: stat.size, file: OUT_FILE } });
} catch(e) {
    let errMsg = e.message;
    try { errMsg = fs.readFileSync('/tmp/_tts_err', 'utf8'); } catch(e2) {}
    node.status({ fill: 'red', shape: 'ring', text: 'Execution failed' });
    node.error('TTS exec failed: ' + errMsg);
    node.send({ payload: { ok: false, error: errMsg, text: text } });
}

LLM Function Node — iFlytek Spark Lite (WebSocket)

JavaScript
The "iFlytek LLM (WebSocket)" Function node sends the recognized text to Spark Lite and receives a short intelligent reply. Fill in the same iFlytek credentials:

const APPID = "your_APPID";
const APISecret = "your_APISecret";
const APIKey = "your_APIKey";
// ===== iFlytek LLM Spark Lite: text → reply =====

const APPID = "your_APPID";
const APISecret = "your_APISecret";
const APIKey = "your_APIKey";

function getAuthUrl(host, path) {
    const date = new Date().toUTCString();
    const signatureOrigin = "host: " + host + "\ndate: " + date + "\nGET " + path + " HTTP/1.1";
    const signature = crypto.createHmac('sha256', APISecret).update(signatureOrigin).digest('base64');
    const authOrigin = "api_key=\"" + APIKey + "\", algorithm=\"hmac-sha256\", headers=\"host date request-line\", signature=\"" + signature + "\"";
    const auth = Buffer.from(authOrigin).toString('base64');
    return "wss://" + host + path + "?authorization=" + encodeURIComponent(auth) + "&date=" + encodeURIComponent(date) + "&host=" + encodeURIComponent(host);
}

let userText = msg.payload;
if (!userText || String(userText).trim().length === 0) { userText = "Sorry, I didn't catch that. Please say it again."; }

const url = getAuthUrl('spark-api.xf-yun.com', '/v1.1/chat');
const socket = new ws(url);
let resultText = [];
let timeout = setTimeout(function() { try { socket.close(); } catch(e) {} node.error('LLM timeout'); }, 20000);

socket.on('open', function() {
    socket.send(JSON.stringify({
        header: { app_id: APPID, uid: "recamera_nodered" },
        parameter: { chat: { domain: "lite", temperature: 0.5, max_tokens: 1024 } },
        payload: { message: { text: [
            { role: "system", content: "You are a voice assistant deployed on the smart camera reCamera. Please answer user questions in short, conversational, friendly language. Each response should be no more than 50 Chinese characters." },
            { role: "user", content: userText }
        ] } }
    }));
});

socket.on('message', function(data) {
    const m = JSON.parse(data);
    if (m.header.code !== 0) { node.error("LLM Error: " + m.header.code + " - " + m.header.message); socket.close(); return; }
    resultText.push(m.payload.choices.text[0].content);
    if (m.payload.choices.status === 2) { socket.close(); }
});

socket.on('close', function() {
    clearTimeout(timeout);
    let text = resultText.join('');
    if (!text) text = "The brain is offline temporarily. Please try again later.";
    node.status({ fill: 'green', shape: 'dot', text: "Reply: " + text.substring(0, 20) });
    node.send({ payload: text });
});

socket.on('error', function(e) { clearTimeout(timeout); node.error("LLM WS Error: " + e.message); });

voice interaction node red github

Node RED flows.

Credits

szhi
2 projects • 0 followers
Application engineer in embedded systems

Comments