@yodaos-pkg/ink 0.17.0 → 0.17.1

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/index.js CHANGED
@@ -52,6 +52,22 @@ function normalizeOptionalFiniteNumber(value) {
52
52
  return Number.isFinite(numericValue) ? numericValue : null;
53
53
  }
54
54
 
55
+ function normalizeOptionalPositiveInteger(value) {
56
+ if (value == null) {
57
+ return null;
58
+ }
59
+ const numericValue = Math.trunc(Number(value));
60
+ return Number.isFinite(numericValue) && numericValue > 0 ? numericValue : null;
61
+ }
62
+
63
+ function normalizeOptionalPositiveNumber(value) {
64
+ if (value == null) {
65
+ return null;
66
+ }
67
+ const numericValue = Number(value);
68
+ return Number.isFinite(numericValue) && numericValue > 0 ? numericValue : null;
69
+ }
70
+
55
71
  function normalizeOptionalNavigatorHostField(value, fieldName) {
56
72
  if (value == null) {
57
73
  return null;
@@ -859,6 +875,182 @@ function normalizePhotoResult(result) {
859
875
  return { data, mimeType };
860
876
  }
861
877
 
878
+ function normalizeMediaDeviceInfo(device, index) {
879
+ if (device == null || typeof device !== 'object' || Array.isArray(device)) {
880
+ throw new TypeError(
881
+ `\`media.enumerateDevices()\` must return an array of media device objects. Invalid entry at index ${index}.`,
882
+ );
883
+ }
884
+
885
+ const deviceId = String(device.deviceId || '').trim();
886
+ if (!deviceId) {
887
+ throw new TypeError(
888
+ `\`media.enumerateDevices()\` entry ${index} must provide a non-empty \`deviceId\`.`,
889
+ );
890
+ }
891
+
892
+ const kind = String(device.kind || '').trim();
893
+ if (kind !== 'audioinput' && kind !== 'videoinput') {
894
+ throw new TypeError(
895
+ `\`media.enumerateDevices()\` entry ${index} must provide kind \`audioinput\` or \`videoinput\`.`,
896
+ );
897
+ }
898
+
899
+ return {
900
+ deviceId,
901
+ kind,
902
+ label: String(device.label || ''),
903
+ groupId: device.groupId == null ? '' : String(device.groupId),
904
+ };
905
+ }
906
+
907
+ function serializeEnumerateDevicesResponse(result) {
908
+ if (!Array.isArray(result)) {
909
+ throw new TypeError('`media.enumerateDevices()` must resolve to an array.');
910
+ }
911
+
912
+ return serializeIpcResponseData({
913
+ type: 'Media',
914
+ data: {
915
+ type: 'EnumerateDevicesResult',
916
+ data: result.map((device, index) => normalizeMediaDeviceInfo(device, index)),
917
+ },
918
+ });
919
+ }
920
+
921
+ function normalizeMediaTrackSettings(settings, methodName) {
922
+ if (settings == null || typeof settings !== 'object' || Array.isArray(settings)) {
923
+ throw new TypeError(`\`${methodName}\` must provide a \`settings\` object for each track.`);
924
+ }
925
+
926
+ const normalized = {};
927
+ const deviceId = settings.deviceId == null ? null : String(settings.deviceId).trim();
928
+ const facingMode =
929
+ settings.facingMode == null ? null : String(settings.facingMode).trim() || null;
930
+ const sampleRate = normalizeOptionalPositiveInteger(settings.sampleRate);
931
+ const channelCount = normalizeOptionalPositiveInteger(settings.channelCount);
932
+ const width = normalizeOptionalPositiveInteger(settings.width);
933
+ const height = normalizeOptionalPositiveInteger(settings.height);
934
+ const frameRate = normalizeOptionalPositiveNumber(settings.frameRate);
935
+
936
+ if (deviceId) {
937
+ normalized.deviceId = deviceId;
938
+ }
939
+ if (sampleRate != null) {
940
+ normalized.sampleRate = sampleRate;
941
+ }
942
+ if (channelCount != null) {
943
+ normalized.channelCount = channelCount;
944
+ }
945
+ if (settings.echoCancellation != null) {
946
+ normalized.echoCancellation = Boolean(settings.echoCancellation);
947
+ }
948
+ if (facingMode) {
949
+ normalized.facingMode = facingMode;
950
+ }
951
+ if (width != null) {
952
+ normalized.width = width;
953
+ }
954
+ if (height != null) {
955
+ normalized.height = height;
956
+ }
957
+ if (frameRate != null) {
958
+ normalized.frameRate = frameRate;
959
+ }
960
+
961
+ return normalized;
962
+ }
963
+
964
+ function normalizeMediaTrackDescriptor(track, index, methodName) {
965
+ if (track == null || typeof track !== 'object' || Array.isArray(track)) {
966
+ throw new TypeError(
967
+ `\`${methodName}\` must provide track objects. Invalid entry at index ${index}.`,
968
+ );
969
+ }
970
+
971
+ const trackId = String(track.trackId || '').trim();
972
+ if (!trackId) {
973
+ throw new TypeError(`\`${methodName}\` track ${index} must provide a non-empty \`trackId\`.`);
974
+ }
975
+
976
+ const kind = String(track.kind || '').trim();
977
+ if (kind !== 'audio' && kind !== 'video') {
978
+ throw new TypeError(
979
+ `\`${methodName}\` track ${index} must provide kind \`audio\` or \`video\`.`,
980
+ );
981
+ }
982
+
983
+ return {
984
+ trackId,
985
+ kind,
986
+ label: String(track.label || ''),
987
+ enabled: Boolean(track.enabled),
988
+ muted: Boolean(track.muted),
989
+ settings: normalizeMediaTrackSettings(track.settings, methodName),
990
+ };
991
+ }
992
+
993
+ function normalizeMediaStreamDescriptor(result, methodName = 'media.getUserMedia()') {
994
+ if (result == null || typeof result !== 'object' || Array.isArray(result)) {
995
+ throw new TypeError(`\`${methodName}\` must resolve to a media stream descriptor object.`);
996
+ }
997
+
998
+ const streamId = String(result.streamId || '').trim();
999
+ if (!streamId) {
1000
+ throw new TypeError(`\`${methodName}\` must provide a non-empty \`streamId\`.`);
1001
+ }
1002
+ if (!Array.isArray(result.tracks)) {
1003
+ throw new TypeError(`\`${methodName}\` must provide a \`tracks\` array.`);
1004
+ }
1005
+
1006
+ return {
1007
+ streamId,
1008
+ tracks: result.tracks.map((track, index) =>
1009
+ normalizeMediaTrackDescriptor(track, index, methodName),
1010
+ ),
1011
+ };
1012
+ }
1013
+
1014
+ function serializeGetUserMediaResponse(result) {
1015
+ return serializeIpcResponseData({
1016
+ type: 'Media',
1017
+ data: {
1018
+ type: 'GetUserMediaResult',
1019
+ data: normalizeMediaStreamDescriptor(result),
1020
+ },
1021
+ });
1022
+ }
1023
+
1024
+ function normalizeMediaRecorderDescriptor(result) {
1025
+ if (result == null || typeof result !== 'object' || Array.isArray(result)) {
1026
+ throw new TypeError(
1027
+ '`media.createMediaRecorder()` must resolve to a media recorder descriptor object.',
1028
+ );
1029
+ }
1030
+
1031
+ const recorderId = String(result.recorderId || '').trim();
1032
+ if (!recorderId) {
1033
+ throw new TypeError('`media.createMediaRecorder()` must provide a non-empty `recorderId`.');
1034
+ }
1035
+
1036
+ const mimeType = String(result.mimeType || '').trim();
1037
+ if (!mimeType) {
1038
+ throw new TypeError('`media.createMediaRecorder()` must provide a non-empty `mimeType`.');
1039
+ }
1040
+
1041
+ return { recorderId, mimeType };
1042
+ }
1043
+
1044
+ function serializeCreateMediaRecorderResponse(result) {
1045
+ return serializeIpcResponseData({
1046
+ type: 'Media',
1047
+ data: {
1048
+ type: 'CreateMediaRecorderResult',
1049
+ data: normalizeMediaRecorderDescriptor(result),
1050
+ },
1051
+ });
1052
+ }
1053
+
862
1054
  function serializeTakePhotoResponse(result) {
863
1055
  const photo = normalizePhotoResult(result);
864
1056
  return serializeIpcResponseData({
@@ -960,10 +1152,15 @@ const HOST_CAPABILITY_SERIALIZERS = {
960
1152
  stop: () => serializeSuccessResponse(),
961
1153
  },
962
1154
  media: {
963
- startAudioRecording: () => serializeSuccessResponse(),
964
- stopAudioRecording: () => serializeSuccessResponse(),
965
- pauseAudioRecording: () => serializeSuccessResponse(),
966
- resumeAudioRecording: () => serializeSuccessResponse(),
1155
+ enumerateDevices: (result) => serializeEnumerateDevicesResponse(result),
1156
+ getUserMedia: (result) => serializeGetUserMediaResponse(result),
1157
+ stopMediaTrack: () => serializeSuccessResponse(),
1158
+ createMediaRecorder: (result) => serializeCreateMediaRecorderResponse(result),
1159
+ startMediaRecorder: () => serializeSuccessResponse(),
1160
+ pauseMediaRecorder: () => serializeSuccessResponse(),
1161
+ resumeMediaRecorder: () => serializeSuccessResponse(),
1162
+ requestMediaRecorderData: () => serializeSuccessResponse(),
1163
+ stopMediaRecorder: () => serializeSuccessResponse(),
967
1164
  takePhoto: (result) => serializeTakePhotoResponse(result),
968
1165
  },
969
1166
  openService: {
@@ -975,16 +1172,657 @@ const HOST_CAPABILITY_SERIALIZERS = {
975
1172
  },
976
1173
  };
977
1174
 
978
- function normalizeHostCapabilities(capabilities) {
1175
+ function getBrowserMediaDevices() {
1176
+ const mediaDevices = globalThis.navigator?.mediaDevices;
1177
+ if (!mediaDevices || typeof mediaDevices !== 'object') {
1178
+ return null;
1179
+ }
1180
+ return mediaDevices;
1181
+ }
1182
+
1183
+ function getBrowserMediaRecorderConstructor() {
1184
+ return typeof globalThis.MediaRecorder === 'function' ? globalThis.MediaRecorder : null;
1185
+ }
1186
+
1187
+ function dispatchHostCapabilityCustomEvent(target, eventType, detail) {
1188
+ if (!target || typeof target.dispatchEvent !== 'function' || typeof CustomEvent !== 'function') {
1189
+ return;
1190
+ }
1191
+ target.dispatchEvent(new CustomEvent(eventType, { detail }));
1192
+ }
1193
+
1194
+ function normalizeBrowserTrackConstraints(constraints) {
1195
+ if (!constraints || typeof constraints !== 'object') {
1196
+ return true;
1197
+ }
1198
+
1199
+ const normalized = {};
1200
+ if (constraints.deviceId) {
1201
+ normalized.deviceId = constraints.deviceId;
1202
+ }
1203
+ if (constraints.sampleRate != null) {
1204
+ normalized.sampleRate = constraints.sampleRate;
1205
+ }
1206
+ if (constraints.channelCount != null) {
1207
+ normalized.channelCount = constraints.channelCount;
1208
+ }
1209
+ if (constraints.echoCancellation != null) {
1210
+ normalized.echoCancellation = Boolean(constraints.echoCancellation);
1211
+ }
1212
+ if (constraints.facingMode) {
1213
+ normalized.facingMode = constraints.facingMode;
1214
+ }
1215
+ if (constraints.width != null) {
1216
+ normalized.width = constraints.width;
1217
+ }
1218
+ if (constraints.height != null) {
1219
+ normalized.height = constraints.height;
1220
+ }
1221
+ if (constraints.frameRate != null) {
1222
+ normalized.frameRate = constraints.frameRate;
1223
+ }
1224
+ return Object.keys(normalized).length > 0 ? normalized : true;
1225
+ }
1226
+
1227
+ function normalizeBrowserMediaTrackDescriptor(track) {
1228
+ const settings = typeof track?.getSettings === 'function' ? track.getSettings() || {} : {};
1229
+ const normalizedSettings = {};
1230
+ if (settings.deviceId) {
1231
+ normalizedSettings.deviceId = String(settings.deviceId);
1232
+ }
1233
+ if (settings.sampleRate != null) {
1234
+ normalizedSettings.sampleRate = Math.max(1, Math.trunc(Number(settings.sampleRate) || 0));
1235
+ }
1236
+ if (settings.channelCount != null) {
1237
+ normalizedSettings.channelCount = Math.max(1, Math.trunc(Number(settings.channelCount) || 0));
1238
+ }
1239
+ if (settings.echoCancellation != null) {
1240
+ normalizedSettings.echoCancellation = Boolean(settings.echoCancellation);
1241
+ }
1242
+ if (settings.facingMode) {
1243
+ normalizedSettings.facingMode = String(settings.facingMode);
1244
+ }
1245
+ if (settings.width != null) {
1246
+ normalizedSettings.width = Math.max(1, Math.trunc(Number(settings.width) || 0));
1247
+ }
1248
+ if (settings.height != null) {
1249
+ normalizedSettings.height = Math.max(1, Math.trunc(Number(settings.height) || 0));
1250
+ }
1251
+ if (settings.frameRate != null) {
1252
+ normalizedSettings.frameRate = Math.max(0, Number(settings.frameRate) || 0);
1253
+ }
1254
+
1255
+ return {
1256
+ trackId: String(track?.id || ''),
1257
+ kind: track?.kind === 'video' ? 'video' : 'audio',
1258
+ label: String(track?.label || ''),
1259
+ enabled: track?.enabled !== false,
1260
+ muted: Boolean(track?.muted),
1261
+ settings: normalizedSettings,
1262
+ };
1263
+ }
1264
+
1265
+ async function blobToUint8Array(blob) {
1266
+ if (!blob || typeof blob.arrayBuffer !== 'function') {
1267
+ return new Uint8Array();
1268
+ }
1269
+ const buffer = await blob.arrayBuffer();
1270
+ return new Uint8Array(buffer);
1271
+ }
1272
+
1273
+ const browserMediaStreamsSymbol = Symbol.for('com.rokid.jsui.ink.mediaStreams');
1274
+
1275
+ function getBrowserMediaStreamRegistry() {
1276
+ const current = globalThis[browserMediaStreamsSymbol];
1277
+ if (current instanceof Map) {
1278
+ return current;
1279
+ }
1280
+ const registry = new Map();
1281
+ globalThis[browserMediaStreamsSymbol] = registry;
1282
+ return registry;
1283
+ }
1284
+
1285
+ function oggCrc(bytes) {
1286
+ let crc = 0;
1287
+ for (const byte of bytes) {
1288
+ crc ^= byte << 24;
1289
+ for (let bit = 0; bit < 8; bit += 1) {
1290
+ crc = (crc << 1) ^ (crc & 0x80000000 ? 0x04c11db7 : 0);
1291
+ }
1292
+ }
1293
+ return crc >>> 0;
1294
+ }
1295
+
1296
+ function createOggPage(packet, serial, sequence, granule, headerType) {
1297
+ const segments = [];
1298
+ let remaining = packet.byteLength;
1299
+ while (remaining >= 255) {
1300
+ segments.push(255);
1301
+ remaining -= 255;
1302
+ }
1303
+ segments.push(remaining);
1304
+ const page = new Uint8Array(27 + segments.length + packet.byteLength);
1305
+ const view = new DataView(page.buffer);
1306
+ page.set([79, 103, 103, 83], 0);
1307
+ page[5] = headerType;
1308
+ view.setBigUint64(6, BigInt(granule), true);
1309
+ view.setUint32(14, serial, true);
1310
+ view.setUint32(18, sequence, true);
1311
+ page[26] = segments.length;
1312
+ page.set(segments, 27);
1313
+ page.set(packet, 27 + segments.length);
1314
+ view.setUint32(22, oggCrc(page), true);
1315
+ return page;
1316
+ }
1317
+
1318
+ function createOpusHead(sampleRate, channels) {
1319
+ const packet = new Uint8Array(19);
1320
+ const view = new DataView(packet.buffer);
1321
+ packet.set(new TextEncoder().encode('OpusHead'));
1322
+ packet[8] = 1;
1323
+ packet[9] = channels;
1324
+ view.setUint16(10, 312, true);
1325
+ view.setUint32(12, sampleRate, true);
1326
+ return packet;
1327
+ }
1328
+
1329
+ function createOpusTags() {
1330
+ const vendor = new TextEncoder().encode('Ink Web');
1331
+ const packet = new Uint8Array(16 + vendor.length);
1332
+ const view = new DataView(packet.buffer);
1333
+ packet.set(new TextEncoder().encode('OpusTags'));
1334
+ view.setUint32(8, vendor.length, true);
1335
+ packet.set(vendor, 12);
1336
+ return packet;
1337
+ }
1338
+
1339
+ class BrowserOggOpusMuxer {
1340
+ constructor(sampleRate, channels) {
1341
+ this.serial = Math.floor(Math.random() * 0xffffffff) >>> 0;
1342
+ this.sequence = 0;
1343
+ this.pending = null;
1344
+ this.headers = [
1345
+ createOggPage(createOpusHead(sampleRate, channels), this.serial, this.sequence++, 0, 2),
1346
+ createOggPage(createOpusTags(), this.serial, this.sequence++, 0, 0),
1347
+ ];
1348
+ }
1349
+
1350
+ add(packet, granule) {
1351
+ const output = this.headers.splice(0);
1352
+ if (this.pending) {
1353
+ output.push(
1354
+ createOggPage(this.pending.packet, this.serial, this.sequence++, this.pending.granule, 0),
1355
+ );
1356
+ }
1357
+ this.pending = { packet, granule };
1358
+ return output;
1359
+ }
1360
+
1361
+ finish() {
1362
+ const output = this.headers.splice(0);
1363
+ if (this.pending) {
1364
+ output.push(
1365
+ createOggPage(this.pending.packet, this.serial, this.sequence++, this.pending.granule, 4),
1366
+ );
1367
+ this.pending = null;
1368
+ }
1369
+ return output;
1370
+ }
1371
+ }
1372
+
1373
+ class BrowserPcmMediaRecorder extends EventTarget {
1374
+ constructor(stream, mimeType) {
1375
+ super();
1376
+ this.stream = stream;
1377
+ this.mimeType = mimeType;
1378
+ this.state = 'inactive';
1379
+ this.chunks = [];
1380
+ this.timesliceTimer = null;
1381
+ }
1382
+
1383
+ _emitData(parts, type, isLastChunk = false) {
1384
+ const event = new Event('dataavailable');
1385
+ Object.defineProperties(event, {
1386
+ data: { value: new Blob(parts, { type }) },
1387
+ inkIsLastChunk: { value: isLastChunk },
1388
+ });
1389
+ this.dispatchEvent(event);
1390
+ }
1391
+
1392
+ _emitError(error) {
1393
+ const event = new Event('error');
1394
+ Object.defineProperty(event, 'error', { value: error });
1395
+ this.dispatchEvent(event);
1396
+ }
1397
+
1398
+ start(timeslice) {
1399
+ if (this.state !== 'inactive') {
1400
+ throw new DOMException('MediaRecorder is not inactive', 'InvalidStateError');
1401
+ }
1402
+ this.state = 'recording';
1403
+ this._startCapture().catch((error) => {
1404
+ this.state = 'inactive';
1405
+ this._emitError(error);
1406
+ });
1407
+ if (timeslice > 0) {
1408
+ this.timesliceTimer = setInterval(() => this.requestData(), timeslice);
1409
+ }
1410
+ }
1411
+
1412
+ async _startCapture() {
1413
+ const AudioContextConstructor = globalThis.AudioContext || globalThis.webkitAudioContext;
1414
+ if (typeof AudioContextConstructor !== 'function') {
1415
+ throw new DOMException('Web Audio capture is unavailable', 'NotSupportedError');
1416
+ }
1417
+ const audioTrack = this.stream.getAudioTracks?.()[0];
1418
+ const settings = audioTrack?.getSettings?.() || {};
1419
+ const requestedSampleRate = Math.max(8000, Math.trunc(Number(settings.sampleRate) || 48000));
1420
+ this.channels = Math.min(2, Math.max(1, Math.trunc(Number(settings.channelCount) || 1)));
1421
+ this.context = new AudioContextConstructor({ sampleRate: requestedSampleRate });
1422
+ this.sampleRate = this.context.sampleRate;
1423
+ this.source = this.context.createMediaStreamSource(this.stream);
1424
+ this.processor = this.context.createScriptProcessor(4096, this.channels, this.channels);
1425
+ this.silentGain = this.context.createGain();
1426
+ this.silentGain.gain.value = 0;
1427
+ this.processor.onaudioprocess = (event) => this._processAudio(event.inputBuffer);
1428
+ this.source.connect(this.processor);
1429
+ this.processor.connect(this.silentGain);
1430
+ this.silentGain.connect(this.context.destination);
1431
+ if (this.mimeType === 'audio/ogg;codecs=opus') {
1432
+ await this._startOpusEncoder();
1433
+ }
1434
+ await this.context.resume();
1435
+ this.dispatchEvent(new Event('start'));
1436
+ }
1437
+
1438
+ async _startOpusEncoder() {
1439
+ if (
1440
+ typeof globalThis.AudioEncoder !== 'function' ||
1441
+ typeof globalThis.AudioData !== 'function'
1442
+ ) {
1443
+ throw new DOMException('WebCodecs Opus encoder is unavailable', 'NotSupportedError');
1444
+ }
1445
+ const config = {
1446
+ codec: 'opus',
1447
+ sampleRate: this.sampleRate,
1448
+ numberOfChannels: this.channels,
1449
+ bitrate: 64000,
1450
+ };
1451
+ if (typeof globalThis.AudioEncoder.isConfigSupported === 'function') {
1452
+ const support = await globalThis.AudioEncoder.isConfigSupported(config);
1453
+ if (!support?.supported) {
1454
+ throw new DOMException('WebCodecs Opus configuration is unsupported', 'NotSupportedError');
1455
+ }
1456
+ }
1457
+ this.oggMuxer = new BrowserOggOpusMuxer(this.sampleRate, this.channels);
1458
+ this.encoder = new globalThis.AudioEncoder({
1459
+ output: (chunk) => {
1460
+ const packet = new Uint8Array(chunk.byteLength);
1461
+ chunk.copyTo(packet);
1462
+ const duration = Number(chunk.duration) || 0;
1463
+ const granule = 312 + Math.round(((Number(chunk.timestamp) + duration) * 48000) / 1000000);
1464
+ const pages = this.oggMuxer.add(packet, granule);
1465
+ if (pages.length > 0) {
1466
+ this._emitData(pages, this.mimeType, false);
1467
+ }
1468
+ },
1469
+ error: (error) => this._emitError(error),
1470
+ });
1471
+ this.encoder.configure(config);
1472
+ this.nextTimestamp = 0;
1473
+ }
1474
+
1475
+ _processAudio(input) {
1476
+ if (this.state !== 'recording') {
1477
+ return;
1478
+ }
1479
+ const frames = input.length;
1480
+ if (this.encoder) {
1481
+ const planar = new Float32Array(frames * this.channels);
1482
+ for (let channel = 0; channel < this.channels; channel += 1) {
1483
+ planar.set(
1484
+ input.getChannelData(Math.min(channel, input.numberOfChannels - 1)),
1485
+ channel * frames,
1486
+ );
1487
+ }
1488
+ const data = new globalThis.AudioData({
1489
+ format: 'f32-planar',
1490
+ sampleRate: this.sampleRate,
1491
+ numberOfFrames: frames,
1492
+ numberOfChannels: this.channels,
1493
+ timestamp: this.nextTimestamp,
1494
+ data: planar,
1495
+ });
1496
+ this.nextTimestamp += Math.round((frames * 1000000) / this.sampleRate);
1497
+ this.encoder.encode(data);
1498
+ data.close();
1499
+ return;
1500
+ }
1501
+ const pcm = new Int16Array(frames * this.channels);
1502
+ for (let frame = 0; frame < frames; frame += 1) {
1503
+ for (let channel = 0; channel < this.channels; channel += 1) {
1504
+ const samples = input.getChannelData(Math.min(channel, input.numberOfChannels - 1));
1505
+ const sample = Math.max(-1, Math.min(1, samples[frame]));
1506
+ pcm[frame * this.channels + channel] = sample < 0 ? sample * 32768 : sample * 32767;
1507
+ }
1508
+ }
1509
+ this.chunks.push(new Uint8Array(pcm.buffer));
1510
+ }
1511
+
1512
+ pause() {
1513
+ if (this.state !== 'recording')
1514
+ throw new DOMException('MediaRecorder is not recording', 'InvalidStateError');
1515
+ this.state = 'paused';
1516
+ this.dispatchEvent(new Event('pause'));
1517
+ }
1518
+
1519
+ resume() {
1520
+ if (this.state !== 'paused')
1521
+ throw new DOMException('MediaRecorder is not paused', 'InvalidStateError');
1522
+ this.state = 'recording';
1523
+ this.dispatchEvent(new Event('resume'));
1524
+ }
1525
+
1526
+ requestData() {
1527
+ if (this.encoder || this.chunks.length === 0) return;
1528
+ this._emitData(this.chunks.splice(0), 'audio/pcm', false);
1529
+ }
1530
+
1531
+ stop() {
1532
+ if (this.state === 'inactive') return;
1533
+ this.state = 'inactive';
1534
+ clearInterval(this.timesliceTimer);
1535
+ this.timesliceTimer = null;
1536
+ this.processor?.disconnect();
1537
+ this.source?.disconnect();
1538
+ this.silentGain?.disconnect();
1539
+ this._finishCapture().catch((error) => this._emitError(error));
1540
+ }
1541
+
1542
+ async _finishCapture() {
1543
+ if (this.encoder) {
1544
+ await this.encoder.flush();
1545
+ const pages = this.oggMuxer.finish();
1546
+ this._emitData(pages, this.mimeType, true);
1547
+ this.encoder.close();
1548
+ } else {
1549
+ this._emitData(this.chunks.splice(0), 'audio/pcm', true);
1550
+ }
1551
+ await this.context?.close();
1552
+ this.dispatchEvent(new Event('stop'));
1553
+ }
1554
+ }
1555
+
1556
+ function createDefaultBrowserMediaCapability(eventTarget) {
1557
+ const mediaDevices = getBrowserMediaDevices();
1558
+ const BrowserMediaRecorder = getBrowserMediaRecorderConstructor();
1559
+ if (!mediaDevices || typeof mediaDevices.getUserMedia !== 'function') {
1560
+ return null;
1561
+ }
1562
+
1563
+ const streams = getBrowserMediaStreamRegistry();
1564
+ const recorders = new Map();
1565
+ let deviceChangeBound = false;
1566
+
1567
+ const dispatchDevicesChanged = async () => {
1568
+ if (typeof mediaDevices.enumerateDevices !== 'function') {
1569
+ return;
1570
+ }
1571
+ try {
1572
+ const devices = await mediaDevices.enumerateDevices();
1573
+ dispatchHostCapabilityCustomEvent(eventTarget, 'media.mediaDevicesChanged', {
1574
+ targetId: 'media-devices',
1575
+ devices: Array.isArray(devices)
1576
+ ? devices.map((device) => ({
1577
+ deviceId: String(device?.deviceId || ''),
1578
+ kind: String(device?.kind || ''),
1579
+ label: String(device?.label || ''),
1580
+ groupId: String(device?.groupId || ''),
1581
+ }))
1582
+ : [],
1583
+ });
1584
+ } catch (error) {
1585
+ console.error('InkView browser media backend failed to enumerate devices change.', error);
1586
+ }
1587
+ };
1588
+
1589
+ const ensureDeviceChangeListener = () => {
1590
+ if (deviceChangeBound || typeof mediaDevices.addEventListener !== 'function') {
1591
+ return;
1592
+ }
1593
+ mediaDevices.addEventListener('devicechange', dispatchDevicesChanged);
1594
+ deviceChangeBound = true;
1595
+ };
1596
+
1597
+ return {
1598
+ async enumerateDevices() {
1599
+ ensureDeviceChangeListener();
1600
+ if (typeof mediaDevices.enumerateDevices !== 'function') {
1601
+ return [];
1602
+ }
1603
+ const devices = await mediaDevices.enumerateDevices();
1604
+ return Array.isArray(devices)
1605
+ ? devices.map((device) => ({
1606
+ deviceId: String(device?.deviceId || ''),
1607
+ kind: String(device?.kind || ''),
1608
+ label: String(device?.label || ''),
1609
+ groupId: String(device?.groupId || ''),
1610
+ }))
1611
+ : [];
1612
+ },
1613
+ async getUserMedia(request) {
1614
+ ensureDeviceChangeListener();
1615
+ const browserConstraints = {
1616
+ audio: request?.constraints?.audio
1617
+ ? normalizeBrowserTrackConstraints(request.constraints.audio)
1618
+ : false,
1619
+ video: request?.constraints?.video
1620
+ ? normalizeBrowserTrackConstraints(request.constraints.video)
1621
+ : false,
1622
+ };
1623
+ const stream = await mediaDevices.getUserMedia(browserConstraints);
1624
+ streams.set(request.streamId, stream);
1625
+
1626
+ const tracks = stream.getTracks().map((track) => {
1627
+ const streamId = request.streamId;
1628
+ const trackId = String(track?.id || '');
1629
+ if (typeof track?.addEventListener === 'function') {
1630
+ track.addEventListener('ended', () => {
1631
+ dispatchHostCapabilityCustomEvent(eventTarget, 'media.mediaTrackEnded', {
1632
+ targetId: trackId,
1633
+ streamId,
1634
+ trackId,
1635
+ });
1636
+ });
1637
+ track.addEventListener('mute', () => {
1638
+ dispatchHostCapabilityCustomEvent(eventTarget, 'media.mediaTrackMuted', {
1639
+ targetId: trackId,
1640
+ streamId,
1641
+ trackId,
1642
+ });
1643
+ });
1644
+ track.addEventListener('unmute', () => {
1645
+ dispatchHostCapabilityCustomEvent(eventTarget, 'media.mediaTrackUnmuted', {
1646
+ targetId: trackId,
1647
+ streamId,
1648
+ trackId,
1649
+ });
1650
+ });
1651
+ }
1652
+ dispatchHostCapabilityCustomEvent(eventTarget, 'media.mediaTrackStarted', {
1653
+ targetId: trackId,
1654
+ streamId,
1655
+ trackId,
1656
+ });
1657
+ return normalizeBrowserMediaTrackDescriptor(track);
1658
+ });
1659
+
1660
+ return {
1661
+ streamId: request.streamId,
1662
+ tracks,
1663
+ };
1664
+ },
1665
+ stopMediaTrack(request) {
1666
+ const stream = streams.get(request?.streamId);
1667
+ if (!stream || typeof stream.getTracks !== 'function') {
1668
+ return;
1669
+ }
1670
+ const track = stream.getTracks().find((entry) => String(entry?.id || '') === request.trackId);
1671
+ if (!track) {
1672
+ return;
1673
+ }
1674
+ if (typeof track.stop === 'function') {
1675
+ track.stop();
1676
+ }
1677
+ dispatchHostCapabilityCustomEvent(eventTarget, 'media.mediaTrackEnded', {
1678
+ targetId: request.trackId,
1679
+ streamId: request.streamId,
1680
+ trackId: request.trackId,
1681
+ });
1682
+ if (stream.getTracks().every((entry) => entry.readyState === 'ended')) {
1683
+ streams.delete(request.streamId);
1684
+ }
1685
+ },
1686
+ createMediaRecorder(request) {
1687
+ const stream = streams.get(request?.streamId);
1688
+ if (!stream) {
1689
+ throw new Error(`Media stream ${String(request?.streamId || '')} is unavailable.`);
1690
+ }
1691
+ const requestedMimeType = String(request?.mimeType || '');
1692
+ const browserSupportsMimeType =
1693
+ BrowserMediaRecorder &&
1694
+ (!requestedMimeType ||
1695
+ typeof BrowserMediaRecorder.isTypeSupported !== 'function' ||
1696
+ BrowserMediaRecorder.isTypeSupported(requestedMimeType));
1697
+ const recorder = browserSupportsMimeType
1698
+ ? requestedMimeType
1699
+ ? new BrowserMediaRecorder(stream, { mimeType: requestedMimeType })
1700
+ : new BrowserMediaRecorder(stream)
1701
+ : new BrowserPcmMediaRecorder(stream, requestedMimeType);
1702
+ const session = {
1703
+ recorder,
1704
+ mimeType: String(recorder.mimeType || request.mimeType || ''),
1705
+ pendingStop: false,
1706
+ pendingData: Promise.resolve(),
1707
+ };
1708
+ recorders.set(request.recorderId, session);
1709
+
1710
+ recorder.addEventListener('start', () => {
1711
+ dispatchHostCapabilityCustomEvent(eventTarget, 'media.mediaRecorderStarted', {
1712
+ targetId: request.recorderId,
1713
+ recorderId: request.recorderId,
1714
+ });
1715
+ });
1716
+ recorder.addEventListener('pause', () => {
1717
+ dispatchHostCapabilityCustomEvent(eventTarget, 'media.mediaRecorderPaused', {
1718
+ targetId: request.recorderId,
1719
+ recorderId: request.recorderId,
1720
+ });
1721
+ });
1722
+ recorder.addEventListener('resume', () => {
1723
+ dispatchHostCapabilityCustomEvent(eventTarget, 'media.mediaRecorderResumed', {
1724
+ targetId: request.recorderId,
1725
+ recorderId: request.recorderId,
1726
+ });
1727
+ });
1728
+ recorder.addEventListener('stop', async () => {
1729
+ await session.pendingData;
1730
+ dispatchHostCapabilityCustomEvent(eventTarget, 'media.mediaRecorderStopped', {
1731
+ targetId: request.recorderId,
1732
+ recorderId: request.recorderId,
1733
+ mimeType: session.mimeType,
1734
+ });
1735
+ recorders.delete(request.recorderId);
1736
+ });
1737
+ recorder.addEventListener('error', (event) => {
1738
+ const message = event?.error?.message || event?.message || 'Browser MediaRecorder failed.';
1739
+ dispatchHostCapabilityCustomEvent(eventTarget, 'media.mediaRecorderError', {
1740
+ targetId: request.recorderId,
1741
+ recorderId: request.recorderId,
1742
+ message,
1743
+ });
1744
+ });
1745
+ recorder.addEventListener('dataavailable', (event) => {
1746
+ session.pendingData = session.pendingData.then(async () => {
1747
+ const bytes = await blobToUint8Array(event?.data);
1748
+ const isLastChunk =
1749
+ typeof event?.inkIsLastChunk === 'boolean'
1750
+ ? event.inkIsLastChunk
1751
+ : Boolean(session.pendingStop);
1752
+ if (isLastChunk) {
1753
+ session.pendingStop = false;
1754
+ }
1755
+ dispatchHostCapabilityCustomEvent(eventTarget, 'media.mediaRecorderData', {
1756
+ targetId: request.recorderId,
1757
+ recorderId: request.recorderId,
1758
+ bytes,
1759
+ mimeType: String(event?.data?.type || session.mimeType || ''),
1760
+ isLastChunk,
1761
+ });
1762
+ });
1763
+ });
1764
+
1765
+ return {
1766
+ recorderId: request.recorderId,
1767
+ mimeType: session.mimeType,
1768
+ };
1769
+ },
1770
+ startMediaRecorder(request) {
1771
+ const session = recorders.get(request?.recorderId);
1772
+ if (!session) {
1773
+ throw new Error(`MediaRecorder ${String(request?.recorderId || '')} is unavailable.`);
1774
+ }
1775
+ if (request?.timesliceMs != null) {
1776
+ session.recorder.start(request.timesliceMs);
1777
+ } else {
1778
+ session.recorder.start();
1779
+ }
1780
+ },
1781
+ pauseMediaRecorder(request) {
1782
+ const session = recorders.get(request?.recorderId);
1783
+ if (!session) {
1784
+ throw new Error(`MediaRecorder ${String(request?.recorderId || '')} is unavailable.`);
1785
+ }
1786
+ session.recorder.pause();
1787
+ },
1788
+ resumeMediaRecorder(request) {
1789
+ const session = recorders.get(request?.recorderId);
1790
+ if (!session) {
1791
+ throw new Error(`MediaRecorder ${String(request?.recorderId || '')} is unavailable.`);
1792
+ }
1793
+ session.recorder.resume();
1794
+ },
1795
+ requestMediaRecorderData(request) {
1796
+ const session = recorders.get(request?.recorderId);
1797
+ if (!session) {
1798
+ throw new Error(`MediaRecorder ${String(request?.recorderId || '')} is unavailable.`);
1799
+ }
1800
+ session.recorder.requestData();
1801
+ },
1802
+ stopMediaRecorder(request) {
1803
+ const session = recorders.get(request?.recorderId);
1804
+ if (!session) {
1805
+ return;
1806
+ }
1807
+ session.pendingStop = true;
1808
+ session.recorder.stop();
1809
+ },
1810
+ };
1811
+ }
1812
+
1813
+ function normalizeHostCapabilities(capabilities, eventTarget = null) {
979
1814
  const normalizedCapabilities = ensureHostCapabilitiesObject(capabilities);
980
- if (!normalizedCapabilities) {
1815
+ const defaultMediaCapability = createDefaultBrowserMediaCapability(eventTarget);
1816
+ if (!normalizedCapabilities && !defaultMediaCapability) {
981
1817
  return null;
982
1818
  }
983
1819
 
984
1820
  return {
985
1821
  async handleRequest(capability, method, requestJson) {
986
1822
  const serializer = HOST_CAPABILITY_SERIALIZERS[capability]?.[method];
987
- const hook = normalizedCapabilities?.[capability]?.[method];
1823
+ const hook =
1824
+ normalizedCapabilities?.[capability]?.[method] ||
1825
+ (capability === 'media' ? defaultMediaCapability?.[method] : undefined);
988
1826
  if (capability === 'openService' && method === 'getVendorHeaders' && hook == null) {
989
1827
  parseHostCapabilityRequest(requestJson, capability, method);
990
1828
  return serializer({});
@@ -1320,44 +2158,102 @@ const HOST_CAPABILITY_EVENT_BUILDERS = {
1320
2158
  buildSensorReadingEvent('Magnetometer', 'magnetometer.reading', detail, ['x', 'y', 'z']),
1321
2159
  'magnetometer.error': (detail) =>
1322
2160
  buildSensorErrorEvent('Magnetometer', 'magnetometer.error', detail),
1323
- 'media.audioRecordingStarted': (detail) =>
1324
- buildMediaRecordingEvent('media', 'AudioRecordingStarted', detail),
1325
- 'media.audioRecordingStopped': (detail) => {
1326
- const payload = requireDetailObject(detail, 'media.audioRecordingStopped');
1327
- return buildMediaRecordingEvent(
1328
- 'media',
1329
- 'AudioRecordingStopped',
1330
- payload,
1331
- String(payload.path || ''),
1332
- );
2161
+ 'media.mediaTrackStarted': (detail) => {
2162
+ const payload = requireDetailObject(detail, 'media.mediaTrackStarted');
2163
+ return buildMediaRecordingEvent('media', 'MediaTrackStarted', detail, {
2164
+ streamId: String(payload.streamId || ''),
2165
+ trackId: String(payload.trackId || ''),
2166
+ });
1333
2167
  },
1334
- 'media.audioRecordingPaused': (detail) =>
1335
- buildMediaRecordingEvent('media', 'AudioRecordingPaused', detail),
1336
- 'media.audioRecordingResumed': (detail) =>
1337
- buildMediaRecordingEvent('media', 'AudioRecordingResumed', detail),
1338
- 'media.audioFrameRecorded': (detail) => {
1339
- const payload = requireDetailObject(detail, 'media.audioFrameRecorded');
1340
- const bytes = cloneUint8Array(payload.data ?? payload.bytes ?? payload.buffer);
2168
+ 'media.mediaTrackEnded': (detail) => {
2169
+ const payload = requireDetailObject(detail, 'media.mediaTrackEnded');
2170
+ return buildMediaRecordingEvent('media', 'MediaTrackEnded', detail, {
2171
+ streamId: String(payload.streamId || ''),
2172
+ trackId: String(payload.trackId || ''),
2173
+ });
2174
+ },
2175
+ 'media.mediaTrackMuted': (detail) => {
2176
+ const payload = requireDetailObject(detail, 'media.mediaTrackMuted');
2177
+ return buildMediaRecordingEvent('media', 'MediaTrackMuted', detail, {
2178
+ streamId: String(payload.streamId || ''),
2179
+ trackId: String(payload.trackId || ''),
2180
+ });
2181
+ },
2182
+ 'media.mediaTrackUnmuted': (detail) => {
2183
+ const payload = requireDetailObject(detail, 'media.mediaTrackUnmuted');
2184
+ return buildMediaRecordingEvent('media', 'MediaTrackUnmuted', detail, {
2185
+ streamId: String(payload.streamId || ''),
2186
+ trackId: String(payload.trackId || ''),
2187
+ });
2188
+ },
2189
+ 'media.mediaTrackError': (detail) => {
2190
+ const payload = requireDetailObject(detail, 'media.mediaTrackError');
2191
+ return buildMediaRecordingEvent('media', 'MediaTrackError', detail, {
2192
+ streamId: String(payload.streamId || ''),
2193
+ trackId: String(payload.trackId || ''),
2194
+ message: String(payload.message || ''),
2195
+ });
2196
+ },
2197
+ 'media.mediaRecorderStarted': (detail) => {
2198
+ const payload = requireDetailObject(detail, 'media.mediaRecorderStarted');
2199
+ return buildMediaRecordingEvent('media', 'MediaRecorderStarted', detail, {
2200
+ recorderId: String(payload.recorderId || ''),
2201
+ });
2202
+ },
2203
+ 'media.mediaRecorderData': (detail) => {
2204
+ const payload = requireDetailObject(detail, 'media.mediaRecorderData');
2205
+ const bytes = cloneUint8Array(payload.bytes ?? payload.data ?? payload.buffer);
1341
2206
  if (!bytes) {
1342
2207
  throw new TypeError(
1343
- 'Host capability event `media.audioFrameRecorded` requires binary `data`.',
2208
+ 'Host capability event `media.mediaRecorderData` requires binary `bytes`.',
1344
2209
  );
1345
2210
  }
1346
- return buildMediaRecordingEvent('media', 'AudioFrameRecorded', payload, Array.from(bytes));
2211
+ return buildMediaRecordingEvent('media', 'MediaRecorderData', detail, {
2212
+ recorderId: String(payload.recorderId || ''),
2213
+ bytes: Array.from(bytes),
2214
+ mimeType: String(payload.mimeType || ''),
2215
+ isLastChunk: Boolean(payload.isLastChunk),
2216
+ });
1347
2217
  },
1348
- 'media.audioRecordingError': (detail) => {
1349
- const payload = requireDetailObject(detail, 'media.audioRecordingError');
1350
- return buildMediaRecordingEvent(
1351
- 'media',
1352
- 'AudioRecordingError',
1353
- payload,
1354
- String(payload.message || ''),
1355
- );
2218
+ 'media.mediaRecorderPaused': (detail) => {
2219
+ const payload = requireDetailObject(detail, 'media.mediaRecorderPaused');
2220
+ return buildMediaRecordingEvent('media', 'MediaRecorderPaused', detail, {
2221
+ recorderId: String(payload.recorderId || ''),
2222
+ });
2223
+ },
2224
+ 'media.mediaRecorderResumed': (detail) => {
2225
+ const payload = requireDetailObject(detail, 'media.mediaRecorderResumed');
2226
+ return buildMediaRecordingEvent('media', 'MediaRecorderResumed', detail, {
2227
+ recorderId: String(payload.recorderId || ''),
2228
+ });
2229
+ },
2230
+ 'media.mediaRecorderStopped': (detail) => {
2231
+ const payload = requireDetailObject(detail, 'media.mediaRecorderStopped');
2232
+ return buildMediaRecordingEvent('media', 'MediaRecorderStopped', detail, {
2233
+ recorderId: String(payload.recorderId || ''),
2234
+ mimeType: payload.mimeType == null ? null : String(payload.mimeType),
2235
+ });
2236
+ },
2237
+ 'media.mediaRecorderError': (detail) => {
2238
+ const payload = requireDetailObject(detail, 'media.mediaRecorderError');
2239
+ return buildMediaRecordingEvent('media', 'MediaRecorderError', detail, {
2240
+ recorderId: String(payload.recorderId || ''),
2241
+ message: String(payload.message || ''),
2242
+ });
2243
+ },
2244
+ 'media.mediaDevicesChanged': (detail) => {
2245
+ const payload = requireDetailObject(detail, 'media.mediaDevicesChanged');
2246
+ return buildMediaRecordingEvent('media', 'MediaDevicesChanged', detail, {
2247
+ devices: Array.isArray(payload.devices)
2248
+ ? payload.devices.map((device) => ({
2249
+ deviceId: String(device?.deviceId || ''),
2250
+ kind: String(device?.kind || ''),
2251
+ label: String(device?.label || ''),
2252
+ groupId: String(device?.groupId || ''),
2253
+ }))
2254
+ : [],
2255
+ });
1356
2256
  },
1357
- 'media.audioRecordingInterruptionBegin': (detail) =>
1358
- buildMediaRecordingEvent('media', 'AudioRecordingInterruptionBegin', detail),
1359
- 'media.audioRecordingInterruptionEnd': (detail) =>
1360
- buildMediaRecordingEvent('media', 'AudioRecordingInterruptionEnd', detail),
1361
2257
  };
1362
2258
 
1363
2259
  const HOST_CAPABILITY_EVENT_TYPES = Object.keys(HOST_CAPABILITY_EVENT_BUILDERS);
@@ -1421,7 +2317,7 @@ export function getInkBundleVersion() {
1421
2317
  }
1422
2318
 
1423
2319
  /**
1424
- * Returns whether the bundled Ink runtime version satisfies the supplied AIX
2320
+ * Returns whether the Ink runtime version satisfies the supplied AIX
1425
2321
  * engine range.
1426
2322
  *
1427
2323
  * The engine range lets a package declare which Ink runtime versions it is
@@ -2469,7 +3365,10 @@ export class InkView {
2469
3365
  }
2470
3366
 
2471
3367
  setHostCapabilities(capabilities) {
2472
- const normalizedCapabilities = normalizeHostCapabilities(capabilities);
3368
+ const normalizedCapabilities = normalizeHostCapabilities(
3369
+ capabilities,
3370
+ this.#hostCapabilitiesTarget,
3371
+ );
2473
3372
  if (typeof this.#rawView.setHostCapabilities === 'function') {
2474
3373
  this.#rawView.setHostCapabilities(normalizedCapabilities);
2475
3374
  }