-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmedia-stream-track-processor.ts
More file actions
181 lines (152 loc) · 5.64 KB
/
Copy pathmedia-stream-track-processor.ts
File metadata and controls
181 lines (152 loc) · 5.64 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
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
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
// Firefox doesn't support MediaStreamTrackProcessor so we need to use a polyfill.
// Based on: https://jan-ivar.github.io/polyfills/mediastreamtrackprocessor.js
// and https://github.com/moq-dev/moq/blob/main/js/hang/src/publish/video/polyfill.ts
// AudioWorklet processor code as a string (will be loaded as a Blob URL), avoid worker loading issues in build systems
const AUDIO_WORKLET_CODE = `
class AudioCaptureProcessor extends AudioWorkletProcessor {
sampleCount = 0;
process(inputs) {
if (inputs.length === 0 || inputs[0].length === 0) {
return true;
}
const channels = inputs[0];
const timestamp = (this.sampleCount / sampleRate) * 1_000_000; // Convert to microseconds
this.port.postMessage({
timestamp,
channels: channels.map(channel => channel.slice()),
});
this.sampleCount += channels[0].length;
return true;
}
}
registerProcessor('audio-capture-processor', AudioCaptureProcessor);
`;
class MediaStreamTrackProcessorPolyfill {
readable: ReadableStream<VideoFrame | AudioData>;
constructor({ track }: { track: MediaStreamTrack }) {
const settings = track.getSettings();
if (!settings) {
throw new Error("track has no settings");
}
// Detect track type and use appropriate polyfill
if (track.kind === 'video') {
this.readable = this.createVideoStream(track, settings);
} else if (track.kind === 'audio') {
this.readable = this.createAudioStream(track, settings);
} else {
throw new Error(`Unsupported track kind: ${track.kind}`);
}
}
private createVideoStream(track: MediaStreamTrack, settings: MediaTrackSettings): ReadableStream<VideoFrame> {
let video: HTMLVideoElement;
let last: number;
let lastDuration: number | undefined;
const frameRate = settings.frameRate ?? 30;
return new ReadableStream<VideoFrame>({
async start() {
video = document.createElement("video") as HTMLVideoElement;
video.srcObject = new MediaStream([track]);
await Promise.all([
video.play(),
new Promise((r) => {
video.onloadedmetadata = r;
}),
]);
last = performance.now();
},
async pull(controller) {
while (true) {
const now = performance.now();
if (now - last < 1000 / frameRate) {
await new Promise((r) => requestAnimationFrame(r));
continue;
}
// Calculate duration based on actual frame timing
const duration = lastDuration ?? Math.round((now - last) * 1000);
lastDuration = duration;
last = now;
controller.enqueue(new VideoFrame(video, {
timestamp: last * 1000,
duration
}));
break;
}
},
});
}
private createAudioStream(track: MediaStreamTrack, settings: MediaTrackSettings): ReadableStream<AudioData> {
let audioContext: AudioContext;
let workletNode: AudioWorkletNode;
let workletUrl: string;
return new ReadableStream<AudioData>({
async start(controller) {
// Create AudioContext
audioContext = new AudioContext({
sampleRate: settings.sampleRate || 48000,
});
// Create MediaStreamAudioSourceNode from the track
const source = new MediaStreamAudioSourceNode(audioContext, {
mediaStream: new MediaStream([track]),
});
// Load the worklet from a Blob URL
const blob = new Blob([AUDIO_WORKLET_CODE], { type: 'application/javascript' });
workletUrl = URL.createObjectURL(blob);
await audioContext.audioWorklet.addModule(workletUrl);
// Create the worklet node
workletNode = new AudioWorkletNode(audioContext, 'audio-capture-processor', {
numberOfInputs: 1,
numberOfOutputs: 0,
channelCount: settings.channelCount || 2,
});
// Connect the source to the worklet
source.connect(workletNode);
// Listen for audio data from the worklet
workletNode.port.onmessage = (event) => {
const { timestamp, channels } = event.data;
// Convert channels to planar format
const channelData = channels as Float32Array[];
const numberOfFrames = channelData[0].length;
const numberOfChannels = channelData.length;
// Create a single buffer with all channels concatenated
const totalLength = numberOfFrames * numberOfChannels;
const buffer = new Float32Array(totalLength);
for (let i = 0; i < numberOfChannels; i++) {
buffer.set(channelData[i], i * numberOfFrames);
}
try {
const audioData = new AudioData({
format: 'f32-planar',
sampleRate: audioContext.sampleRate,
numberOfFrames,
numberOfChannels,
timestamp,
data: buffer,
});
controller.enqueue(audioData);
} catch (e) {
console.error('Failed to create AudioData:', e);
}
};
},
cancel() {
// Cleanup
if (workletNode) {
workletNode.disconnect();
workletNode.port.onmessage = null;
}
if (audioContext) {
audioContext.close();
}
if (workletUrl) {
URL.revokeObjectURL(workletUrl);
}
}
});
}
}
// Auto-polyfill if not available
if (!self.MediaStreamTrackProcessor) {
self.MediaStreamTrackProcessor = MediaStreamTrackProcessorPolyfill;
}
// Export native if available, polyfill otherwise
export const MediaStreamTrackProcessor = self.MediaStreamTrackProcessor || MediaStreamTrackProcessorPolyfill;