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.157';
1
+ export const CLI_VERSION = '0.2.158';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runcloud",
3
- "version": "0.2.157",
3
+ "version": "0.2.158",
4
4
  "description": "Create and control run.cloud remote mobile simulators and cloud sandboxes",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
Binary file
Binary file