wenay-common2 1.0.65 → 1.0.66
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/CLAUDE.md +20 -0
- package/doc/ROADMAP.md +92 -33
- package/doc/changes/1.0.65.md +2 -1
- package/doc/changes/1.0.66.md +8 -0
- package/doc/wenay-common2-rare.md +44 -0
- package/doc/wenay-common2.md +11 -0
- package/lib/Common/media/media-index.d.ts +1 -0
- package/lib/Common/media/media-index.js +17 -0
- package/lib/Common/media/media-source.d.ts +94 -0
- package/lib/Common/media/media-source.js +537 -0
- package/lib/client.d.ts +2 -0
- package/lib/client.js +3 -1
- package/lib/index.d.ts +2 -0
- package/lib/index.js +3 -1
- package/package.json +4 -2
package/CLAUDE.md
CHANGED
|
@@ -51,6 +51,26 @@ Write new code in this style by default — no need to ask.
|
|
|
51
51
|
## Comments
|
|
52
52
|
- Section dividers like `// ===...===` with a block heading.
|
|
53
53
|
- Explain "why", not "what". Keep them short.
|
|
54
|
+
|
|
55
|
+
## Work progress files
|
|
56
|
+
|
|
57
|
+
- For any task that is more than a tiny/local edit, create a temporary progress file before
|
|
58
|
+
starting broad changes.
|
|
59
|
+
- Put progress files under `doc/progress/`. This is the working-doc area, separate from public
|
|
60
|
+
API docs, roadmap docs, and release notes.
|
|
61
|
+
- Name them by task, for example `doc/progress/replay-route-handoff.md`.
|
|
62
|
+
- Keep the file short: goal, current checkpoint list, notable decisions, blockers, and verification
|
|
63
|
+
already run or still needed.
|
|
64
|
+
- Update the progress file as checkpoints are completed, especially before switching context or
|
|
65
|
+
making broad edits.
|
|
66
|
+
- When the task is finished, delete the progress file. Preserve only the durable outcome:
|
|
67
|
+
final response, commit message, and, for publishable changes, the matching `doc/changes/<version>.md`
|
|
68
|
+
entry.
|
|
69
|
+
- If work is paused or blocked locally, leave the progress file in place and make the next required
|
|
70
|
+
action explicit. If the paused state must be committed or handed off, promote the useful part into
|
|
71
|
+
a durable doc (`ROADMAP`, `RECOMMENDATIONS`, `doc/target`, or `doc/changes`) instead of relying on
|
|
72
|
+
an ignored progress file.
|
|
73
|
+
|
|
54
74
|
## Documentation and release notes
|
|
55
75
|
|
|
56
76
|
- `README.md` is navigation only. Do not put API guides, examples, or long explanations there.
|
package/doc/ROADMAP.md
CHANGED
|
@@ -35,39 +35,98 @@ This implies a useful split:
|
|
|
35
35
|
- **API surface** remains facade-shaped and typed.
|
|
36
36
|
- **Transport/routing layer** may substitute sockets and promote paths.
|
|
37
37
|
- **Replay layer** resumes streams and mirrors from the last known `seq`.
|
|
38
|
-
- **Authority layer** decides whether an incoming write is accepted, predicted, reconciled, or merged.
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
38
|
+
- **Authority layer** decides whether an incoming write is accepted, predicted, reconciled, or merged.
|
|
39
|
+
|
|
40
|
+
Critical ordering rule: do **not** start with WebRTC/NAT plumbing or CRDTs. The next useful layer is a
|
|
41
|
+
small route/account/policy coordinator with a fake/in-process transport adapter. Direct transport and
|
|
42
|
+
multi-writer merge are expensive adapters; they only become safe after the coordinator state machine is
|
|
43
|
+
boring and well-tested.
|
|
44
|
+
|
|
45
|
+
### 0.1 Account route coordinator ("wrapper over wrappers") 🔴
|
|
46
|
+
|
|
47
|
+
Some deployments need separate client/account identities to communicate directly when policy and
|
|
48
|
+
network conditions allow it, while still allowing the server/relay to step back into the path later.
|
|
49
|
+
This is not just a socket trick; it is an account-aware routing shell above the existing facade,
|
|
50
|
+
mirror, and replay primitives.
|
|
51
|
+
|
|
52
|
+
- **Account model:** every participant has its own account/session identity and a scoped facade/store
|
|
53
|
+
set. Runtime-account maps are a dynamic keyspace, so they should look like `noStrict(accountMap)`
|
|
54
|
+
rather than a fixed schema. Access checks stay in the facade/policy layer; `noStrict` is not an ACL.
|
|
55
|
+
- **Wrapper over wrappers:** an app-level coordinator owns the set of per-account clients, exposed
|
|
56
|
+
facades, mirrors, replay subscriptions, and route state. "State of other accounts" is represented as
|
|
57
|
+
selected `Observe` mirrors/replay/offline resources, ideally started and stopped through
|
|
58
|
+
`createStoreManager`, not as a new global store core.
|
|
59
|
+
- **Route states:** a pair of accounts may be `relay`, `direct`, `direct+shadowRelay` (audit/observe
|
|
60
|
+
copy), `blocked`, or `fallback`. Direct links are optional optimizations, useful for latency or
|
|
61
|
+
traffic cost, not a semantic change.
|
|
62
|
+
- **Re-interposition:** if NDA/privacy policy, audit, moderation, throttling, reauth, direct-link
|
|
63
|
+
failure, or group topology changes require it, the relay must be able to re-enter the data path.
|
|
64
|
+
This is the reverse of direct promotion: open the replacement route, resume from the last `seq`,
|
|
65
|
+
switch consumers after catch-up, then close or demote the old route.
|
|
66
|
+
- **Privacy rule:** direct account links are opt-in and policy-gated. Peers only receive the endpoint
|
|
67
|
+
and session material needed for that specific relationship; no implicit broad account discovery.
|
|
68
|
+
|
|
69
|
+
Concrete next API shape, not final names:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
createRouteCoordinator({
|
|
73
|
+
policy,
|
|
74
|
+
routes,
|
|
75
|
+
resources,
|
|
76
|
+
}) -> {
|
|
77
|
+
pair(a, b),
|
|
78
|
+
state(pair),
|
|
79
|
+
promoteDirect(pair, opts?),
|
|
80
|
+
reinterposeRelay(pair, reason?),
|
|
81
|
+
block(pair, reason?),
|
|
82
|
+
fallback(pair, reason?),
|
|
83
|
+
onRoute(cb),
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Policy must run **before** transport promotion:
|
|
88
|
+
|
|
89
|
+
- `canDirect(pair, ctx)` - may these accounts attempt direct at all?
|
|
90
|
+
- `mustRelay(pair, ctx)` - force relay path because of NDA/audit/moderation/reauth.
|
|
91
|
+
- `mustShadowRelay(pair, ctx)` - allow direct payload path, but keep audit/observe copy.
|
|
92
|
+
- `canExposeEndpoint(pair, ctx)` - whether signaling may reveal endpoint/session material.
|
|
93
|
+
- `canReinterpose(pair, ctx)` - whether/when relay is allowed or required to step back in.
|
|
94
|
+
|
|
95
|
+
Minimum state machine:
|
|
96
|
+
|
|
97
|
+
- `relay` -> `direct:connecting` -> `direct` -> `relay:reinterposing` -> `relay`
|
|
98
|
+
- `relay` -> `direct:connecting` -> `fallback` -> `relay`
|
|
99
|
+
- `direct` -> `direct+shadowRelay`
|
|
100
|
+
- any state -> `blocked`
|
|
101
|
+
|
|
102
|
+
Required failure modes:
|
|
103
|
+
|
|
104
|
+
- direct setup never completes: keep relay active and mark fallback;
|
|
105
|
+
- replacement route catches up too slowly: keep old route active and fail the switch;
|
|
106
|
+
- policy changes mid-stream: re-interpose relay through replay hand-off;
|
|
107
|
+
- account reauth changes facade/ACL: rebuild route policy before accepting new writes;
|
|
108
|
+
- endpoint/session material is revoked: close direct and resume relay from `seq`;
|
|
109
|
+
- audit/shadow route lags: decide whether to throttle, fallback, or block by policy.
|
|
110
|
+
|
|
111
|
+
Acceptance tests for 0.1:
|
|
112
|
+
|
|
113
|
+
- policy denial: direct is never attempted;
|
|
114
|
+
- direct promotion: old relay stays live until replacement catches up;
|
|
115
|
+
- failed direct: old relay continues with no data gap;
|
|
116
|
+
- re-interposition: direct -> relay resumes from `seq`;
|
|
117
|
+
- `direct+shadowRelay`: direct path is active while relay/audit mirror observes;
|
|
118
|
+
- revocation: direct closes and relay resumes without changing facade API;
|
|
119
|
+
- account map uses `noStrict`, but all access checks live in policy/facade code;
|
|
120
|
+
- `createStoreManager` starts/stops selected per-account mirrors without store-core changes.
|
|
121
|
+
|
|
122
|
+
Open questions: exact policy object shape; signaling envelope; whether the relay sees payloads or only
|
|
123
|
+
coordinates encrypted direct streams; app-facing route-state events; backpressure across multi-hop and
|
|
124
|
+
direct paths; group topology beyond a pair of accounts.
|
|
125
|
+
|
|
126
|
+
Status: 🔴 not started. This belongs to project-0 architecture: no store-core change, but it defines
|
|
127
|
+
the account/routing shell that later direct-connection work plugs into. This is the next important
|
|
128
|
+
roadmap item; WebRTC and CRDT work should wait until this state machine is proven with fake transports.
|
|
129
|
+
|
|
71
130
|
## 1. Connection hand-off — relay ↔ direct promotion ("port forwarding") 🟡
|
|
72
131
|
|
|
73
132
|
A relay/intermediary bootstraps a connection between two parties, then — on a signal — **steps out
|
package/doc/changes/1.0.65.md
CHANGED
|
@@ -4,4 +4,5 @@
|
|
|
4
4
|
- Add `Observe.syncStoreReplayRoute` for store mirrors that can switch relay/direct routes without gaps or duplicate patch delivery.
|
|
5
5
|
- Add `replay/route-handoff.test.ts` covering relay -> direct promotion, direct -> relay re-interposition, failed replacement fallback, and store mirror convergence.
|
|
6
6
|
- Update roadmap/replay docs to mark connection hand-off as partially implemented and keep signaling/WebRTC/policy triggers explicitly open.
|
|
7
|
-
- Normalize package metadata for `wenay-common2@1.0.65`.
|
|
7
|
+
- Normalize package metadata for `wenay-common2@1.0.65`.
|
|
8
|
+
- Add `doc/progress/` working-checkpoint rules for non-trivial tasks and keep task progress files out of package output.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# 1.0.66
|
|
2
|
+
|
|
3
|
+
- Add `Media.createAudioSource` and `Media.createVideoSource` browser capture helpers that expose media as ordinary binary `Listen` sources.
|
|
4
|
+
- Add fixed-header media frame helpers: `Media.encodeMediaFrame` and `Media.decodeMediaFrame` (`Uint8Array` header + raw payload, no JSON envelope).
|
|
5
|
+
- Add optional `replay:true` media source mode so `createRpcServerAuto` can expose media listeners with legacy + replay surfaces under the same key.
|
|
6
|
+
- Add `wenay-common2/media` package export and root `Media` namespace export.
|
|
7
|
+
- Add `replay/media-socket.test.ts` covering media binary frame headers, no-device typed state, replay media source shape, and real Socket.IO/RPC binary delivery.
|
|
8
|
+
- Document media-over-socket backpressure defaults and reserve WebRTC as a future explicit SFU/signaling opt-in, not the default transport.
|
|
@@ -205,6 +205,50 @@ Contract:
|
|
|
205
205
|
- Static dotted keys are also supported: `api["a.b"].c` is distinct from `api.a.b.c`. Internal route/listen/cache identity must stay lossless; dotted strings are only a debug display form.
|
|
206
206
|
- If a branch is a fixed public API, keep it strict. If a branch is a personal/dynamic keyspace, wrap that branch in `noStrict` instead of trying to publish all current keys as schema.
|
|
207
207
|
|
|
208
|
+
## 🎙️ Media over socket — browser capture as binary Listen
|
|
209
|
+
> `import { Media } from "wenay-common2"` or `import * as Media from "wenay-common2/media"`.
|
|
210
|
+
> The hot path event is ONE `Uint8Array`: fixed 40-byte common2 media header + raw payload. No JSON envelope.
|
|
211
|
+
```
|
|
212
|
+
Media.createAudioSource(opts?) -> [emit, listen] & control
|
|
213
|
+
Media.createVideoSource(opts?) -> [emit, listen] & control
|
|
214
|
+
Media.encodeMediaFrame(meta, payload) -> Uint8Array
|
|
215
|
+
Media.decodeMediaFrame(frame) -> {kind, codec, seq, tMono, payload, sampleRate?, channels?, nSamples?, width?, height?}
|
|
216
|
+
|
|
217
|
+
control: start() -> Promise<MediaSourceState> · stop() · getStats() · setDevice(id) · listDevices()
|
|
218
|
+
state: 'idle'|'requesting'|'live'|'denied'|'no-device'|'error'
|
|
219
|
+
```
|
|
220
|
+
Audio source:
|
|
221
|
+
- default `mode:'pcm'`, `format:'int16'`, raw PCM payload; uses `AudioWorklet` when available and falls back to `ScriptProcessor` only when the browser cannot run a worklet.
|
|
222
|
+
- `mode:'record'` uses `MediaRecorder` chunks (`webm-opus`) for record/upload flows, not live STT.
|
|
223
|
+
- `getStats().rms` gives a VU-meter signal; permission denied/no device returns typed state, not a thrown public failure.
|
|
224
|
+
|
|
225
|
+
Video source:
|
|
226
|
+
- default snapshots, not a 30fps video stream: `video->canvas`, JPEG, `fps` default 3, `quality` default 0.82.
|
|
227
|
+
- each frame carries absolute image bytes, so `replay:true` can safely keep the latest frame for lag recovery.
|
|
228
|
+
- `worker` is API-reserved; current implementation stays main-thread. Any future worker path must transfer `ArrayBuffer` payloads, never structured-clone frame objects.
|
|
229
|
+
|
|
230
|
+
Replay/RPC wiring:
|
|
231
|
+
```ts
|
|
232
|
+
const audio = Media.createAudioSource({sourceId: 'mic'}) // plain lossless queue Listen
|
|
233
|
+
const video = Media.createVideoSource({sourceId: 'cam', fps: 2, replay: true})
|
|
234
|
+
|
|
235
|
+
createRpcServerAuto({
|
|
236
|
+
socket, socketKey: 'media',
|
|
237
|
+
object: {audio: audio[1], video: video[1]},
|
|
238
|
+
replayOpts: {highWater: 64, lowWater: 8},
|
|
239
|
+
})
|
|
240
|
+
```
|
|
241
|
+
`replay:true` makes the returned listen a `Replay.replayListen` surface before capture emits into it, so `createRpcServerAuto` brand-detects it and exposes legacy + replay under the same key. Defaults differ by media kind: audio replay is a sacred queue (`history:1024`, no keyframe/frame, do not drop samples); video replay is keep-latest (`history:256`, `current:'last'`, `frame` returns the newest covered frame). Pass `replay:{...}` for custom history/current/frame.
|
|
242
|
+
|
|
243
|
+
Backpressure rule: audio consumers should use the default queue policy unless the app explicitly accepts loss. Video consumers can use `Replay.replaySubscribe(remote.video, cb, {policy:'frame'})`; a slow socket drains to the latest frame instead of accumulating stale images. The binary frame itself is RPC-safe because `rpc-walk` passes `TypedArray`/`ArrayBuffer` leaves through natively and applies `maxBinaryLen`.
|
|
244
|
+
|
|
245
|
+
WebRTC future contract:
|
|
246
|
+
- `transport:'socket'` is the implemented default today.
|
|
247
|
+
- `transport:'webrtc'` is reserved and currently reports `state:'error'` on `start()`; it is not a hidden second transport.
|
|
248
|
+
- Future WebRTC support must be explicit opt-in for sub-200ms human duplex. Signaling belongs on the existing socket/RPC control channel (offer/answer/ICE), and backend/AI access requires an SFU that re-emits media bytes into the same `Media` Listen/replay surface. Downstream RPC/replay/store consumers must not change.
|
|
249
|
+
|
|
250
|
+
Oracle: `npx ts-node replay/media-socket.test.ts` checks header decode, plain Listen shape, `replay:true`, typed no-device state in Node, and real Socket.IO binary delivery.
|
|
251
|
+
|
|
208
252
|
## 📈 exchange — params (`CParams`)
|
|
209
253
|
```
|
|
210
254
|
class CParams / CParamsReadonly implements IParams
|
package/doc/wenay-common2.md
CHANGED
|
@@ -143,6 +143,17 @@ await l.ticks.frame(mySeq) // pull at YOUR pace (
|
|
|
143
143
|
// full guide + examples → rpc.md; frame model / lag policies → 🎞️ recipe below and rare docs
|
|
144
144
|
```
|
|
145
145
|
|
|
146
|
+
## 🎙️ Media over socket — binary Listen frames
|
|
147
|
+
```
|
|
148
|
+
import { Media } from "wenay-common2" // or: import * as Media from "wenay-common2/media"
|
|
149
|
+
|
|
150
|
+
Media.createAudioSource({format?: 'int16'|'float32', mode?: 'pcm'|'record', replay?}) -> [emit, listen] & control
|
|
151
|
+
Media.createVideoSource({fps? = 3, codec? = 'jpeg', quality?, replay?}) -> [emit, listen] & control
|
|
152
|
+
control: start() -> Promise<'idle'|'requesting'|'live'|'denied'|'no-device'|'error'> · stop() · getStats() · setDevice(id) · listDevices() · state
|
|
153
|
+
Media.encodeMediaFrame(meta, payload) / Media.decodeMediaFrame(frame) // one Uint8Array = 40-byte fixed header + raw payload
|
|
154
|
+
```
|
|
155
|
+
Audio default is PCM frames from `AudioWorklet` where available (`mode:'record'` uses MediaRecorder chunks). Video default is camera snapshots (`video->canvas`, JPEG, low fps for vision). Put `listen` into `createRpcServerAuto` like any other Listen; with `replay:true`, the returned listen is a replay line, so RPC auto exposes legacy + replay surfaces under the same key. Backpressure policy: audio is lossless queue; video `replay:true` defaults to keep-latest frame recovery. `transport:'webrtc'` is reserved for a future SFU/signaling adapter; socket binary is the default today.
|
|
156
|
+
|
|
146
157
|
## 🔁 Observe — reactive state + store/mirror API
|
|
147
158
|
> `import { Observe } from "wenay-common2"` or `import * as Observe from "wenay-common2/observe"`.
|
|
148
159
|
> This is the documented v2 reactive/store surface.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './media-source';
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
__exportStar(require("./media-source"), exports);
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { Listener, ListenApi } from '../events/Listen';
|
|
2
|
+
import { ListenReplayApi, ReplayListenUseOptions } from '../events/replay-listen';
|
|
3
|
+
export type MediaSourceKind = 'audio' | 'video';
|
|
4
|
+
export type MediaSourceState = 'idle' | 'requesting' | 'live' | 'denied' | 'no-device' | 'error';
|
|
5
|
+
export type MediaFrameKind = 'audio-pcm' | 'audio-record' | 'video-frame';
|
|
6
|
+
export type MediaFrameCodec = 'pcm16' | 'float32' | 'jpeg' | 'png' | 'webp' | 'webm-opus';
|
|
7
|
+
export type MediaTransportMode = 'socket' | 'webrtc';
|
|
8
|
+
export type MediaSourceDevice = {
|
|
9
|
+
deviceId: string;
|
|
10
|
+
label: string;
|
|
11
|
+
kind: string;
|
|
12
|
+
};
|
|
13
|
+
export type MediaReplayOpts = true | ReplayListenUseOptions<[Uint8Array]>;
|
|
14
|
+
export type AudioSourceOpts = {
|
|
15
|
+
sourceId?: string;
|
|
16
|
+
deviceId?: string;
|
|
17
|
+
mode?: 'pcm' | 'record';
|
|
18
|
+
format?: 'int16' | 'float32';
|
|
19
|
+
channels?: number;
|
|
20
|
+
sampleRate?: number;
|
|
21
|
+
worklet?: boolean;
|
|
22
|
+
bufferSize?: number;
|
|
23
|
+
recordMimeType?: string;
|
|
24
|
+
recordTimesliceMs?: number;
|
|
25
|
+
transport?: MediaTransportMode;
|
|
26
|
+
replay?: MediaReplayOpts;
|
|
27
|
+
};
|
|
28
|
+
export type VideoSourceOpts = {
|
|
29
|
+
sourceId?: string;
|
|
30
|
+
deviceId?: string;
|
|
31
|
+
fps?: number;
|
|
32
|
+
width?: number;
|
|
33
|
+
height?: number;
|
|
34
|
+
codec?: 'jpeg' | 'png' | 'webp';
|
|
35
|
+
quality?: number;
|
|
36
|
+
video?: any;
|
|
37
|
+
canvas?: any;
|
|
38
|
+
worker?: false | {
|
|
39
|
+
transferable?: true;
|
|
40
|
+
};
|
|
41
|
+
transport?: MediaTransportMode;
|
|
42
|
+
replay?: MediaReplayOpts;
|
|
43
|
+
};
|
|
44
|
+
export type MediaStats = {
|
|
45
|
+
sourceId: string;
|
|
46
|
+
kind: MediaSourceKind;
|
|
47
|
+
state: MediaSourceState;
|
|
48
|
+
seq: number;
|
|
49
|
+
frames: number;
|
|
50
|
+
bytes: number;
|
|
51
|
+
dropped: number;
|
|
52
|
+
startedAt: number;
|
|
53
|
+
lastAt: number;
|
|
54
|
+
fps: number;
|
|
55
|
+
rms?: number;
|
|
56
|
+
error?: string;
|
|
57
|
+
};
|
|
58
|
+
export type MediaSourceControl = {
|
|
59
|
+
start(): Promise<MediaSourceState>;
|
|
60
|
+
stop(): void;
|
|
61
|
+
getStats(): MediaStats;
|
|
62
|
+
setDevice(id: string): Promise<MediaSourceState>;
|
|
63
|
+
listDevices(): Promise<MediaSourceDevice[]>;
|
|
64
|
+
readonly state: MediaSourceState;
|
|
65
|
+
readonly sourceId: string;
|
|
66
|
+
readonly kind: MediaSourceKind;
|
|
67
|
+
readonly transport: MediaTransportMode;
|
|
68
|
+
};
|
|
69
|
+
export type MediaListen = ListenApi<[Uint8Array]>;
|
|
70
|
+
export type MediaReplayListen = ListenReplayApi<[Uint8Array]>;
|
|
71
|
+
export type MediaAnyListen = MediaListen | MediaReplayListen;
|
|
72
|
+
export type MediaEmit = Listener<[Uint8Array]>;
|
|
73
|
+
export type MediaSource = readonly [MediaEmit, MediaAnyListen] & MediaSourceControl;
|
|
74
|
+
export type MediaFrameMeta = {
|
|
75
|
+
kind: MediaFrameKind;
|
|
76
|
+
codec: MediaFrameCodec;
|
|
77
|
+
seq: number;
|
|
78
|
+
tMono: number;
|
|
79
|
+
sampleRate?: number;
|
|
80
|
+
channels?: number;
|
|
81
|
+
nSamples?: number;
|
|
82
|
+
width?: number;
|
|
83
|
+
height?: number;
|
|
84
|
+
};
|
|
85
|
+
export type DecodedMediaFrame = MediaFrameMeta & {
|
|
86
|
+
payload: Uint8Array;
|
|
87
|
+
};
|
|
88
|
+
export declare const MEDIA_FRAME_MAGIC = 1464028466;
|
|
89
|
+
export declare const MEDIA_FRAME_VERSION = 1;
|
|
90
|
+
export declare const MEDIA_FRAME_HEADER_BYTES = 40;
|
|
91
|
+
export declare function encodeMediaFrame(meta: MediaFrameMeta, payload: ArrayBuffer | ArrayBufferView): Uint8Array<ArrayBuffer>;
|
|
92
|
+
export declare function decodeMediaFrame(frameLike: ArrayBuffer | ArrayBufferView): DecodedMediaFrame;
|
|
93
|
+
export declare function createAudioSource(opts?: AudioSourceOpts): MediaSource;
|
|
94
|
+
export declare function createVideoSource(opts?: VideoSourceOpts): MediaSource;
|
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MEDIA_FRAME_HEADER_BYTES = exports.MEDIA_FRAME_VERSION = exports.MEDIA_FRAME_MAGIC = void 0;
|
|
4
|
+
exports.encodeMediaFrame = encodeMediaFrame;
|
|
5
|
+
exports.decodeMediaFrame = decodeMediaFrame;
|
|
6
|
+
exports.createAudioSource = createAudioSource;
|
|
7
|
+
exports.createVideoSource = createVideoSource;
|
|
8
|
+
const Listen_1 = require("../events/Listen");
|
|
9
|
+
const replay_listen_1 = require("../events/replay-listen");
|
|
10
|
+
exports.MEDIA_FRAME_MAGIC = 0x57434d32;
|
|
11
|
+
exports.MEDIA_FRAME_VERSION = 1;
|
|
12
|
+
exports.MEDIA_FRAME_HEADER_BYTES = 40;
|
|
13
|
+
const KIND_TO_CODE = {
|
|
14
|
+
'audio-pcm': 1,
|
|
15
|
+
'audio-record': 2,
|
|
16
|
+
'video-frame': 3,
|
|
17
|
+
};
|
|
18
|
+
const CODE_TO_KIND = {
|
|
19
|
+
1: 'audio-pcm',
|
|
20
|
+
2: 'audio-record',
|
|
21
|
+
3: 'video-frame',
|
|
22
|
+
};
|
|
23
|
+
const CODEC_TO_CODE = {
|
|
24
|
+
pcm16: 1,
|
|
25
|
+
float32: 2,
|
|
26
|
+
jpeg: 10,
|
|
27
|
+
png: 11,
|
|
28
|
+
webp: 12,
|
|
29
|
+
'webm-opus': 20,
|
|
30
|
+
};
|
|
31
|
+
const CODE_TO_CODEC = {
|
|
32
|
+
1: 'pcm16',
|
|
33
|
+
2: 'float32',
|
|
34
|
+
10: 'jpeg',
|
|
35
|
+
11: 'png',
|
|
36
|
+
12: 'webp',
|
|
37
|
+
20: 'webm-opus',
|
|
38
|
+
};
|
|
39
|
+
function nowMono() {
|
|
40
|
+
const perf = globalThis.performance;
|
|
41
|
+
return typeof perf?.now == 'function' ? perf.now() : Date.now();
|
|
42
|
+
}
|
|
43
|
+
function toBytes(data) {
|
|
44
|
+
if (ArrayBuffer.isView(data))
|
|
45
|
+
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
46
|
+
return new Uint8Array(data);
|
|
47
|
+
}
|
|
48
|
+
function clonePayload(data) {
|
|
49
|
+
const src = toBytes(data);
|
|
50
|
+
const out = new Uint8Array(src.byteLength);
|
|
51
|
+
out.set(src);
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
function writeU32(view, offset, value) {
|
|
55
|
+
view.setUint32(offset, Math.max(0, Math.min(0xffffffff, Math.floor(value ?? 0))), true);
|
|
56
|
+
}
|
|
57
|
+
function readPayload(frame, headerBytes) {
|
|
58
|
+
return frame.subarray(headerBytes);
|
|
59
|
+
}
|
|
60
|
+
function encodeMediaFrame(meta, payload) {
|
|
61
|
+
const body = toBytes(payload);
|
|
62
|
+
const out = new Uint8Array(exports.MEDIA_FRAME_HEADER_BYTES + body.byteLength);
|
|
63
|
+
const view = new DataView(out.buffer, out.byteOffset, exports.MEDIA_FRAME_HEADER_BYTES);
|
|
64
|
+
view.setUint32(0, exports.MEDIA_FRAME_MAGIC, true);
|
|
65
|
+
view.setUint8(4, exports.MEDIA_FRAME_VERSION);
|
|
66
|
+
view.setUint8(5, KIND_TO_CODE[meta.kind]);
|
|
67
|
+
view.setUint8(6, CODEC_TO_CODE[meta.codec]);
|
|
68
|
+
view.setUint8(7, 0);
|
|
69
|
+
view.setUint16(8, exports.MEDIA_FRAME_HEADER_BYTES, true);
|
|
70
|
+
view.setUint16(10, 0, true);
|
|
71
|
+
writeU32(view, 12, meta.seq);
|
|
72
|
+
view.setFloat64(16, meta.tMono, true);
|
|
73
|
+
if (meta.kind == 'video-frame') {
|
|
74
|
+
writeU32(view, 24, meta.width);
|
|
75
|
+
writeU32(view, 28, meta.height);
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
writeU32(view, 24, meta.sampleRate);
|
|
79
|
+
writeU32(view, 28, meta.channels);
|
|
80
|
+
writeU32(view, 32, meta.nSamples);
|
|
81
|
+
}
|
|
82
|
+
writeU32(view, 36, body.byteLength);
|
|
83
|
+
out.set(body, exports.MEDIA_FRAME_HEADER_BYTES);
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
function decodeMediaFrame(frameLike) {
|
|
87
|
+
const frame = toBytes(frameLike);
|
|
88
|
+
if (frame.byteLength < exports.MEDIA_FRAME_HEADER_BYTES)
|
|
89
|
+
throw new Error('media frame too short');
|
|
90
|
+
const view = new DataView(frame.buffer, frame.byteOffset, exports.MEDIA_FRAME_HEADER_BYTES);
|
|
91
|
+
if (view.getUint32(0, true) != exports.MEDIA_FRAME_MAGIC)
|
|
92
|
+
throw new Error('media frame magic mismatch');
|
|
93
|
+
const version = view.getUint8(4);
|
|
94
|
+
if (version != exports.MEDIA_FRAME_VERSION)
|
|
95
|
+
throw new Error(`media frame version ${version} is not supported`);
|
|
96
|
+
const kind = CODE_TO_KIND[view.getUint8(5)];
|
|
97
|
+
const codec = CODE_TO_CODEC[view.getUint8(6)];
|
|
98
|
+
if (!kind || !codec)
|
|
99
|
+
throw new Error('media frame kind/codec is not supported');
|
|
100
|
+
const headerBytes = view.getUint16(8, true);
|
|
101
|
+
if (headerBytes < exports.MEDIA_FRAME_HEADER_BYTES || headerBytes > frame.byteLength)
|
|
102
|
+
throw new Error('media frame header size is invalid');
|
|
103
|
+
const payloadBytes = view.getUint32(36, true);
|
|
104
|
+
const payload = readPayload(frame, headerBytes);
|
|
105
|
+
if (payload.byteLength != payloadBytes)
|
|
106
|
+
throw new Error('media frame payload size mismatch');
|
|
107
|
+
const base = {
|
|
108
|
+
kind,
|
|
109
|
+
codec,
|
|
110
|
+
seq: view.getUint32(12, true),
|
|
111
|
+
tMono: view.getFloat64(16, true),
|
|
112
|
+
payload,
|
|
113
|
+
};
|
|
114
|
+
if (kind == 'video-frame') {
|
|
115
|
+
return {
|
|
116
|
+
...base,
|
|
117
|
+
width: view.getUint32(24, true),
|
|
118
|
+
height: view.getUint32(28, true),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
...base,
|
|
123
|
+
sampleRate: view.getUint32(24, true),
|
|
124
|
+
channels: view.getUint32(28, true),
|
|
125
|
+
nSamples: view.getUint32(32, true),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function createStats(sourceId, kind) {
|
|
129
|
+
return {
|
|
130
|
+
sourceId,
|
|
131
|
+
kind,
|
|
132
|
+
state: 'idle',
|
|
133
|
+
seq: 0,
|
|
134
|
+
frames: 0,
|
|
135
|
+
bytes: 0,
|
|
136
|
+
dropped: 0,
|
|
137
|
+
startedAt: 0,
|
|
138
|
+
lastAt: 0,
|
|
139
|
+
fps: 0,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
function hasGetUserMedia() {
|
|
143
|
+
const nav = globalThis.navigator;
|
|
144
|
+
return typeof nav?.mediaDevices?.getUserMedia == 'function';
|
|
145
|
+
}
|
|
146
|
+
async function listDevices(kind) {
|
|
147
|
+
const nav = globalThis.navigator;
|
|
148
|
+
if (typeof nav?.mediaDevices?.enumerateDevices != 'function')
|
|
149
|
+
return [];
|
|
150
|
+
const devices = await nav.mediaDevices.enumerateDevices();
|
|
151
|
+
const prefix = kind == 'audio' ? 'audioinput' : 'videoinput';
|
|
152
|
+
return devices
|
|
153
|
+
.filter(d => d?.kind == prefix)
|
|
154
|
+
.map(d => ({ deviceId: String(d.deviceId ?? ''), label: String(d.label ?? ''), kind: String(d.kind ?? '') }));
|
|
155
|
+
}
|
|
156
|
+
function stopTracks(stream) {
|
|
157
|
+
const tracks = typeof stream?.getTracks == 'function' ? stream.getTracks() : [];
|
|
158
|
+
for (const track of tracks)
|
|
159
|
+
track.stop?.();
|
|
160
|
+
}
|
|
161
|
+
function stateFromMediaError(e) {
|
|
162
|
+
const name = String(e?.name ?? '');
|
|
163
|
+
if (name == 'NotAllowedError' || name == 'SecurityError' || name == 'PermissionDeniedError')
|
|
164
|
+
return 'denied';
|
|
165
|
+
if (name == 'NotFoundError' || name == 'DevicesNotFoundError')
|
|
166
|
+
return 'no-device';
|
|
167
|
+
return 'error';
|
|
168
|
+
}
|
|
169
|
+
function defaultReplayOptions(kind) {
|
|
170
|
+
if (kind == 'video') {
|
|
171
|
+
return {
|
|
172
|
+
history: 256,
|
|
173
|
+
current: 'last',
|
|
174
|
+
frame: tail => tail.length ? [tail[tail.length - 1]] : [],
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
return { history: 1024 };
|
|
178
|
+
}
|
|
179
|
+
function createSourceShell(kind, sourceId, transport, replay) {
|
|
180
|
+
const replayOpts = replay == true ? defaultReplayOptions(kind) : replay;
|
|
181
|
+
const [emit, listenApi] = replayOpts ? (0, replay_listen_1.replayListen)(replayOpts) : (0, Listen_1.listen)();
|
|
182
|
+
const stats = createStats(sourceId, kind);
|
|
183
|
+
let state = 'idle';
|
|
184
|
+
let error;
|
|
185
|
+
function setState(next, err) {
|
|
186
|
+
state = next;
|
|
187
|
+
stats.state = next;
|
|
188
|
+
error = err ? String(err?.message ?? err) : undefined;
|
|
189
|
+
stats.error = error;
|
|
190
|
+
}
|
|
191
|
+
function emitFrame(frame, seq) {
|
|
192
|
+
stats.seq = seq;
|
|
193
|
+
stats.frames++;
|
|
194
|
+
stats.bytes += frame.byteLength;
|
|
195
|
+
stats.lastAt = nowMono();
|
|
196
|
+
const age = Math.max(1, stats.lastAt - (stats.startedAt || stats.lastAt));
|
|
197
|
+
stats.fps = Math.round((stats.frames * 10000) / age) / 10;
|
|
198
|
+
emit(frame);
|
|
199
|
+
}
|
|
200
|
+
return { emit, listenApi, stats, get state() { return state; }, get error() { return error; }, setState, emitFrame };
|
|
201
|
+
}
|
|
202
|
+
function attachControl(pair, control) {
|
|
203
|
+
return Object.assign(pair, control);
|
|
204
|
+
}
|
|
205
|
+
function ensureSocketTransport(mode) {
|
|
206
|
+
if (mode == 'webrtc') {
|
|
207
|
+
throw new Error('WebRTC media transport is reserved for a future opt-in SFU/signaling adapter; use transport:"socket" today');
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
function int16PcmFromFloat(input) {
|
|
211
|
+
const out = new Int16Array(input.length);
|
|
212
|
+
for (let i = 0; i < input.length; i++) {
|
|
213
|
+
const x = Math.max(-1, Math.min(1, input[i]));
|
|
214
|
+
out[i] = x < 0 ? x * 0x8000 : x * 0x7fff;
|
|
215
|
+
}
|
|
216
|
+
return new Uint8Array(out.buffer);
|
|
217
|
+
}
|
|
218
|
+
function interleaveChannels(buffers, frames, channels) {
|
|
219
|
+
const out = new Float32Array(frames * channels);
|
|
220
|
+
for (let i = 0; i < frames; i++) {
|
|
221
|
+
for (let ch = 0; ch < channels; ch++)
|
|
222
|
+
out[i * channels + ch] = buffers[ch]?.[i] ?? 0;
|
|
223
|
+
}
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
function rmsOf(input) {
|
|
227
|
+
if (input.length == 0)
|
|
228
|
+
return 0;
|
|
229
|
+
let sum = 0;
|
|
230
|
+
for (let i = 0; i < input.length; i++)
|
|
231
|
+
sum += input[i] * input[i];
|
|
232
|
+
return Math.sqrt(sum / input.length);
|
|
233
|
+
}
|
|
234
|
+
function audioWorkletCode() {
|
|
235
|
+
return `
|
|
236
|
+
class WenayCommon2PcmProcessor extends AudioWorkletProcessor {
|
|
237
|
+
process(inputs) {
|
|
238
|
+
const input = inputs[0]
|
|
239
|
+
if (!input || !input.length || !input[0]) return true
|
|
240
|
+
const channels = input.length
|
|
241
|
+
const frames = input[0].length
|
|
242
|
+
const out = new Float32Array(frames * channels)
|
|
243
|
+
for (let i = 0; i < frames; i++) {
|
|
244
|
+
for (let ch = 0; ch < channels; ch++) out[i * channels + ch] = input[ch] ? input[ch][i] : 0
|
|
245
|
+
}
|
|
246
|
+
this.port.postMessage({sampleRate, channels, frames, samples: out}, [out.buffer])
|
|
247
|
+
return true
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
registerProcessor('wenay-common2-pcm', WenayCommon2PcmProcessor)
|
|
251
|
+
`;
|
|
252
|
+
}
|
|
253
|
+
function createAudioSource(opts = {}) {
|
|
254
|
+
const sourceId = opts.sourceId ?? 'audio';
|
|
255
|
+
const transport = opts.transport ?? 'socket';
|
|
256
|
+
const shell = createSourceShell('audio', sourceId, transport, opts.replay);
|
|
257
|
+
let deviceId = opts.deviceId;
|
|
258
|
+
let stream = null;
|
|
259
|
+
let audioCtx = null;
|
|
260
|
+
let mediaNode = null;
|
|
261
|
+
let workletNode = null;
|
|
262
|
+
let recorder = null;
|
|
263
|
+
let seq = 0;
|
|
264
|
+
function emitPcm(samples, sampleRate, channels, frames) {
|
|
265
|
+
const codec = opts.format == 'float32' ? 'float32' : 'pcm16';
|
|
266
|
+
const payload = codec == 'float32' ? new Uint8Array(samples.buffer, samples.byteOffset, samples.byteLength) : int16PcmFromFloat(samples);
|
|
267
|
+
shell.stats.rms = rmsOf(samples);
|
|
268
|
+
shell.emitFrame(encodeMediaFrame({
|
|
269
|
+
kind: 'audio-pcm',
|
|
270
|
+
codec,
|
|
271
|
+
seq: ++seq,
|
|
272
|
+
tMono: nowMono(),
|
|
273
|
+
sampleRate,
|
|
274
|
+
channels,
|
|
275
|
+
nSamples: frames,
|
|
276
|
+
}, payload), seq);
|
|
277
|
+
}
|
|
278
|
+
async function startPcm(nextStream) {
|
|
279
|
+
const AudioContextCtor = globalThis.AudioContext ?? globalThis.webkitAudioContext;
|
|
280
|
+
if (!AudioContextCtor)
|
|
281
|
+
throw new Error('AudioContext is not available');
|
|
282
|
+
audioCtx = new AudioContextCtor(opts.sampleRate ? { sampleRate: opts.sampleRate } : undefined);
|
|
283
|
+
mediaNode = audioCtx.createMediaStreamSource(nextStream);
|
|
284
|
+
if (opts.worklet != false && audioCtx.audioWorklet && globalThis.Blob && globalThis.URL) {
|
|
285
|
+
const blob = new globalThis.Blob([audioWorkletCode()], { type: 'text/javascript' });
|
|
286
|
+
const url = globalThis.URL.createObjectURL(blob);
|
|
287
|
+
try {
|
|
288
|
+
await audioCtx.audioWorklet.addModule(url);
|
|
289
|
+
}
|
|
290
|
+
finally {
|
|
291
|
+
globalThis.URL.revokeObjectURL(url);
|
|
292
|
+
}
|
|
293
|
+
const AudioWorkletNodeCtor = globalThis.AudioWorkletNode;
|
|
294
|
+
workletNode = new AudioWorkletNodeCtor(audioCtx, 'wenay-common2-pcm', {
|
|
295
|
+
numberOfInputs: 1,
|
|
296
|
+
numberOfOutputs: 0,
|
|
297
|
+
channelCount: opts.channels ?? 1,
|
|
298
|
+
});
|
|
299
|
+
workletNode.port.onmessage = function onWorkletSamples(ev) {
|
|
300
|
+
const data = ev.data ?? {};
|
|
301
|
+
emitPcm(data.samples, data.sampleRate ?? audioCtx.sampleRate, data.channels ?? 1, data.frames ?? 0);
|
|
302
|
+
};
|
|
303
|
+
mediaNode.connect(workletNode);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
const channels = opts.channels ?? 1;
|
|
307
|
+
const processor = audioCtx.createScriptProcessor(opts.bufferSize ?? 2048, channels, channels);
|
|
308
|
+
processor.onaudioprocess = function onAudioProcess(ev) {
|
|
309
|
+
const input = ev.inputBuffer;
|
|
310
|
+
const frames = input.length;
|
|
311
|
+
const buffers = [];
|
|
312
|
+
for (let ch = 0; ch < channels; ch++)
|
|
313
|
+
buffers.push(input.getChannelData(Math.min(ch, input.numberOfChannels - 1)));
|
|
314
|
+
emitPcm(interleaveChannels(buffers, frames, channels), input.sampleRate ?? audioCtx.sampleRate, channels, frames);
|
|
315
|
+
const out = ev.outputBuffer;
|
|
316
|
+
if (out)
|
|
317
|
+
for (let ch = 0; ch < out.numberOfChannels; ch++)
|
|
318
|
+
out.getChannelData(ch).fill(0);
|
|
319
|
+
};
|
|
320
|
+
mediaNode.connect(processor);
|
|
321
|
+
processor.connect(audioCtx.destination);
|
|
322
|
+
workletNode = processor;
|
|
323
|
+
}
|
|
324
|
+
function startRecord(nextStream) {
|
|
325
|
+
const Recorder = globalThis.MediaRecorder;
|
|
326
|
+
if (!Recorder)
|
|
327
|
+
throw new Error('MediaRecorder is not available');
|
|
328
|
+
const mimeType = opts.recordMimeType ?? 'audio/webm;codecs=opus';
|
|
329
|
+
recorder = new Recorder(nextStream, Recorder.isTypeSupported?.(mimeType) ? { mimeType } : undefined);
|
|
330
|
+
recorder.ondataavailable = async function onRecordChunk(ev) {
|
|
331
|
+
if (!ev.data || ev.data.size == 0)
|
|
332
|
+
return;
|
|
333
|
+
const payload = new Uint8Array(await ev.data.arrayBuffer());
|
|
334
|
+
shell.emitFrame(encodeMediaFrame({
|
|
335
|
+
kind: 'audio-record',
|
|
336
|
+
codec: 'webm-opus',
|
|
337
|
+
seq: ++seq,
|
|
338
|
+
tMono: nowMono(),
|
|
339
|
+
}, payload), seq);
|
|
340
|
+
};
|
|
341
|
+
recorder.start(opts.recordTimesliceMs ?? 1000);
|
|
342
|
+
}
|
|
343
|
+
function stop() {
|
|
344
|
+
recorder?.stop?.();
|
|
345
|
+
recorder = null;
|
|
346
|
+
workletNode?.disconnect?.();
|
|
347
|
+
mediaNode?.disconnect?.();
|
|
348
|
+
audioCtx?.close?.();
|
|
349
|
+
stopTracks(stream);
|
|
350
|
+
stream = null;
|
|
351
|
+
audioCtx = null;
|
|
352
|
+
mediaNode = null;
|
|
353
|
+
workletNode = null;
|
|
354
|
+
shell.setState('idle');
|
|
355
|
+
}
|
|
356
|
+
async function start() {
|
|
357
|
+
try {
|
|
358
|
+
ensureSocketTransport(transport);
|
|
359
|
+
if (!hasGetUserMedia()) {
|
|
360
|
+
shell.setState('no-device');
|
|
361
|
+
return shell.state;
|
|
362
|
+
}
|
|
363
|
+
stop();
|
|
364
|
+
shell.setState('requesting');
|
|
365
|
+
shell.stats.startedAt = nowMono();
|
|
366
|
+
const constraints = { audio: { deviceId: deviceId ? { exact: deviceId } : undefined, channelCount: opts.channels, sampleRate: opts.sampleRate } };
|
|
367
|
+
stream = await globalThis.navigator.mediaDevices.getUserMedia(constraints);
|
|
368
|
+
if (opts.mode == 'record')
|
|
369
|
+
startRecord(stream);
|
|
370
|
+
else
|
|
371
|
+
await startPcm(stream);
|
|
372
|
+
shell.setState('live');
|
|
373
|
+
return shell.state;
|
|
374
|
+
}
|
|
375
|
+
catch (e) {
|
|
376
|
+
stopTracks(stream);
|
|
377
|
+
stream = null;
|
|
378
|
+
shell.setState(stateFromMediaError(e), e);
|
|
379
|
+
return shell.state;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
const control = {
|
|
383
|
+
start,
|
|
384
|
+
stop,
|
|
385
|
+
getStats: () => ({ ...shell.stats }),
|
|
386
|
+
setDevice: async (id) => {
|
|
387
|
+
deviceId = id;
|
|
388
|
+
return shell.state == 'live' ? start() : shell.state;
|
|
389
|
+
},
|
|
390
|
+
listDevices: () => listDevices('audio'),
|
|
391
|
+
get state() { return shell.state; },
|
|
392
|
+
sourceId,
|
|
393
|
+
kind: 'audio',
|
|
394
|
+
transport,
|
|
395
|
+
};
|
|
396
|
+
return attachControl([shell.emit, shell.listenApi], control);
|
|
397
|
+
}
|
|
398
|
+
function mimeForVideo(codec) {
|
|
399
|
+
if (codec == 'png')
|
|
400
|
+
return 'image/png';
|
|
401
|
+
if (codec == 'webp')
|
|
402
|
+
return 'image/webp';
|
|
403
|
+
return 'image/jpeg';
|
|
404
|
+
}
|
|
405
|
+
function canvasToBlob(canvas, mime, quality) {
|
|
406
|
+
if (typeof canvas.convertToBlob == 'function')
|
|
407
|
+
return canvas.convertToBlob({ type: mime, quality });
|
|
408
|
+
return new Promise((resolve, reject) => {
|
|
409
|
+
if (typeof canvas.toBlob != 'function') {
|
|
410
|
+
reject(new Error('canvas.toBlob is not available'));
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
canvas.toBlob((blob) => blob ? resolve(blob) : reject(new Error('canvas.toBlob returned null')), mime, quality);
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
function createVideoSource(opts = {}) {
|
|
417
|
+
const sourceId = opts.sourceId ?? 'video';
|
|
418
|
+
const transport = opts.transport ?? 'socket';
|
|
419
|
+
const shell = createSourceShell('video', sourceId, transport, opts.replay);
|
|
420
|
+
let deviceId = opts.deviceId;
|
|
421
|
+
let stream = null;
|
|
422
|
+
let video = opts.video;
|
|
423
|
+
let canvas = opts.canvas;
|
|
424
|
+
let ctx = null;
|
|
425
|
+
let timer = null;
|
|
426
|
+
let busy = false;
|
|
427
|
+
let seq = 0;
|
|
428
|
+
async function captureOne() {
|
|
429
|
+
if (busy || shell.state != 'live') {
|
|
430
|
+
shell.stats.dropped++;
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
busy = true;
|
|
434
|
+
try {
|
|
435
|
+
const w = opts.width ?? video.videoWidth ?? video.width ?? 0;
|
|
436
|
+
const h = opts.height ?? video.videoHeight ?? video.height ?? 0;
|
|
437
|
+
if (!w || !h)
|
|
438
|
+
return;
|
|
439
|
+
if (canvas.width != w)
|
|
440
|
+
canvas.width = w;
|
|
441
|
+
if (canvas.height != h)
|
|
442
|
+
canvas.height = h;
|
|
443
|
+
ctx.drawImage(video, 0, 0, w, h);
|
|
444
|
+
const codec = opts.codec ?? 'jpeg';
|
|
445
|
+
const blob = await canvasToBlob(canvas, mimeForVideo(codec), opts.quality ?? 0.82);
|
|
446
|
+
const payload = new Uint8Array(await blob.arrayBuffer());
|
|
447
|
+
shell.emitFrame(encodeMediaFrame({
|
|
448
|
+
kind: 'video-frame',
|
|
449
|
+
codec,
|
|
450
|
+
seq: ++seq,
|
|
451
|
+
tMono: nowMono(),
|
|
452
|
+
width: w,
|
|
453
|
+
height: h,
|
|
454
|
+
}, payload), seq);
|
|
455
|
+
}
|
|
456
|
+
catch (e) {
|
|
457
|
+
shell.setState('error', e);
|
|
458
|
+
}
|
|
459
|
+
finally {
|
|
460
|
+
busy = false;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
function stop() {
|
|
464
|
+
if (timer) {
|
|
465
|
+
clearInterval(timer);
|
|
466
|
+
timer = null;
|
|
467
|
+
}
|
|
468
|
+
stopTracks(stream);
|
|
469
|
+
stream = null;
|
|
470
|
+
if (!opts.video && video) {
|
|
471
|
+
try {
|
|
472
|
+
video.srcObject = null;
|
|
473
|
+
}
|
|
474
|
+
catch { }
|
|
475
|
+
}
|
|
476
|
+
shell.setState('idle');
|
|
477
|
+
}
|
|
478
|
+
async function start() {
|
|
479
|
+
try {
|
|
480
|
+
ensureSocketTransport(transport);
|
|
481
|
+
if (!hasGetUserMedia()) {
|
|
482
|
+
shell.setState('no-device');
|
|
483
|
+
return shell.state;
|
|
484
|
+
}
|
|
485
|
+
const doc = globalThis.document;
|
|
486
|
+
if (!video && !doc?.createElement) {
|
|
487
|
+
shell.setState('error', 'document.createElement is not available');
|
|
488
|
+
return shell.state;
|
|
489
|
+
}
|
|
490
|
+
stop();
|
|
491
|
+
shell.setState('requesting');
|
|
492
|
+
shell.stats.startedAt = nowMono();
|
|
493
|
+
stream = await globalThis.navigator.mediaDevices.getUserMedia({
|
|
494
|
+
video: {
|
|
495
|
+
deviceId: deviceId ? { exact: deviceId } : undefined,
|
|
496
|
+
width: opts.width,
|
|
497
|
+
height: opts.height,
|
|
498
|
+
},
|
|
499
|
+
});
|
|
500
|
+
video = video ?? doc.createElement('video');
|
|
501
|
+
video.muted = true;
|
|
502
|
+
video.playsInline = true;
|
|
503
|
+
video.srcObject = stream;
|
|
504
|
+
await video.play?.();
|
|
505
|
+
canvas = canvas ?? doc.createElement('canvas');
|
|
506
|
+
ctx = canvas.getContext('2d');
|
|
507
|
+
if (!ctx)
|
|
508
|
+
throw new Error('2d canvas context is not available');
|
|
509
|
+
shell.setState('live');
|
|
510
|
+
const ms = Math.max(1, Math.round(1000 / (opts.fps ?? 3)));
|
|
511
|
+
await captureOne();
|
|
512
|
+
timer = setInterval(captureOne, ms);
|
|
513
|
+
return shell.state;
|
|
514
|
+
}
|
|
515
|
+
catch (e) {
|
|
516
|
+
stopTracks(stream);
|
|
517
|
+
stream = null;
|
|
518
|
+
shell.setState(stateFromMediaError(e), e);
|
|
519
|
+
return shell.state;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
const control = {
|
|
523
|
+
start,
|
|
524
|
+
stop,
|
|
525
|
+
getStats: () => ({ ...shell.stats }),
|
|
526
|
+
setDevice: async (id) => {
|
|
527
|
+
deviceId = id;
|
|
528
|
+
return shell.state == 'live' ? start() : shell.state;
|
|
529
|
+
},
|
|
530
|
+
listDevices: () => listDevices('video'),
|
|
531
|
+
get state() { return shell.state; },
|
|
532
|
+
sourceId,
|
|
533
|
+
kind: 'video',
|
|
534
|
+
transport,
|
|
535
|
+
};
|
|
536
|
+
return attachControl([shell.emit, shell.listenApi], control);
|
|
537
|
+
}
|
package/lib/client.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ export * from "./Common/events/event";
|
|
|
13
13
|
export * from "./Common/events/SocketBuffer";
|
|
14
14
|
export * from "./Common/events/SocketServerHook";
|
|
15
15
|
export * from "./Common/events/joinListens";
|
|
16
|
+
export * from "./Common/media/media-index";
|
|
16
17
|
export * from "./Common/rcp/rpc-index";
|
|
17
18
|
export * from "./Common/Color";
|
|
18
19
|
export * from "./Common/funcTimeWait";
|
|
@@ -27,3 +28,4 @@ export * as BaseTypes from "./Common/core/BaseTypes";
|
|
|
27
28
|
export * as Params from "./Exchange/CParams";
|
|
28
29
|
export * as Time from "./Common/Time";
|
|
29
30
|
export * as Color from "./Common/Color";
|
|
31
|
+
export * as Media from "./Common/media/media-index";
|
package/lib/client.js
CHANGED
|
@@ -36,7 +36,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
36
36
|
};
|
|
37
37
|
})();
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
-
exports.Color = exports.Time = exports.Params = exports.BaseTypes = exports.Bars = exports.Math = void 0;
|
|
39
|
+
exports.Media = exports.Color = exports.Time = exports.Params = exports.BaseTypes = exports.Bars = exports.Math = void 0;
|
|
40
40
|
__exportStar(require("./Common/core/Decorator"), exports);
|
|
41
41
|
__exportStar(require("./Common/core/BaseTypes"), exports);
|
|
42
42
|
__exportStar(require("./Common/core/common"), exports);
|
|
@@ -52,6 +52,7 @@ __exportStar(require("./Common/events/event"), exports);
|
|
|
52
52
|
__exportStar(require("./Common/events/SocketBuffer"), exports);
|
|
53
53
|
__exportStar(require("./Common/events/SocketServerHook"), exports);
|
|
54
54
|
__exportStar(require("./Common/events/joinListens"), exports);
|
|
55
|
+
__exportStar(require("./Common/media/media-index"), exports);
|
|
55
56
|
__exportStar(require("./Common/rcp/rpc-index"), exports);
|
|
56
57
|
__exportStar(require("./Common/Color"), exports);
|
|
57
58
|
__exportStar(require("./Common/funcTimeWait"), exports);
|
|
@@ -66,3 +67,4 @@ exports.BaseTypes = __importStar(require("./Common/core/BaseTypes"));
|
|
|
66
67
|
exports.Params = __importStar(require("./Exchange/CParams"));
|
|
67
68
|
exports.Time = __importStar(require("./Common/Time"));
|
|
68
69
|
exports.Color = __importStar(require("./Common/Color"));
|
|
70
|
+
exports.Media = __importStar(require("./Common/media/media-index"));
|
package/lib/index.d.ts
CHANGED
|
@@ -17,6 +17,7 @@ export * from "./Common/events/SocketBuffer";
|
|
|
17
17
|
export * from "./Common/events/SocketServerHook";
|
|
18
18
|
export * from "./Common/events/joinListens";
|
|
19
19
|
export * from "./Common/events/mapListen";
|
|
20
|
+
export * from "./Common/media/media-index";
|
|
20
21
|
export * from "./Common/rcp/rpc-index";
|
|
21
22
|
export * from "./Common/Color";
|
|
22
23
|
export * from "./Common/funcTimeWait";
|
|
@@ -33,3 +34,4 @@ export * as Time from "./Common/Time";
|
|
|
33
34
|
export * as Color from "./Common/Color";
|
|
34
35
|
export * as Observe from "./Common/Observe";
|
|
35
36
|
export * as Replay from "./Common/events/replay-index";
|
|
37
|
+
export * as Media from "./Common/media/media-index";
|
package/lib/index.js
CHANGED
|
@@ -36,7 +36,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
36
36
|
};
|
|
37
37
|
})();
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
-
exports.Replay = exports.Observe = exports.Color = exports.Time = exports.Params = exports.Bars = exports.Math = void 0;
|
|
39
|
+
exports.Media = exports.Replay = exports.Observe = exports.Color = exports.Time = exports.Params = exports.Bars = exports.Math = void 0;
|
|
40
40
|
__exportStar(require("./Common/node_console"), exports);
|
|
41
41
|
__exportStar(require("./Common/core/Decorator"), exports);
|
|
42
42
|
__exportStar(require("./Common/core/BaseTypes"), exports);
|
|
@@ -56,6 +56,7 @@ __exportStar(require("./Common/events/SocketBuffer"), exports);
|
|
|
56
56
|
__exportStar(require("./Common/events/SocketServerHook"), exports);
|
|
57
57
|
__exportStar(require("./Common/events/joinListens"), exports);
|
|
58
58
|
__exportStar(require("./Common/events/mapListen"), exports);
|
|
59
|
+
__exportStar(require("./Common/media/media-index"), exports);
|
|
59
60
|
__exportStar(require("./Common/rcp/rpc-index"), exports);
|
|
60
61
|
__exportStar(require("./Common/Color"), exports);
|
|
61
62
|
__exportStar(require("./Common/funcTimeWait"), exports);
|
|
@@ -72,3 +73,4 @@ exports.Time = __importStar(require("./Common/Time"));
|
|
|
72
73
|
exports.Color = __importStar(require("./Common/Color"));
|
|
73
74
|
exports.Observe = __importStar(require("./Common/Observe"));
|
|
74
75
|
exports.Replay = __importStar(require("./Common/events/replay-index"));
|
|
76
|
+
exports.Media = __importStar(require("./Common/media/media-index"));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wenay-common2",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.66",
|
|
4
4
|
"description": "Common library",
|
|
5
5
|
"strict": true,
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"CLAUDE.md",
|
|
12
12
|
"rpc.md",
|
|
13
13
|
"!doc/target/**/*",
|
|
14
|
+
"!doc/progress/**/*",
|
|
14
15
|
"!**/*.tsbuildinfo"
|
|
15
16
|
],
|
|
16
17
|
"author": "wenay",
|
|
@@ -33,7 +34,8 @@
|
|
|
33
34
|
"./lib/server": "./lib/server.js",
|
|
34
35
|
"./client": "./lib/client.js",
|
|
35
36
|
"./server": "./lib/server.js",
|
|
36
|
-
"./package.json": "./package.json"
|
|
37
|
+
"./package.json": "./package.json",
|
|
38
|
+
"./media": "./lib/Common/media/media-index.js"
|
|
37
39
|
},
|
|
38
40
|
"dependencies": {
|
|
39
41
|
"express": "^5.2.1"
|