amv-train 0.0.0-stage → 3.0.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/lib/doctor.js ADDED
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Copyright (c) 2026 Uno Kim / AMEVA Foundation. All rights reserved.
3
+ * SPDX-License-Identifier: Apache-2.0 OR Commercial-License
4
+ */
5
+ /**
6
+ * amv-train: Hardware & Training Capacity Diagnostic Doctor
7
+ * Production-grade heuristics with free RAM safety margin calculation.
8
+ * Open-Source under Apache License 2.0.
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ const os = require('os');
14
+ const { spawnSync } = require('child_process');
15
+
16
+ // Cache holds { cmd: string, resolvedAt: number } or null on failure
17
+ // Cache TTL: 60 seconds — allows re-detection after pyenv / venv switches
18
+ const PYTHON_CACHE_TTL_MS = 60_000;
19
+ let _pythonCache = null;
20
+
21
+ function resolvePythonCmd() {
22
+ const now = Date.now();
23
+
24
+ // Return cached result if fresh
25
+ if (_pythonCache !== null && (now - _pythonCache.resolvedAt) < PYTHON_CACHE_TTL_MS) {
26
+ return _pythonCache.cmd; // May be null if Python was not found at last check
27
+ }
28
+
29
+ const candidates = ['python3', 'python', 'py'];
30
+ for (const cmd of candidates) {
31
+ try {
32
+ const res = spawnSync(cmd, ['--version'], { stdio: 'pipe', encoding: 'utf-8' });
33
+ if (!res.error && res.status === 0) {
34
+ _pythonCache = { cmd, resolvedAt: now };
35
+ return cmd;
36
+ }
37
+ } catch (_) {}
38
+ }
39
+
40
+ // Cache the negative result with TTL so repeat calls don't thrash disk
41
+ _pythonCache = { cmd: null, resolvedAt: now };
42
+ return null;
43
+ }
44
+
45
+ /** Force re-detection on the next call (useful after Python installation). */
46
+ function invalidatePythonCache() {
47
+ _pythonCache = null;
48
+ }
49
+
50
+ function runDoctor(options = {}) {
51
+ const isTermux = Boolean(process.env.TERMUX_VERSION || (process.env.PREFIX && process.env.PREFIX.includes('com.termux')));
52
+ const isAndroid = process.platform === 'android' || isTermux;
53
+ const totalRamMb = typeof os.totalmem === 'function' ? Math.round(os.totalmem() / (1024 * 1024)) : 4096;
54
+ const freeRamMb = typeof os.freemem === 'function' ? Math.round(os.freemem() / (1024 * 1024)) : 1024;
55
+ const cpus = os.cpus();
56
+ const cpuCores = Array.isArray(cpus) && cpus.length > 0 ? cpus.length : 4;
57
+
58
+ // Usable capacity = 60% of currently available free RAM (40% safety headroom)
59
+ const usableFreeRamMb = Math.max(0, Math.floor(freeRamMb * 0.6));
60
+
61
+ let tier;
62
+ let recLoraRank;
63
+ let recBatchSize;
64
+
65
+ if (usableFreeRamMb >= 4096) {
66
+ tier = 'High-End Mobile / Workstation';
67
+ recLoraRank = 16;
68
+ recBatchSize = 32;
69
+ } else if (usableFreeRamMb >= 1024) {
70
+ tier = 'Standard Mobile';
71
+ recLoraRank = 8;
72
+ recBatchSize = 16;
73
+ } else if (usableFreeRamMb >= 256) {
74
+ tier = 'Low-Memory Mobile';
75
+ recLoraRank = 4;
76
+ recBatchSize = 4;
77
+ } else {
78
+ tier = 'Critical Low-Memory (<256MB Free)';
79
+ recLoraRank = 2;
80
+ recBatchSize = 1;
81
+ }
82
+
83
+ const pyCmd = resolvePythonCmd();
84
+ let pythonAvailable = false;
85
+ let pythonVersion = 'Not Installed';
86
+ let termuxTrainPyInstalled = false;
87
+ let vulkanDetected = false;
88
+
89
+ if (pyCmd) {
90
+ try {
91
+ const pyVer = spawnSync(pyCmd, ['--version'], { stdio: 'pipe', encoding: 'utf-8' });
92
+ if (!pyVer.error && pyVer.status === 0) {
93
+ pythonAvailable = true;
94
+ pythonVersion = (pyVer.stdout || pyVer.stderr).trim();
95
+ }
96
+
97
+ const checkMod = spawnSync(
98
+ pyCmd,
99
+ ['-c', 'import amv_train as tt; b = tt.get_backend("vulkan") if "vulkan" in tt.available_backends() else None; print(getattr(b, "is_vulkan_active", False))'],
100
+ { stdio: 'pipe', encoding: 'utf-8' }
101
+ );
102
+ if (!checkMod.error && checkMod.status === 0) {
103
+ termuxTrainPyInstalled = true;
104
+ vulkanDetected = checkMod.stdout.trim().toLowerCase() === 'true';
105
+ }
106
+ } catch (_) {}
107
+ }
108
+
109
+ return {
110
+ framework: 'amv-train',
111
+ version: require('../package.json').version,
112
+ platform: {
113
+ system: process.platform,
114
+ arch: process.arch,
115
+ isTermux,
116
+ isAndroid
117
+ },
118
+ hardware: {
119
+ cpuCores,
120
+ totalRamMb,
121
+ freeRamMb,
122
+ usableFreeRamMb,
123
+ tier
124
+ },
125
+ python: {
126
+ available: pythonAvailable,
127
+ version: pythonVersion,
128
+ binary: pyCmd,
129
+ moduleInstalled: termuxTrainPyInstalled
130
+ },
131
+ vulkan: {
132
+ gpuAcceleration: vulkanDetected,
133
+ status: vulkanDetected ? 'AVAILABLE' : 'CPU_FALLBACK'
134
+ },
135
+ recommendedTrainingConfig: {
136
+ loraRank: recLoraRank,
137
+ batchSize: recBatchSize,
138
+ seqLen: 512,
139
+ safeTensorsMmap: true
140
+ },
141
+ status: pythonAvailable && termuxTrainPyInstalled ? 'READY' : 'SETUP_REQUIRED'
142
+ };
143
+ }
144
+
145
+ module.exports = {
146
+ resolvePythonCmd,
147
+ invalidatePythonCache,
148
+ runDoctor
149
+ };
package/lib/errors.js ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Copyright (c) 2026 Uno Kim / AMEVA Foundation. All rights reserved.
3
+ * SPDX-License-Identifier: Apache-2.0 OR Commercial-License
4
+ */
5
+ /**
6
+ * amv-train: Standard Error Definitions
7
+ * Open-Source under Apache License 2.0.
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ class TermuxTrainError extends Error {
13
+ constructor(message) {
14
+ super(message);
15
+ this.name = this.constructor.name;
16
+ Error.captureStackTrace(this, this.constructor);
17
+ }
18
+ }
19
+
20
+ class PythonRuntimeNotFoundError extends TermuxTrainError {
21
+ constructor(msg = 'Python runtime (python3 or python) was not found in PATH.') {
22
+ super(msg);
23
+ }
24
+ }
25
+
26
+ class TermuxTrainModuleNotFoundError extends TermuxTrainError {
27
+ constructor(msg = 'amv_train Python module is not installed or importable. Run "pip install amv-train".') {
28
+ super(msg);
29
+ }
30
+ }
31
+
32
+ class TrainingExecutionError extends TermuxTrainError {
33
+ constructor(message, exitCode = 1, stderr = '') {
34
+ super(message);
35
+ this.exitCode = exitCode;
36
+ this.stderr = stderr;
37
+ }
38
+ }
39
+
40
+ class VulkanNotAvailableError extends TermuxTrainError {
41
+ constructor(msg = 'Vulkan GPU acceleration is requested but not available on this device.') {
42
+ super(msg);
43
+ }
44
+ }
45
+
46
+ class InvalidCheckpointError extends TermuxTrainError {
47
+ constructor(path, reason = 'Corrupted or unreadable SafeTensors checkpoint.') {
48
+ super(`Invalid checkpoint at "${path}": ${reason}`);
49
+ this.path = path;
50
+ }
51
+ }
52
+
53
+ module.exports = {
54
+ TermuxTrainError,
55
+ PythonRuntimeNotFoundError,
56
+ TermuxTrainModuleNotFoundError,
57
+ TrainingExecutionError,
58
+ VulkanNotAvailableError,
59
+ InvalidCheckpointError
60
+ };
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Copyright (c) 2026 Uno Kim / AMEVA Foundation. All rights reserved.
3
+ * SPDX-License-Identifier: Apache-2.0 OR Commercial-License
4
+ */
5
+ /**
6
+ * amv-train: Multimodal & Distributed Training Node.js Bridge
7
+ * Provides asynchronous JavaScript SDK bindings for Diffusion, Vision VLM,
8
+ * Whisper STT, TTS Speaker Adaptation, and Cluster Orchestration.
9
+ * Open-Source under Apache License 2.0.
10
+ */
11
+
12
+ 'use strict';
13
+
14
+ const { spawn } = require('child_process');
15
+ const { resolvePythonCmd } = require('./doctor');
16
+ const { PythonRuntimeNotFoundError, TrainingExecutionError } = require('./errors');
17
+
18
+ function _executePythonCli(subcommand, optArgs = {}) {
19
+ return new Promise((resolve, reject) => {
20
+ const pyCmd = resolvePythonCmd();
21
+ if (!pyCmd) {
22
+ return reject(new PythonRuntimeNotFoundError('Python runtime required for multimodal training execution.'));
23
+ }
24
+
25
+ const cliArgs = ['-m', 'amv_train.cli', subcommand];
26
+ for (const [k, v] of Object.entries(optArgs)) {
27
+ if (v === null || v === undefined) continue;
28
+ const flagName = k.replace(/([A-Z])/g, '-$1').toLowerCase();
29
+ if (typeof v === 'boolean') {
30
+ if (v) cliArgs.push(`--${flagName}`);
31
+ } else {
32
+ cliArgs.push(`--${flagName}`, String(v));
33
+ }
34
+ }
35
+
36
+ const child = spawn(pyCmd, cliArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
37
+ let stdoutData = '';
38
+ let stderrData = '';
39
+
40
+ child.stdout.on('data', (d) => {
41
+ stdoutData += d.toString();
42
+ });
43
+
44
+ child.stderr.on('data', (d) => {
45
+ stderrData += d.toString();
46
+ });
47
+
48
+ child.on('error', (err) => {
49
+ reject(err);
50
+ });
51
+
52
+ child.on('close', (code) => {
53
+ if (code !== 0) {
54
+ const err = new TrainingExecutionError(`Execution of '${subcommand}' failed with exit code ${code}:\n${stderrData || stdoutData}`);
55
+ err.exitCode = code;
56
+ err.stderr = stderrData;
57
+ return reject(err);
58
+ }
59
+ resolve({
60
+ status: 'SUCCESS',
61
+ subcommand,
62
+ stdout: stdoutData.trim(),
63
+ stderr: stderrData.trim()
64
+ });
65
+ });
66
+ });
67
+ }
68
+
69
+ function trainDiffusion(options = {}) {
70
+ return _executePythonCli('diffusion-train', options);
71
+ }
72
+
73
+ function trainVision(options = {}) {
74
+ return _executePythonCli('vision-train', options);
75
+ }
76
+
77
+ function trainSTT(options = {}) {
78
+ return _executePythonCli('stt-train', options);
79
+ }
80
+
81
+ function trainTTS(options = {}) {
82
+ return _executePythonCli('tts-train', options);
83
+ }
84
+
85
+ function runClusterProbe(options = {}) {
86
+ return _executePythonCli('cluster-probe', options);
87
+ }
88
+
89
+ module.exports = {
90
+ trainDiffusion,
91
+ trainVision,
92
+ trainSTT,
93
+ trainTTS,
94
+ runClusterProbe,
95
+ };
package/lib/trainer.js ADDED
@@ -0,0 +1,350 @@
1
+ /**
2
+ * Copyright (c) 2026 Uno Kim / AMEVA Foundation. All rights reserved.
3
+ * SPDX-License-Identifier: Apache-2.0 OR Commercial-License
4
+ */
5
+ /**
6
+ * amv-train: Production-Grade On-Device Training Orchestrator
7
+ * True Dual-Engine LoRA, Transformer & MLP Training Pipeline with SafeTensors I/O.
8
+ * Open-Source under Apache License 2.0.
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ const { EventEmitter } = require('events');
14
+ const { spawn, spawnSync } = require('child_process');
15
+ const path = require('path');
16
+ const fs = require('fs');
17
+ const { resolvePythonCmd } = require('./doctor');
18
+ const {
19
+ PythonRuntimeNotFoundError,
20
+ TrainingExecutionError,
21
+ InvalidCheckpointError
22
+ } = require('./errors');
23
+
24
+ // Maximum stderr accumulation per session: 1MB cap to prevent heap exhaustion
25
+ const MAX_STDERR_BYTES = 1024 * 1024;
26
+
27
+ class TermuxTrainer extends EventEmitter {
28
+ constructor(config = {}) {
29
+ super();
30
+ // Default max listener count raised to support multi-epoch long training
31
+ this.setMaxListeners(50);
32
+
33
+ const modelType = typeof config.modelType === 'string'
34
+ ? config.modelType.toLowerCase().trim()
35
+ : 'mlp';
36
+
37
+ const dim = Number.isInteger(config.dim) && config.dim >= 4 && config.dim <= 4096
38
+ ? config.dim
39
+ : 32;
40
+
41
+ const hiddenDim = Number.isInteger(config.hiddenDim) && config.hiddenDim >= 4
42
+ ? config.hiddenDim
43
+ : dim * 2;
44
+
45
+ const outDim = Number.isInteger(config.outDim) && config.outDim >= 1
46
+ ? config.outDim
47
+ : (modelType.includes('lora') ? dim : 1);
48
+
49
+ const maxRank = Math.min(dim, outDim);
50
+ const loraRank = Number.isInteger(config.loraRank) && config.loraRank >= 1
51
+ ? Math.min(config.loraRank, maxRank)
52
+ : Math.min(4, maxRank);
53
+
54
+ const loraAlpha = typeof config.loraAlpha === 'number' && isFinite(config.loraAlpha) && config.loraAlpha > 0
55
+ ? config.loraAlpha
56
+ : 1.0;
57
+
58
+ const vocabSize = Number.isInteger(config.vocabSize) && config.vocabSize >= 2
59
+ ? config.vocabSize
60
+ : 256;
61
+
62
+ const layers = Number.isInteger(config.layers) && config.layers >= 1 && config.layers <= 32
63
+ ? config.layers
64
+ : 2;
65
+
66
+ // Determine heads: use explicit user configuration, or choose adaptive default that divides dim evenly
67
+ let heads = Number.isInteger(config.heads) && config.heads >= 1 ? config.heads : 2;
68
+
69
+ // Strict division & RoPE invariants only apply to Transformer / Attention models
70
+ if (modelType.includes('transformer') || modelType.includes('rope')) {
71
+ if (!Number.isInteger(config.heads) || config.heads < 1) {
72
+ // Adaptive default for transformer: 2 for even dim
73
+ heads = (dim % 2 === 0) ? 2 : 1;
74
+ }
75
+
76
+ if (dim % heads !== 0) {
77
+ throw new TypeError(
78
+ `Transformer model dimension (dim=${dim}) is not divisible by heads (heads=${heads}). ` +
79
+ `Choose heads that evenly divide dim (e.g., ${this._suggestHeads(dim)}).`
80
+ );
81
+ }
82
+
83
+ if (dim % 2 !== 0) {
84
+ throw new TypeError(
85
+ `Transformer models with RoPE rotary embeddings require an even dimension, got dim=${dim}. ` +
86
+ `RoPE operates on 2D coordinate pairs. Choose an even dimension (e.g., ${dim + 1}).`
87
+ );
88
+ }
89
+ const headDim = Math.floor(dim / heads);
90
+ if (headDim % 2 !== 0) {
91
+ throw new TypeError(
92
+ `head_dim = dim / heads (${dim} / ${heads} = ${headDim}) must be even for RoPE embeddings. ` +
93
+ `Choose heads such that dim / heads is even.`
94
+ );
95
+ }
96
+ }
97
+
98
+ const lr = typeof config.lr === 'number' && isFinite(config.lr) && config.lr > 0 && config.lr <= 10.0
99
+ ? config.lr
100
+ : 1e-3;
101
+
102
+ const batchSize = Number.isInteger(config.batchSize) && config.batchSize >= 1 && config.batchSize <= 1024
103
+ ? config.batchSize
104
+ : 16;
105
+
106
+ const seqLen = Number.isInteger(config.seqLen) && config.seqLen >= 2 && config.seqLen <= 2048
107
+ ? config.seqLen
108
+ : 32;
109
+
110
+ const backend = typeof config.backend === 'string'
111
+ ? config.backend.replace(/[^a-zA-Z0-9_-]/g, '').toLowerCase()
112
+ : 'auto';
113
+
114
+ this.config = {
115
+ modelType,
116
+ dim,
117
+ hiddenDim,
118
+ outDim,
119
+ loraRank,
120
+ loraAlpha,
121
+ vocabSize,
122
+ layers,
123
+ heads,
124
+ lr,
125
+ batchSize,
126
+ seqLen,
127
+ backend
128
+ };
129
+
130
+ this.process = null;
131
+ this.isRunning = false;
132
+ this._lineBuffer = '';
133
+ this._stoppedEmitted = false;
134
+ }
135
+
136
+ _suggestHeads(dim) {
137
+ const candidates = [1, 2, 4, 8, 16];
138
+ return candidates.filter(h => dim % h === 0).join(', ');
139
+ }
140
+
141
+ async fit(options = {}) {
142
+ if (this.isRunning) {
143
+ throw new Error('Training session is already running. Concurrent execution on single device is restricted.');
144
+ }
145
+
146
+ const pyCmd = resolvePythonCmd();
147
+ if (!pyCmd) {
148
+ throw new PythonRuntimeNotFoundError();
149
+ }
150
+
151
+ const rawEpochs = options.epochs !== undefined ? options.epochs : 5;
152
+ const rawLr = options.lr !== undefined ? options.lr : this.config.lr;
153
+ const checkpointPath = options.checkpointPath ? path.resolve(String(options.checkpointPath)) : null;
154
+ const dataPath = options.dataPath ? path.resolve(String(options.dataPath)) : null;
155
+
156
+ if (!Number.isInteger(rawEpochs) || rawEpochs <= 0 || rawEpochs > 100000) {
157
+ throw new TypeError(`Invalid epochs: ${rawEpochs}. Must be an integer between 1 and 100000.`);
158
+ }
159
+
160
+ if (typeof rawLr !== 'number' || !isFinite(rawLr) || rawLr <= 0 || rawLr > 10.0) {
161
+ throw new TypeError(`Invalid learning rate: ${rawLr}. Must be a finite positive number <= 10.0.`);
162
+ }
163
+
164
+ if (dataPath && !fs.existsSync(dataPath)) {
165
+ throw new Error(`Specified dataPath does not exist: "${dataPath}"`);
166
+ }
167
+
168
+ if (checkpointPath) {
169
+ const parent = path.dirname(checkpointPath);
170
+ try {
171
+ if (!fs.existsSync(parent)) {
172
+ fs.mkdirSync(parent, { recursive: true });
173
+ }
174
+ } catch (mkdirErr) {
175
+ throw new InvalidCheckpointError(checkpointPath, `Cannot create parent directory: ${mkdirErr.message}`);
176
+ }
177
+ }
178
+
179
+ const safeEpochs = Math.floor(rawEpochs);
180
+ const safeLr = Number(rawLr.toPrecision(8));
181
+
182
+ const sessionConfig = {
183
+ ...this.config,
184
+ epochs: safeEpochs,
185
+ lr: safeLr,
186
+ checkpointPath,
187
+ dataPath
188
+ };
189
+
190
+ return new Promise((resolve, reject) => {
191
+ this.isRunning = true;
192
+ this._lineBuffer = '';
193
+ this._stoppedEmitted = false;
194
+
195
+ this.process = spawn(pyCmd, ['-m', 'amv_train.runtime.runner', '--stdin-json'], {
196
+ stdio: ['pipe', 'pipe', 'pipe']
197
+ });
198
+
199
+ this.process.stdin.write(JSON.stringify(sessionConfig));
200
+ this.process.stdin.end();
201
+
202
+ let stderrBytes = 0;
203
+ let stderrAcc = '';
204
+ let checkpointInfo = null;
205
+
206
+ this.process.stdout.on('data', (chunk) => {
207
+ this._lineBuffer += chunk.toString('utf-8');
208
+ let newlineIdx;
209
+ while ((newlineIdx = this._lineBuffer.indexOf('\n')) !== -1) {
210
+ const line = this._lineBuffer.slice(0, newlineIdx).trim();
211
+ this._lineBuffer = this._lineBuffer.slice(newlineIdx + 1);
212
+
213
+ if (line.startsWith('__METRICS__:')) {
214
+ try {
215
+ const data = JSON.parse(line.slice('__METRICS__:'.length));
216
+ if (data.event === 'checkpoint') {
217
+ checkpointInfo = data;
218
+ this.emit('checkpoint', data);
219
+ } else {
220
+ this.emit('step', data);
221
+ if (data.epoch) {
222
+ this.emit('epoch', data);
223
+ }
224
+ }
225
+ } catch (parseErr) {
226
+ this.emit('warning', `Failed to parse telemetry line: ${parseErr.message}`);
227
+ }
228
+ } else if (line.startsWith('__ERROR__:')) {
229
+ this.emit('warning', `Remote backend error: ${line}`);
230
+ }
231
+ }
232
+ });
233
+
234
+ this.process.stderr.on('data', (chunk) => {
235
+ const chunkStr = chunk.toString('utf-8');
236
+ stderrBytes += Buffer.byteLength(chunkStr, 'utf-8');
237
+ if (stderrBytes <= MAX_STDERR_BYTES) {
238
+ stderrAcc += chunkStr;
239
+ }
240
+ // Beyond cap: silently drop to prevent heap exhaustion. Error is still propagated via exit code.
241
+ });
242
+
243
+ this.process.on('close', (code) => {
244
+ this.isRunning = false;
245
+ this.process = null;
246
+ this._lineBuffer = '';
247
+
248
+ if (code === 0) {
249
+ const result = {
250
+ status: 'SUCCESS',
251
+ epochs: safeEpochs,
252
+ modelType: this.config.modelType,
253
+ checkpoint: checkpointInfo
254
+ };
255
+ this.emit('complete', result);
256
+ resolve(result);
257
+ } else {
258
+ const err = new TrainingExecutionError(
259
+ `Training process terminated with exit code ${code}`,
260
+ code,
261
+ stderrAcc
262
+ );
263
+ if (this.listenerCount('error') > 0) {
264
+ this.emit('error', err);
265
+ }
266
+ reject(err);
267
+ }
268
+ });
269
+
270
+ this.process.on('error', (procErr) => {
271
+ this.isRunning = false;
272
+ this.process = null;
273
+ if (this.listenerCount('error') > 0) {
274
+ this.emit('error', procErr);
275
+ }
276
+ reject(procErr);
277
+ });
278
+ });
279
+ }
280
+
281
+ async stop() {
282
+ if (!this.process || !this.isRunning) {
283
+ return;
284
+ }
285
+
286
+ const proc = this.process;
287
+ const pid = proc.pid;
288
+
289
+ // Guard: pid must be a valid positive integer before any kill attempt
290
+ if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) {
291
+ this.isRunning = false;
292
+ this.process = null;
293
+ this._emitStopped();
294
+ return;
295
+ }
296
+
297
+ return new Promise((resolve) => {
298
+ const timer = setTimeout(() => {
299
+ try {
300
+ if (process.platform === 'win32') {
301
+ spawnSync('taskkill', ['/pid', String(pid), '/f', '/t']);
302
+ } else {
303
+ proc.kill('SIGKILL');
304
+ }
305
+ } catch (_) {}
306
+ this.isRunning = false;
307
+ this.process = null;
308
+ this._lineBuffer = '';
309
+ this._emitStopped();
310
+ resolve();
311
+ }, 2000);
312
+
313
+ proc.once('close', () => {
314
+ clearTimeout(timer);
315
+ this.isRunning = false;
316
+ this.process = null;
317
+ this._lineBuffer = '';
318
+ this._emitStopped();
319
+ resolve();
320
+ });
321
+
322
+ try {
323
+ if (process.platform === 'win32') {
324
+ spawn('taskkill', ['/pid', String(pid), '/f', '/t']);
325
+ } else {
326
+ proc.kill('SIGTERM');
327
+ }
328
+ } catch (_) {
329
+ try { proc.kill('SIGKILL'); } catch (_2) {}
330
+ }
331
+ });
332
+ }
333
+
334
+ /** Ensures 'stopped' is emitted exactly once per stop() call. */
335
+ _emitStopped() {
336
+ if (!this._stoppedEmitted) {
337
+ this._stoppedEmitted = true;
338
+ this.emit('stopped');
339
+ }
340
+ }
341
+
342
+ /** Deterministically remove all event listeners after a session. */
343
+ dispose() {
344
+ this.removeAllListeners();
345
+ }
346
+ }
347
+
348
+ module.exports = {
349
+ TermuxTrainer
350
+ };