handy-remote-server 1.0.0 β 1.2.0
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/README.md +55 -14
- package/dist/index.js +137 -29
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,30 +10,71 @@ The easiest way to run the external inference server is using `npx`:
|
|
|
10
10
|
npx handy-remote-server
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
_(You must have Node.js and
|
|
13
|
+
_(You must have Node.js and Rust/Cargo installed)_
|
|
14
14
|
|
|
15
15
|
## Usage
|
|
16
16
|
|
|
17
|
-
When you run the server for the first time, it will
|
|
17
|
+
When you run the server for the first time, it will:
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
1. **Download the GigaAM v3 model** (~100 MB) with a progress bar:
|
|
20
20
|
|
|
21
|
-
```
|
|
22
|
-
|
|
21
|
+
```
|
|
22
|
+
π₯ Downloading model...
|
|
23
|
+
URL: https://blob.handy.computer/giga-am-v3.int8.onnx
|
|
24
|
+
Dest: /path/to/models/gigaam.onnx
|
|
25
|
+
|
|
26
|
+
ββββββββββββββββββββββββββββββββββββββββ 52.3% 52.10 MB / 99.60 MB 12.5 MB/s
|
|
27
|
+
|
|
28
|
+
β
Download complete in 8.2s
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
2. **Generate a persistent API key** saved to `~/.handy/api_key`:
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
======================================================
|
|
35
|
+
Generated a new API KEY (saved to /Users/you/.handy/api_key)
|
|
36
|
+
Your API KEY is: xxxxx...xxxxx
|
|
37
|
+
======================================================
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The key persists across restarts. On the next launch, it will be loaded automatically.
|
|
41
|
+
|
|
42
|
+
3. **Start the server** and log every request in detail:
|
|
43
|
+
|
|
44
|
+
```
|
|
23
45
|
Handy Remote Server is running on port 3000
|
|
46
|
+
|
|
47
|
+
[2026-03-07T12:00:00.000Z] ββ REQUEST #1 ββββββββββββββββββββββ
|
|
48
|
+
Method: POST /transcribe
|
|
49
|
+
From: 192.168.1.5
|
|
50
|
+
Auth: OK
|
|
51
|
+
[#1] Audio received: 156.3 KB
|
|
52
|
+
[#1] Queued for inference (queue length: 0)
|
|
53
|
+
[2026-03-07T12:00:01.234Z] ββ RESPONSE #1 βββββββββββββββββββββ
|
|
54
|
+
Status: 200
|
|
55
|
+
Duration: 1.23s
|
|
56
|
+
Result: "ΠΡΠΈΠ²Π΅Ρ, ΠΊΠ°ΠΊ Π΄Π΅Π»Π°?"
|
|
24
57
|
```
|
|
25
58
|
|
|
59
|
+
### Connecting from Handy
|
|
60
|
+
|
|
26
61
|
1. Open **Handy** on your client machine.
|
|
27
|
-
2. Go to **Settings >
|
|
28
|
-
3.
|
|
29
|
-
|
|
30
|
-
|
|
62
|
+
2. Go to **Settings > Models**, select **Remote Server**.
|
|
63
|
+
3. Go to **Settings > General**, fill in:
|
|
64
|
+
- **Remote Server URL**: `http://<your-server-ip>:3000`
|
|
65
|
+
- **API Token**: the generated token
|
|
66
|
+
4. All transcriptions will now be processed by the server!
|
|
31
67
|
|
|
32
|
-
##
|
|
68
|
+
## Environment Variables
|
|
33
69
|
|
|
34
|
-
|
|
70
|
+
| Variable | Default | Description |
|
|
71
|
+
| ---------------- | ------------------------------------------- | ------------------------------- |
|
|
72
|
+
| `PORT` | `3000` | Server port |
|
|
73
|
+
| `API_KEY` | auto-generated, saved to `~/.handy/api_key` | Bearer token for authentication |
|
|
74
|
+
| `INFER_CLI_PATH` | auto-detected | Path to the `rust-infer` binary |
|
|
35
75
|
|
|
36
|
-
|
|
76
|
+
## How It Works
|
|
77
|
+
|
|
78
|
+
The `handy-remote-server` spins up a tiny Express server alongside a heavily optimized Rust CLI (`rust-infer`) powered by `transcribe-rs`. Audio files are dispatched sequentially from the Node server directly into the Rust engine.
|
|
37
79
|
|
|
38
|
-
|
|
39
|
-
- `API_KEY` - defaults to an auto-generated token in development. Set this to a permanent token for production.
|
|
80
|
+
Currently the server uses the **GigaAM v3** model (Russian-language, fast inference, ~100 MB).
|
package/dist/index.js
CHANGED
|
@@ -10,42 +10,115 @@ const child_process_1 = require("child_process");
|
|
|
10
10
|
const crypto_1 = __importDefault(require("crypto"));
|
|
11
11
|
const path_1 = __importDefault(require("path"));
|
|
12
12
|
const fs_1 = __importDefault(require("fs"));
|
|
13
|
+
const os_1 = __importDefault(require("os"));
|
|
13
14
|
const dotenv_1 = __importDefault(require("dotenv"));
|
|
14
15
|
dotenv_1.default.config();
|
|
15
16
|
const app = (0, express_1.default)();
|
|
16
17
|
const port = process.env.PORT || 3000;
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
// ββ Persistent API Key ββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
19
|
+
const handyDir = path_1.default.join(os_1.default.homedir(), '.handy');
|
|
20
|
+
const keyFilePath = path_1.default.join(handyDir, 'api_key');
|
|
21
|
+
function loadOrCreateApiKey() {
|
|
22
|
+
// 1. Env var takes priority
|
|
23
|
+
if (process.env.API_KEY) {
|
|
24
|
+
return process.env.API_KEY;
|
|
25
|
+
}
|
|
26
|
+
// 2. Try to load from cached file
|
|
27
|
+
if (fs_1.default.existsSync(keyFilePath)) {
|
|
28
|
+
const cached = fs_1.default.readFileSync(keyFilePath, 'utf-8').trim();
|
|
29
|
+
if (cached.length > 0) {
|
|
30
|
+
console.log(`\n======================================================`);
|
|
31
|
+
console.log(`Loaded API KEY from ${keyFilePath}`);
|
|
32
|
+
console.log(`Your API KEY is: ${cached}`);
|
|
33
|
+
console.log(`======================================================\n`);
|
|
34
|
+
return cached;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// 3. Generate a new one and persist it
|
|
38
|
+
const newKey = crypto_1.default.randomBytes(32).toString('hex');
|
|
39
|
+
fs_1.default.mkdirSync(handyDir, { recursive: true });
|
|
40
|
+
fs_1.default.writeFileSync(keyFilePath, newKey + '\n', { mode: 0o600 });
|
|
21
41
|
console.log(`\n======================================================`);
|
|
22
|
-
console.log(`
|
|
23
|
-
console.log(`
|
|
24
|
-
console.log(`Your API KEY is: ${API_KEY}`);
|
|
42
|
+
console.log(`Generated a new API KEY (saved to ${keyFilePath})`);
|
|
43
|
+
console.log(`Your API KEY is: ${newKey}`);
|
|
25
44
|
console.log(`======================================================\n`);
|
|
45
|
+
return newKey;
|
|
46
|
+
}
|
|
47
|
+
const API_KEY = loadOrCreateApiKey();
|
|
48
|
+
// ββ Logging helpers βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
49
|
+
function timestamp() {
|
|
50
|
+
return new Date().toISOString();
|
|
51
|
+
}
|
|
52
|
+
function formatBytes(bytes) {
|
|
53
|
+
if (bytes < 1024)
|
|
54
|
+
return `${bytes} B`;
|
|
55
|
+
if (bytes < 1024 * 1024)
|
|
56
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
57
|
+
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
|
58
|
+
}
|
|
59
|
+
function formatDuration(ms) {
|
|
60
|
+
if (ms < 1000)
|
|
61
|
+
return `${ms}ms`;
|
|
62
|
+
return `${(ms / 1000).toFixed(2)}s`;
|
|
26
63
|
}
|
|
27
|
-
// Ensure
|
|
64
|
+
// ββ Ensure directories ββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
28
65
|
const modelsDir = path_1.default.join(__dirname, '..', 'models');
|
|
29
66
|
if (!fs_1.default.existsSync(modelsDir)) {
|
|
30
67
|
fs_1.default.mkdirSync(modelsDir, { recursive: true });
|
|
31
68
|
}
|
|
32
|
-
|
|
69
|
+
const uploadDir = path_1.default.join(__dirname, '..', 'uploads');
|
|
70
|
+
if (!fs_1.default.existsSync(uploadDir)) {
|
|
71
|
+
fs_1.default.mkdirSync(uploadDir, { recursive: true });
|
|
72
|
+
}
|
|
73
|
+
// ββ Request logging middleware ββββββββββββββββββββββββββββββββββββββββ
|
|
74
|
+
let requestCounter = 0;
|
|
75
|
+
app.use((req, res, next) => {
|
|
76
|
+
const reqId = ++requestCounter;
|
|
77
|
+
const start = Date.now();
|
|
78
|
+
const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress || 'unknown';
|
|
79
|
+
console.log(`\n[${timestamp()}] ββ REQUEST #${reqId} ββββββββββββββββββββββ`);
|
|
80
|
+
console.log(` Method: ${req.method} ${req.path}`);
|
|
81
|
+
console.log(` From: ${ip}`);
|
|
82
|
+
console.log(` Headers: Content-Type=${req.headers['content-type'] || 'N/A'}, Content-Length=${req.headers['content-length'] || 'N/A'}`);
|
|
83
|
+
// Store metadata on request for later use
|
|
84
|
+
req._reqId = reqId;
|
|
85
|
+
req._startTime = start;
|
|
86
|
+
req._ip = ip;
|
|
87
|
+
const originalJson = res.json.bind(res);
|
|
88
|
+
res.json = function (body) {
|
|
89
|
+
const duration = Date.now() - start;
|
|
90
|
+
const status = res.statusCode;
|
|
91
|
+
console.log(`[${timestamp()}] ββ RESPONSE #${reqId} βββββββββββββββββββββ`);
|
|
92
|
+
console.log(` Status: ${status}`);
|
|
93
|
+
console.log(` Duration: ${formatDuration(duration)}`);
|
|
94
|
+
if (body?.text) {
|
|
95
|
+
const preview = body.text.length > 100 ? body.text.substring(0, 100) + '...' : body.text;
|
|
96
|
+
console.log(` Result: "${preview}"`);
|
|
97
|
+
}
|
|
98
|
+
else if (body?.error) {
|
|
99
|
+
console.log(` Error: ${body.error}`);
|
|
100
|
+
}
|
|
101
|
+
console.log(` ββββββββββββββββββββββββββββββββββββββββββββββββ`);
|
|
102
|
+
return originalJson(body);
|
|
103
|
+
};
|
|
104
|
+
next();
|
|
105
|
+
});
|
|
106
|
+
// ββ Authentication middleware βββββββββββββββββββββββββββββββββββββββββ
|
|
33
107
|
app.use((req, res, next) => {
|
|
34
108
|
const authHeader = req.headers.authorization;
|
|
35
109
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
110
|
+
console.log(` Auth: REJECTED (missing/invalid Authorization header)`);
|
|
36
111
|
return res.status(401).json({ error: 'Missing or invalid Authorization header' });
|
|
37
112
|
}
|
|
38
113
|
const token = authHeader.split(' ')[1];
|
|
39
114
|
if (token !== API_KEY) {
|
|
115
|
+
console.log(` Auth: REJECTED (invalid key)`);
|
|
40
116
|
return res.status(403).json({ error: 'Invalid API Key' });
|
|
41
117
|
}
|
|
118
|
+
console.log(` Auth: OK`);
|
|
42
119
|
next();
|
|
43
120
|
});
|
|
44
|
-
//
|
|
45
|
-
const uploadDir = path_1.default.join(__dirname, '..', 'uploads');
|
|
46
|
-
if (!fs_1.default.existsSync(uploadDir)) {
|
|
47
|
-
fs_1.default.mkdirSync(uploadDir, { recursive: true });
|
|
48
|
-
}
|
|
121
|
+
// ββ Multer storage ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
49
122
|
const storage = multer_1.default.diskStorage({
|
|
50
123
|
destination: function (req, file, cb) {
|
|
51
124
|
cb(null, uploadDir);
|
|
@@ -56,20 +129,52 @@ const storage = multer_1.default.diskStorage({
|
|
|
56
129
|
}
|
|
57
130
|
});
|
|
58
131
|
const upload = (0, multer_1.default)({ storage: storage });
|
|
59
|
-
// Model download
|
|
132
|
+
// ββ Model download βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
60
133
|
const GIGAAM_MODEL_URL = 'https://blob.handy.computer/giga-am-v3.int8.onnx';
|
|
61
134
|
const gigaamModelPath = path_1.default.join(modelsDir, 'gigaam.onnx');
|
|
62
135
|
async function downloadFile(url, dest) {
|
|
63
136
|
if (fs_1.default.existsSync(dest))
|
|
64
137
|
return;
|
|
65
|
-
console.log(
|
|
138
|
+
console.log(`\nπ₯ Downloading model...`);
|
|
139
|
+
console.log(` URL: ${url}`);
|
|
140
|
+
console.log(` Dest: ${dest}\n`);
|
|
66
141
|
fs_1.default.mkdirSync(path_1.default.dirname(dest), { recursive: true });
|
|
67
142
|
const response = await fetch(url);
|
|
68
143
|
if (!response.ok)
|
|
69
144
|
throw new Error(`Failed to fetch ${url}: ${response.statusText}`);
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
145
|
+
const totalBytes = parseInt(response.headers.get('content-length') || '0', 10);
|
|
146
|
+
let downloadedBytes = 0;
|
|
147
|
+
const startTime = Date.now();
|
|
148
|
+
const fileStream = fs_1.default.createWriteStream(dest);
|
|
149
|
+
const reader = response.body?.getReader();
|
|
150
|
+
if (!reader)
|
|
151
|
+
throw new Error('Response body is not readable');
|
|
152
|
+
const barWidth = 40;
|
|
153
|
+
while (true) {
|
|
154
|
+
const { done, value } = await reader.read();
|
|
155
|
+
if (done)
|
|
156
|
+
break;
|
|
157
|
+
fileStream.write(Buffer.from(value));
|
|
158
|
+
downloadedBytes += value.length;
|
|
159
|
+
// Draw progress bar
|
|
160
|
+
const percent = totalBytes > 0 ? downloadedBytes / totalBytes : 0;
|
|
161
|
+
const filled = Math.round(barWidth * percent);
|
|
162
|
+
const empty = barWidth - filled;
|
|
163
|
+
const bar = 'β'.repeat(filled) + 'β'.repeat(empty);
|
|
164
|
+
const pct = (percent * 100).toFixed(1).padStart(5);
|
|
165
|
+
const dl = formatBytes(downloadedBytes);
|
|
166
|
+
const tot = totalBytes > 0 ? formatBytes(totalBytes) : '?';
|
|
167
|
+
const elapsed = (Date.now() - startTime) / 1000;
|
|
168
|
+
const speed = elapsed > 0 ? formatBytes(downloadedBytes / elapsed) + '/s' : '';
|
|
169
|
+
process.stdout.write(`\r ${bar} ${pct}% ${dl} / ${tot} ${speed} `);
|
|
170
|
+
}
|
|
171
|
+
await new Promise((resolve, reject) => {
|
|
172
|
+
fileStream.end(() => resolve());
|
|
173
|
+
fileStream.on('error', reject);
|
|
174
|
+
});
|
|
175
|
+
const totalTime = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
176
|
+
process.stdout.write('\n');
|
|
177
|
+
console.log(`\nβ
Download complete in ${totalTime}s\n`);
|
|
73
178
|
}
|
|
74
179
|
async function ensureModels() {
|
|
75
180
|
await downloadFile(GIGAAM_MODEL_URL, gigaamModelPath);
|
|
@@ -78,7 +183,6 @@ let inferProcess = null;
|
|
|
78
183
|
let isReady = false;
|
|
79
184
|
let resolvers = {};
|
|
80
185
|
ensureModels().then(() => {
|
|
81
|
-
// Spawn the rust background process
|
|
82
186
|
let inferProcessPath = process.env.INFER_CLI_PATH || path_1.default.join(__dirname, '..', 'rust-infer', 'target', 'release', 'rust-infer');
|
|
83
187
|
if (!fs_1.default.existsSync(inferProcessPath)) {
|
|
84
188
|
inferProcessPath = path_1.default.join(__dirname, '..', 'rust-infer', 'target', 'debug', 'rust-infer');
|
|
@@ -114,7 +218,7 @@ ensureModels().then(() => {
|
|
|
114
218
|
console.error('Failed to download models:', e);
|
|
115
219
|
process.exit(1);
|
|
116
220
|
});
|
|
117
|
-
//
|
|
221
|
+
// ββ Request queue βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
118
222
|
const requestQueue = [];
|
|
119
223
|
let isProcessing = false;
|
|
120
224
|
function processQueue() {
|
|
@@ -122,38 +226,42 @@ function processQueue() {
|
|
|
122
226
|
return;
|
|
123
227
|
isProcessing = true;
|
|
124
228
|
const req = requestQueue.shift();
|
|
125
|
-
|
|
229
|
+
console.log(` [Queue] Processing request #${req.reqId} (queue length: ${requestQueue.length})`);
|
|
126
230
|
resolvers[req.file] = (result) => {
|
|
127
231
|
delete resolvers[req.file];
|
|
128
232
|
isProcessing = false;
|
|
129
|
-
// Clean up temp file
|
|
130
233
|
if (fs_1.default.existsSync(req.file)) {
|
|
131
234
|
fs_1.default.unlinkSync(req.file);
|
|
132
235
|
}
|
|
133
236
|
req.resolve(result);
|
|
134
|
-
// Process next
|
|
135
237
|
process.nextTick(processQueue);
|
|
136
238
|
};
|
|
137
|
-
// Send path to worker via stdin
|
|
138
239
|
inferProcess.stdin.write(req.file + '\n');
|
|
139
240
|
}
|
|
140
|
-
//
|
|
241
|
+
// ββ Transcription endpoint ββββββββββββββββββββββββββββββββββββββββββββ
|
|
141
242
|
app.post('/transcribe', express_1.default.raw({ type: 'audio/wav', limit: '50mb' }), async (req, res) => {
|
|
243
|
+
const reqId = req._reqId || 0;
|
|
142
244
|
if (!isReady) {
|
|
245
|
+
console.log(` [#${reqId}] Rejected: models still loading`);
|
|
143
246
|
return res.status(503).json({ error: 'Models are still loading' });
|
|
144
247
|
}
|
|
145
|
-
// If using express.raw, the body is a Buffer
|
|
146
248
|
if (!req.body || !Buffer.isBuffer(req.body)) {
|
|
249
|
+
console.log(` [#${reqId}] Rejected: invalid audio body`);
|
|
147
250
|
return res.status(400).json({ error: 'Invalid audio body. Send raw WAV bytes with Content-Type: audio/wav' });
|
|
148
251
|
}
|
|
252
|
+
const audioSize = req.body.length;
|
|
253
|
+
console.log(` [#${reqId}] Audio received: ${formatBytes(audioSize)}`);
|
|
149
254
|
const tempFilePath = path_1.default.join(uploadDir, `upload-${Date.now()}-${Math.random().toString(36).substring(7)}.wav`);
|
|
150
255
|
fs_1.default.writeFileSync(tempFilePath, req.body);
|
|
256
|
+
console.log(` [#${reqId}] Queued for inference (queue length: ${requestQueue.length})`);
|
|
151
257
|
const result = await new Promise((resolve) => {
|
|
152
|
-
requestQueue.push({ file: tempFilePath, resolve });
|
|
258
|
+
requestQueue.push({ file: tempFilePath, resolve, reqId });
|
|
153
259
|
processQueue();
|
|
154
260
|
});
|
|
155
261
|
res.json(result);
|
|
156
262
|
});
|
|
263
|
+
// ββ Start server ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
157
264
|
app.listen(port, () => {
|
|
158
|
-
console.log(
|
|
265
|
+
console.log(`\nHandy Remote Server is running on port ${port}`);
|
|
266
|
+
console.log(`Waiting for requests...\n`);
|
|
159
267
|
});
|