182 lines
4.7 KiB
JavaScript
182 lines
4.7 KiB
JavaScript
// Import dotenv file if not in production
|
|
if (process.env.NODE_ENV !== "production") {
|
|
require("dotenv").config();
|
|
}
|
|
|
|
const TinyTTS = require("tiny-tts");
|
|
const tts = new TinyTTS();
|
|
|
|
const fs = require("fs");
|
|
const os = require("os");
|
|
const path = require("path");
|
|
const wav = require("wav-decoder");
|
|
const Speaker = require("speaker");
|
|
const { spawn } = require("child_process");
|
|
|
|
const {redis} = require('./modules/redis');
|
|
|
|
const config = require("./config");
|
|
const transConfig = require("./config/transcribe.config");
|
|
const { handlePythonOutput } = require("./scripts/py/pythonOutput");
|
|
|
|
let speaker = null;
|
|
let speakerChannels = null;
|
|
|
|
function ensureSpeaker(channels) {
|
|
if (speaker && speakerChannels === channels) return;
|
|
speakerChannels = channels;
|
|
|
|
if (speaker) {
|
|
try { speaker.end(); } catch {}
|
|
}
|
|
|
|
const candidates = [
|
|
{ device: "default" },
|
|
{ device: "plughw:0,0" },
|
|
{ device: "hw:0,0" },
|
|
];
|
|
|
|
let lastErr = null;
|
|
for (const c of candidates) {
|
|
try {
|
|
speaker = new Speaker({
|
|
channels,
|
|
bitDepth: 16,
|
|
sampleRate: 44100,
|
|
device: c.device,
|
|
});
|
|
return;
|
|
} catch (e) {
|
|
lastErr = e;
|
|
}
|
|
}
|
|
|
|
throw lastErr || new Error("Failed to open output device");
|
|
}
|
|
|
|
function floatsToPCM16LE(channelData) {
|
|
const nCh = channelData.length;
|
|
const n = channelData[0].length;
|
|
const out = Buffer.alloc(n * nCh * 2);
|
|
|
|
for (let i = 0; i < n; i++) {
|
|
for (let ch = 0; ch < nCh; ch++) {
|
|
let s = channelData[ch][i];
|
|
s = Math.max(-1, Math.min(1, s));
|
|
out.writeInt16LE((s * 0x7fff) | 0, (i * nCh + ch) * 2);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
async function playAudio(text) {
|
|
const outFile = path.join(
|
|
os.tmpdir(),
|
|
`tinytts-${Date.now()}-${Math.random().toString(16).slice(2)}.wav`
|
|
);
|
|
|
|
try {
|
|
// Force tiny-tts to write the WAV to disk (works across versions)
|
|
const maybeBytes = await tts.speak(text, { output: outFile });
|
|
|
|
// Some versions return bytes, some return nothing. Always read the file if needed.
|
|
const wavBytes = Buffer.isBuffer(maybeBytes)
|
|
? maybeBytes
|
|
: await fs.promises.readFile(outFile);
|
|
|
|
if (!Buffer.isBuffer(wavBytes) || wavBytes.length < 44) {
|
|
throw new Error("tiny-tts did not produce a valid WAV buffer");
|
|
}
|
|
if (wavBytes.toString("ascii", 0, 4) !== "RIFF") {
|
|
throw new Error("tiny-tts WAV buffer missing RIFF header");
|
|
}
|
|
|
|
const decoded = await wav.decode(wavBytes);
|
|
if (!decoded || decoded.sampleRate !== 44100 || !decoded.channelData?.length) {
|
|
throw new Error(`Unsupported WAV: ${decoded?.sampleRate} Hz`);
|
|
}
|
|
|
|
ensureSpeaker(decoded.channelData.length);
|
|
const pcm = floatsToPCM16LE(decoded.channelData);
|
|
speaker.write(pcm);
|
|
} finally {
|
|
fs.promises.unlink(outFile).catch(() => {});
|
|
}
|
|
}
|
|
|
|
const py = spawn("python", ["./src/scripts/py/transcribe.py", JSON.stringify(transConfig)], {
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
|
|
async function processor(group, consumer, streamKey) {
|
|
while (true) {
|
|
const res = await redis.client.xReadGroup(
|
|
group,
|
|
consumer,
|
|
[{ key: streamKey, id: ">" }],
|
|
{ COUNT: 5, BLOCK: 3000 }
|
|
);
|
|
|
|
if (!res) continue;
|
|
|
|
for (const stream of res) {
|
|
for (const msg of stream.messages) {
|
|
if (msg.message.device_id !== config.DEVICE_ID) {
|
|
continue
|
|
}
|
|
|
|
console.log(`[${consumer}] Processing:`, msg);
|
|
|
|
switch (msg.message.command) {
|
|
case "greet":
|
|
await playAudio("Hello! I am the Many Tailed Fox.");
|
|
break;
|
|
|
|
case "remind":
|
|
await playAudio("Reminder, " + String(msg.message.value));
|
|
break;
|
|
|
|
default:
|
|
await playAudio("Command, " + String(msg.message.command) + " is undefined.")
|
|
}
|
|
|
|
await redis.client.xAck(streamKey, group, msg.id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
// Attach python listeners FIRST to avoid missing logs
|
|
py.stdout.on("data", (data) => {
|
|
handlePythonOutput(data.toString(), async (msg) => {
|
|
if (msg.event === "text" || msg.event === "final") {
|
|
await redis.sendTranscriptEvent("transcripts", config.DEVICE_ID, msg.text);
|
|
}
|
|
});
|
|
});
|
|
|
|
py.stderr.on("data", (data) => {
|
|
console.error("[PY ERR]", data.toString());
|
|
});
|
|
|
|
py.on("close", (code) => {
|
|
console.log("Python exited with code", code);
|
|
});
|
|
|
|
try {
|
|
await redis.connect();
|
|
await redis.ensureGroup("commands", "assistant");
|
|
await playAudio("Assistant, online.");
|
|
|
|
// Start the command processor
|
|
processor("assistant", `${config.DEVICE_ID}`, "commands").catch(console.error);
|
|
|
|
console.log("System fully initialized.");
|
|
} catch (err) {
|
|
console.error("Initialization failed:", err);
|
|
}
|
|
}
|
|
|
|
main().catch(console.error);
|