runcloud 0.2.157 → 0.2.158
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.
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
export async function recordSimulatorAudio(session, durationMs, signal, fetchImpl = fetch) {
|
|
3
|
+
if (!Number.isInteger(durationMs) || durationMs < 100 || durationMs > 60000)
|
|
4
|
+
throw new Error('Audio duration must be from 100ms to 60s');
|
|
5
|
+
if (session.status !== 'active' || !session.url || !session.device)
|
|
6
|
+
throw new Error('An active simulator session is required for audio');
|
|
7
|
+
const url = new URL(session.url);
|
|
8
|
+
if (!['http:', 'https:'].includes(url.protocol))
|
|
9
|
+
throw new Error('Invalid simulator audio URL');
|
|
10
|
+
url.pathname = `${url.pathname.replace(/\/+$/, '')}/helper/${encodeURIComponent(session.device)}/audio.pcm`;
|
|
11
|
+
url.hash = '';
|
|
12
|
+
const controller = new AbortController();
|
|
13
|
+
const cancel = () => controller.abort(signal?.reason);
|
|
14
|
+
if (signal?.aborted)
|
|
15
|
+
cancel();
|
|
16
|
+
else
|
|
17
|
+
signal?.addEventListener('abort', cancel, { once: true });
|
|
18
|
+
let timer = setTimeout(() => controller.abort(new Error('Simulator audio did not connect within 15 seconds')), 15000);
|
|
19
|
+
let elapsed = false;
|
|
20
|
+
let reader;
|
|
21
|
+
try {
|
|
22
|
+
const response = await fetchImpl(url.toString(), {
|
|
23
|
+
signal: controller.signal,
|
|
24
|
+
headers: { Accept: 'application/x.run-cloud.pcm', traceparent: `00-${randomBytes(16).toString('hex')}-${randomBytes(8).toString('hex')}-01` },
|
|
25
|
+
});
|
|
26
|
+
if (!response.ok || !response.body || !response.headers.get('content-type')?.startsWith('application/x.run-cloud.pcm')) {
|
|
27
|
+
await response.body?.cancel();
|
|
28
|
+
throw new Error('Audio is unavailable for this simulator. Reconnect and try again.');
|
|
29
|
+
}
|
|
30
|
+
clearTimeout(timer);
|
|
31
|
+
const bytes = Math.floor(durationMs * 44100 / 1000) * 4;
|
|
32
|
+
const wav = Buffer.alloc(44 + bytes);
|
|
33
|
+
wav.write('RIFF', 0);
|
|
34
|
+
wav.writeUInt32LE(36 + bytes, 4);
|
|
35
|
+
wav.write('WAVEfmt ', 8);
|
|
36
|
+
wav.writeUInt32LE(16, 16);
|
|
37
|
+
wav.writeUInt16LE(1, 20);
|
|
38
|
+
wav.writeUInt16LE(2, 22);
|
|
39
|
+
wav.writeUInt32LE(44100, 24);
|
|
40
|
+
wav.writeUInt32LE(44100 * 4, 28);
|
|
41
|
+
wav.writeUInt16LE(4, 32);
|
|
42
|
+
wav.writeUInt16LE(16, 34);
|
|
43
|
+
wav.write('data', 36);
|
|
44
|
+
wav.writeUInt32LE(bytes, 40);
|
|
45
|
+
const started = performance.now();
|
|
46
|
+
let writeOffset = 0;
|
|
47
|
+
let pending = Buffer.alloc(0);
|
|
48
|
+
reader = response.body.getReader();
|
|
49
|
+
timer = setTimeout(() => { elapsed = true; controller.abort(); }, durationMs);
|
|
50
|
+
try {
|
|
51
|
+
while (true) {
|
|
52
|
+
const { done, value } = await reader.read();
|
|
53
|
+
if (done)
|
|
54
|
+
throw new Error('Simulator audio disconnected before recording finished');
|
|
55
|
+
pending = Buffer.concat([pending, value]);
|
|
56
|
+
while (pending.length >= 4) {
|
|
57
|
+
const size = pending.readUInt32BE();
|
|
58
|
+
if (!size || size > 65536 || size % 4)
|
|
59
|
+
throw new Error('Invalid simulator audio frame');
|
|
60
|
+
if (pending.length < size + 4)
|
|
61
|
+
break;
|
|
62
|
+
const arrivalOffset = Math.max(0, Math.floor((performance.now() - started) * 44100 / 1000) * 4 - size);
|
|
63
|
+
writeOffset = Math.max(writeOffset, arrivalOffset);
|
|
64
|
+
if (writeOffset < bytes)
|
|
65
|
+
pending.copy(wav, 44 + writeOffset, 4, 4 + Math.min(size, bytes - writeOffset));
|
|
66
|
+
writeOffset += size;
|
|
67
|
+
pending = pending.subarray(size + 4);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
if (!elapsed || signal?.aborted)
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
return wav;
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
clearTimeout(timer);
|
|
79
|
+
controller.abort();
|
|
80
|
+
signal?.removeEventListener('abort', cancel);
|
|
81
|
+
await reader?.cancel().catch(() => undefined);
|
|
82
|
+
reader?.releaseLock();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -6,6 +6,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
6
6
|
import { createHash, randomUUID } from 'node:crypto';
|
|
7
7
|
import { ApiClient, ApiError, ApiTransportError, friendlyApiError } from '../api.js';
|
|
8
8
|
import { requireCredentials } from '../config.js';
|
|
9
|
+
import { recordSimulatorAudio } from './run-cloud-audio.js';
|
|
9
10
|
export const SIMULATOR_KEYS = [
|
|
10
11
|
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm',
|
|
11
12
|
'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z',
|
|
@@ -1040,6 +1041,30 @@ function registerSimulatorCommands(program, platform) {
|
|
|
1040
1041
|
.option('--rm', 'release the session when this command exits', false)
|
|
1041
1042
|
.option('--json', 'output JSON', false)
|
|
1042
1043
|
.addOption(new Option('--dev-host <host>', 'dev-only: target a specific simulator fleet host').hideHelp());
|
|
1044
|
+
simulator.command('audio')
|
|
1045
|
+
.description('Capture simulator speaker output as a WAV file')
|
|
1046
|
+
.argument('<id>', 'session id')
|
|
1047
|
+
.requiredOption('--output <file>', 'destination WAV file')
|
|
1048
|
+
.option('--duration <seconds>', 'recording duration in seconds, from 0.1 to 60', '10')
|
|
1049
|
+
.option('--json', 'output JSON', false)
|
|
1050
|
+
.action((id, opts) => action(async () => {
|
|
1051
|
+
const durationMs = Number(opts.duration) * 1000;
|
|
1052
|
+
if (!Number.isInteger(durationMs) || durationMs < 100 || durationMs > 60000)
|
|
1053
|
+
throw new Error('--duration must be a number of seconds from 0.1 to 60');
|
|
1054
|
+
const session = await client().get(`/run-cloud/${platform}/${encodeURIComponent(id)}`);
|
|
1055
|
+
const controller = new AbortController();
|
|
1056
|
+
const cancel = () => controller.abort(new Error('Audio recording cancelled'));
|
|
1057
|
+
process.once('SIGINT', cancel);
|
|
1058
|
+
try {
|
|
1059
|
+
const wav = await recordSimulatorAudio(session, durationMs, controller.signal);
|
|
1060
|
+
const output = resolve(opts.output);
|
|
1061
|
+
writeFileSync(output, wav, { flag: 'wx' });
|
|
1062
|
+
print({ output, durationMs, sampleRate: 44100, channels: 2, byteSize: wav.length }, opts);
|
|
1063
|
+
}
|
|
1064
|
+
finally {
|
|
1065
|
+
process.removeListener('SIGINT', cancel);
|
|
1066
|
+
}
|
|
1067
|
+
}, opts));
|
|
1043
1068
|
createCommand
|
|
1044
1069
|
.action((opts) => action(async () => {
|
|
1045
1070
|
print(await createSimulatorSession(platform, opts), opts);
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const CLI_VERSION = '0.2.
|
|
1
|
+
export const CLI_VERSION = '0.2.158';
|
package/package.json
CHANGED
|
Binary file
|
package/runcloud-0.2.157.tgz
DELETED
|
Binary file
|