@gonzih/cc-discord 0.2.38 → 0.2.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bot.js +8 -1
- package/dist/voice.d.ts +3 -2
- package/dist/voice.js +86 -42
- package/package.json +1 -1
package/dist/bot.js
CHANGED
|
@@ -911,8 +911,15 @@ export class CcDiscordBot {
|
|
|
911
911
|
this.lastActiveChannelId = channelId;
|
|
912
912
|
switch (interaction.commandName) {
|
|
913
913
|
case "reset": {
|
|
914
|
+
// Kill local DM session if any
|
|
914
915
|
this.killSession(channelId);
|
|
915
|
-
|
|
916
|
+
// Kill persistent meta-agent session if this is a routed channel.
|
|
917
|
+
// Unlike /clear, we do NOT delete the JSONL — context is preserved.
|
|
918
|
+
// Next message will respawn with --continue, picking up the same history.
|
|
919
|
+
const resetNs = this.resolveNamespaceForChannel(channelId);
|
|
920
|
+
this.metaAgentManager.killSession(resetNs);
|
|
921
|
+
await interaction.reply(`Session process killed for \`${resetNs}\`. Next message respawns with existing context intact.\n` +
|
|
922
|
+
`Use \`/clear\` to also wipe conversation history.`);
|
|
916
923
|
break;
|
|
917
924
|
}
|
|
918
925
|
case "costs": {
|
package/dist/voice.d.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Voice message transcription via whisper.cpp.
|
|
3
|
-
* Flow:
|
|
3
|
+
* Flow: Discord voice memo (OGG/M4A) → ffmpeg convert to 16kHz WAV → whisper-cpp → text
|
|
4
4
|
*/
|
|
5
5
|
/**
|
|
6
|
-
* Transcribe a voice message from a
|
|
6
|
+
* Transcribe a voice message from a Discord CDN URL.
|
|
7
|
+
* Accepts OGG, M4A, MP3, or any audio format ffmpeg can decode.
|
|
7
8
|
* Returns the transcribed text, or throws if whisper/ffmpeg not available.
|
|
8
9
|
*/
|
|
9
10
|
export declare function transcribeVoice(fileUrl: string): Promise<string>;
|
package/dist/voice.js
CHANGED
|
@@ -1,40 +1,40 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Voice message transcription via whisper.cpp.
|
|
3
|
-
* Flow:
|
|
3
|
+
* Flow: Discord voice memo (OGG/M4A) → ffmpeg convert to 16kHz WAV → whisper-cpp → text
|
|
4
4
|
*/
|
|
5
5
|
import { execFile } from "child_process";
|
|
6
6
|
import { promisify } from "util";
|
|
7
|
-
import { existsSync } from "fs";
|
|
8
|
-
import { unlink, readFile } from "fs/promises";
|
|
7
|
+
import { existsSync, createWriteStream } from "fs";
|
|
8
|
+
import { unlink, readFile, access } from "fs/promises";
|
|
9
9
|
import { tmpdir } from "os";
|
|
10
10
|
import { join } from "path";
|
|
11
11
|
import https from "https";
|
|
12
12
|
import http from "http";
|
|
13
|
-
import { createWriteStream } from "fs";
|
|
14
13
|
const execFileAsync = promisify(execFile);
|
|
15
|
-
//
|
|
16
|
-
// Falls back to base.en if small not found
|
|
14
|
+
// Env var overrides allow test injection: WHISPER_BIN, FFMPEG_BIN, WHISPER_MODEL
|
|
17
15
|
const WHISPER_MODELS = [
|
|
16
|
+
process.env.WHISPER_MODEL,
|
|
18
17
|
"/opt/homebrew/share/whisper-cpp/ggml-small.en.bin",
|
|
19
18
|
"/opt/homebrew/share/whisper-cpp/ggml-small.bin",
|
|
20
19
|
"/opt/homebrew/share/whisper-cpp/ggml-base.en.bin",
|
|
21
20
|
"/opt/homebrew/share/whisper-cpp/ggml-base.bin",
|
|
22
|
-
// user-local
|
|
23
21
|
`${process.env.HOME}/.local/share/whisper-cpp/ggml-small.en.bin`,
|
|
24
22
|
`${process.env.HOME}/.local/share/whisper-cpp/ggml-base.en.bin`,
|
|
25
|
-
];
|
|
23
|
+
].filter(Boolean);
|
|
26
24
|
const WHISPER_BIN_CANDIDATES = [
|
|
27
|
-
|
|
25
|
+
process.env.WHISPER_BIN,
|
|
26
|
+
"/opt/homebrew/bin/whisper-cli",
|
|
28
27
|
"/opt/homebrew/bin/whisper-cpp",
|
|
29
28
|
"/usr/local/bin/whisper-cli",
|
|
30
29
|
"/usr/local/bin/whisper-cpp",
|
|
31
30
|
"/opt/homebrew/bin/whisper",
|
|
32
|
-
];
|
|
31
|
+
].filter(Boolean);
|
|
33
32
|
const FFMPEG_CANDIDATES = [
|
|
33
|
+
process.env.FFMPEG_BIN,
|
|
34
34
|
"/opt/homebrew/bin/ffmpeg",
|
|
35
35
|
"/usr/local/bin/ffmpeg",
|
|
36
36
|
"/usr/bin/ffmpeg",
|
|
37
|
-
];
|
|
37
|
+
].filter(Boolean);
|
|
38
38
|
function findBin(candidates) {
|
|
39
39
|
for (const p of candidates) {
|
|
40
40
|
if (existsSync(p))
|
|
@@ -49,15 +49,34 @@ function findModel() {
|
|
|
49
49
|
}
|
|
50
50
|
return null;
|
|
51
51
|
}
|
|
52
|
-
|
|
52
|
+
/**
|
|
53
|
+
* Download a file from a URL to dest.
|
|
54
|
+
* Supports file:// (copies local file), and https/http with redirect following.
|
|
55
|
+
*/
|
|
56
|
+
async function downloadFile(url, dest, redirectsLeft = 5) {
|
|
57
|
+
if (url.startsWith("file://")) {
|
|
58
|
+
const { copyFile } = await import("fs/promises");
|
|
59
|
+
await copyFile(url.slice("file://".length), dest);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
53
62
|
return new Promise((resolve, reject) => {
|
|
54
|
-
|
|
63
|
+
if (redirectsLeft <= 0) {
|
|
64
|
+
reject(new Error(`Too many redirects downloading ${url}`));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
55
67
|
const getter = url.startsWith("https") ? https : http;
|
|
56
68
|
getter.get(url, (res) => {
|
|
69
|
+
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
70
|
+
res.resume();
|
|
71
|
+
downloadFile(res.headers.location, dest, redirectsLeft - 1).then(resolve).catch(reject);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
57
74
|
if (res.statusCode !== 200) {
|
|
75
|
+
res.resume();
|
|
58
76
|
reject(new Error(`HTTP ${res.statusCode} downloading ${url}`));
|
|
59
77
|
return;
|
|
60
78
|
}
|
|
79
|
+
const file = createWriteStream(dest);
|
|
61
80
|
res.pipe(file);
|
|
62
81
|
file.on("finish", () => file.close(() => resolve()));
|
|
63
82
|
file.on("error", reject);
|
|
@@ -65,7 +84,8 @@ function downloadFile(url, dest) {
|
|
|
65
84
|
});
|
|
66
85
|
}
|
|
67
86
|
/**
|
|
68
|
-
* Transcribe a voice message from a
|
|
87
|
+
* Transcribe a voice message from a Discord CDN URL.
|
|
88
|
+
* Accepts OGG, M4A, MP3, or any audio format ffmpeg can decode.
|
|
69
89
|
* Returns the transcribed text, or throws if whisper/ffmpeg not available.
|
|
70
90
|
*/
|
|
71
91
|
export async function transcribeVoice(fileUrl) {
|
|
@@ -78,58 +98,82 @@ export async function transcribeVoice(fileUrl) {
|
|
|
78
98
|
const model = findModel();
|
|
79
99
|
if (!model)
|
|
80
100
|
throw new Error("No whisper model found — run: whisper-cpp-download-ggml-model small.en");
|
|
81
|
-
const tmp = join(tmpdir(), `cc-
|
|
82
|
-
|
|
101
|
+
const tmp = join(tmpdir(), `cc-discord-voice-${Date.now()}`);
|
|
102
|
+
// Preserve original extension so ffmpeg auto-detects format
|
|
103
|
+
const urlPath = new URL(fileUrl).pathname;
|
|
104
|
+
const ext = urlPath.includes(".") ? "." + urlPath.split(".").pop().split("?")[0] : ".ogg";
|
|
105
|
+
const audioPath = `${tmp}${ext}`;
|
|
83
106
|
const wavPath = `${tmp}.wav`;
|
|
84
107
|
try {
|
|
85
|
-
// 1. Download
|
|
86
|
-
await downloadFile(fileUrl,
|
|
87
|
-
// 2. Convert
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
108
|
+
// 1. Download audio from Discord CDN (follows redirects)
|
|
109
|
+
await downloadFile(fileUrl, audioPath);
|
|
110
|
+
// 2. Convert to 16kHz mono WAV (whisper requirement)
|
|
111
|
+
try {
|
|
112
|
+
await execFileAsync(ffmpegBin, [
|
|
113
|
+
"-y", "-i", audioPath,
|
|
114
|
+
"-ar", "16000",
|
|
115
|
+
"-ac", "1",
|
|
116
|
+
"-c:a", "pcm_s16le",
|
|
117
|
+
wavPath,
|
|
118
|
+
]);
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
122
|
+
throw new Error(`ffmpeg conversion failed: ${msg}`);
|
|
123
|
+
}
|
|
95
124
|
// 3. Run whisper-cpp
|
|
96
|
-
//
|
|
97
|
-
// -l auto fails with .en models — detect and use -l en instead
|
|
125
|
+
// -l auto fails with .en models — use -l en for those
|
|
98
126
|
const isEnModel = model.includes(".en.");
|
|
99
127
|
const langArgs = isEnModel ? ["-l", "en"] : ["-l", "auto"];
|
|
128
|
+
let whisperStderr = "";
|
|
100
129
|
try {
|
|
101
|
-
await execFileAsync(whisperBin, [
|
|
130
|
+
const result = await execFileAsync(whisperBin, [
|
|
102
131
|
"-m", model,
|
|
103
132
|
"-f", wavPath,
|
|
104
133
|
"--no-timestamps",
|
|
105
134
|
...langArgs,
|
|
106
|
-
"--output-txt",
|
|
135
|
+
"--output-txt",
|
|
107
136
|
]);
|
|
137
|
+
whisperStderr = result.stderr ?? "";
|
|
108
138
|
}
|
|
109
139
|
catch (err) {
|
|
110
|
-
const
|
|
111
|
-
|
|
140
|
+
const e = err;
|
|
141
|
+
whisperStderr = e.stderr ?? "";
|
|
142
|
+
const msg = e.message ?? String(err);
|
|
143
|
+
throw new Error(`whisper-cpp failed: ${msg}\nstderr: ${whisperStderr}`);
|
|
112
144
|
}
|
|
113
|
-
//
|
|
114
|
-
|
|
145
|
+
// 4. Find output file — whisper-cpp versions differ on suffix:
|
|
146
|
+
// some write {wavPath}.txt, others write {baseName}.txt (strip .wav)
|
|
147
|
+
const txtPathFull = `${wavPath}.txt`; // /tmp/foo.wav.txt
|
|
148
|
+
const txtPathStripped = wavPath.replace(/\.wav$/, ".txt"); // /tmp/foo.txt
|
|
115
149
|
let raw = "";
|
|
116
|
-
|
|
117
|
-
|
|
150
|
+
for (const candidate of [txtPathFull, txtPathStripped]) {
|
|
151
|
+
try {
|
|
152
|
+
await access(candidate);
|
|
153
|
+
raw = await readFile(candidate, "utf-8");
|
|
154
|
+
await unlink(candidate).catch(() => { });
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
// try next
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (!raw && !whisperStderr) {
|
|
162
|
+
throw new Error("whisper-cpp ran but produced no output file (tried .wav.txt and .txt)");
|
|
118
163
|
}
|
|
119
|
-
|
|
120
|
-
|
|
164
|
+
// Use stdout text if file wasn't found but whisper printed to stderr (some versions do)
|
|
165
|
+
if (!raw) {
|
|
166
|
+
raw = whisperStderr;
|
|
121
167
|
}
|
|
122
168
|
const text = raw
|
|
123
169
|
.replace(/\[BLANK_AUDIO\]/gi, "")
|
|
124
|
-
.replace(/\[.*?\]/g, "")
|
|
170
|
+
.replace(/\[.*?\]/g, "")
|
|
125
171
|
.trim();
|
|
126
172
|
return text || "[empty transcription]";
|
|
127
173
|
}
|
|
128
174
|
finally {
|
|
129
|
-
|
|
130
|
-
await unlink(oggPath).catch(() => { });
|
|
175
|
+
await unlink(audioPath).catch(() => { });
|
|
131
176
|
await unlink(wavPath).catch(() => { });
|
|
132
|
-
await unlink(`${wavPath}.txt`).catch(() => { });
|
|
133
177
|
}
|
|
134
178
|
}
|
|
135
179
|
/**
|