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.
Files changed (3) hide show
  1. package/README.md +55 -14
  2. package/dist/index.js +137 -29
  3. 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 npm installed)_
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 automatically download the **GigaAM v3** model (Russian-only fast architecture model) if it's not present.
17
+ When you run the server for the first time, it will:
18
18
 
19
- It will also generate a unique Bearer API Token for your active session:
19
+ 1. **Download the GigaAM v3 model** (~100 MB) with a progress bar:
20
20
 
21
- ```bash
22
- Your API KEY is: xxxxx-xxxxx-xxxxx-xxxxx
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 > General**, select the `Remote` engine.
28
- 3. Provide the Server URL: `http://<your-server-ip>:3000`
29
- 4. Provide the generated Token.
30
- 5. All audio chunks will now be transcribed by the server!
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
- ## How it works
68
+ ## Environment Variables
33
69
 
34
- 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.
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
- ### Environment variables
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
- - `PORT` - defaults to `3000`
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
- // Set up API KEY
18
- let API_KEY = process.env.API_KEY;
19
- if (!API_KEY) {
20
- API_KEY = crypto_1.default.randomBytes(32).toString('hex');
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(`Server started without API_KEY in environment variables.`);
23
- console.log(`Generated a new token for this session.`);
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 models directory exists
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
- // Authentication middleware
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
- // Configure multer to store uploaded files in a temp directory
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 logic
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(`Downloading ${url} to ${dest}...`);
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 arrBuffer = await response.arrayBuffer();
71
- fs_1.default.writeFileSync(dest, Buffer.from(arrBuffer));
72
- console.log(`Downloaded ${dest}`);
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
- // Queue for pending transcriptions to send them sequentially to the single worker
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
- // Register resolver
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
- // The route
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(`Handy Remote Server is running on port ${port}`);
265
+ console.log(`\nHandy Remote Server is running on port ${port}`);
266
+ console.log(`Waiting for requests...\n`);
159
267
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "handy-remote-server",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Remote Transcription Server for Handy",
5
5
  "main": "dist/index.js",
6
6
  "bin": {