Skip to content

Commit ffe95e0

Browse files
committed
Merge branch 'release/mp4-media-stream-2024.2.0'
2 parents 330c7f5 + a09d951 commit ffe95e0

File tree

8 files changed

+133
-72
lines changed

8 files changed

+133
-72
lines changed

.github/workflows/release.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ jobs:
2121
tag_name: ${{ steps.get_version.outputs.VERSION }}
2222
release_name: ${{ steps.get_version.outputs.VERSION }}
2323
draft: true
24-
prerelease: true
2524

2625
notification:
2726
name: Slack Notification

CHANGES.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,18 @@
1111

1212
## develop
1313

14+
## mp4-media-stream-2024.2.0
15+
16+
- [CHANGE] `Mp4MediaStream.play()` を非同期にする
17+
- @sile
18+
- [FIX] `Mp4MediaStream` が生成した `MediaStream` を WebRTC の入力とすると受信側で映像と音声のタイムスタンプが大幅にズレることがある問題を修正する
19+
- 以前は `MediaStreamTrackGenerator` を使って、映像および音声の出力先の `MediaTrack` を生成していた
20+
- ただし `MediaStreamTrackGenerator` に映像フレーム・音声データを書き込む際に指定するタイムスタンプを 0 始まりにすると、WebRTC を通した場合に映像と音声でのタイムスタンプが大幅(e.g., 数時間以上)にズレる問題が確認された
21+
- 実際に `MediaStreamTrackProcessor` が生成したタイムスタンプを確認したところ、0 始まりではなかったが、このタイムスタンプの基準値を外部から取得する簡単な方法はなさそうだった
22+
- 一度 `getUserMedia()` を呼び出してその結果を `MediaStreamTrackProcessor` に渡すことで取得できないことはないが現実的ではない
23+
- そのため、`MediaStreamTrackGenerator` は使うのは止めて、映像では `HTMLCanvasElement` を、音声では `AudioContext` を使って `MediaTrack` を生成するように変更した
24+
- @sile
25+
1426
## mp4-media-stream-2024.1.2
1527

1628
- [FIX] 音声のみの MP4 をロードした後に `Mp4MediaStream.play()` を呼び出すとエラーになる問題を修正する

examples/mp4-media-stream/main.mts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ document.addEventListener('DOMContentLoaded', async () => {
2828
}
2929
}
3030

31-
function play() {
31+
async function play() {
3232
if (mp4MediaStream === undefined) {
3333
alert('MP4 ファイルが未選択です')
3434
return
@@ -37,7 +37,7 @@ document.addEventListener('DOMContentLoaded', async () => {
3737
const options = {
3838
repeat: document.getElementById('repeat').checked,
3939
}
40-
const stream = mp4MediaStream.play(options)
40+
const stream = await mp4MediaStream.play(options)
4141

4242
const output = document.getElementById('output')
4343
output.srcObject = stream

examples/noise-suppression/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
<h1>Media Processors: ノイズ抑制サンプル</h1>
1010

1111
<b>GitHub: <a href="https://github.com/shiguredo/media-processors/tree/develop/packages/noise-suppression">https://github.com/shiguredo/media-processors/tree/develop/packages/noise-suppression</a></b>
12+
<br />
1213

1314
マイクに入力した音声が、ノイズ抑制されてスピーカから出力されます。<br />
1415
<strong><span style="color:#F00">※ イヤホン推奨</span></strong><br />

packages/mp4-media-stream/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@shiguredo/mp4-media-stream",
3-
"version": "2024.1.2",
3+
"version": "2024.2.0",
44
"description": "Library to generate MediaStream from MP4 file",
55
"author": "Shiguredo Inc.",
66
"license": "Apache-2.0",

packages/mp4-media-stream/rollup.config.mjs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ export default [
2222
__WASM__: () => fs.readFileSync("../../target/wasm32-unknown-unknown/release/mp4_media_stream.wasm", "base64"),
2323
preventAssignment: true
2424
}),
25+
replace({
26+
__AUDIO_PROCESSOR__: () => fs.readFileSync("src/audio_processor.js"),
27+
preventAssignment: true
28+
}),
2529
typescript({module: "esnext"}),
2630
commonjs(),
2731
resolve()
@@ -42,6 +46,10 @@ export default [
4246
__WASM__: () => fs.readFileSync("../../target/wasm32-unknown-unknown/release/mp4_media_stream.wasm", "base64"),
4347
preventAssignment: true
4448
}),
49+
replace({
50+
__AUDIO_PROCESSOR__: () => fs.readFileSync("src/audio_processor.js"),
51+
preventAssignment: true
52+
}),
4553
typescript({module: "esnext"}),
4654
commonjs(),
4755
resolve()
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// WebCodecs の音声デコーダーが生成した AudioData の中身を MediaTrack に伝えるためのプロセッサー
2+
class Mp4MediaStreamAudioWorkletProcessor extends AudioWorkletProcessor {
3+
constructor() {
4+
super()
5+
this.inputBuffer = []
6+
this.offset = 0
7+
this.port.onmessage = (e) => {
8+
this.inputBuffer.push(e.data)
9+
}
10+
}
11+
12+
process(inputs, outputs, parameters) {
13+
for (let sampleIdx = 0; sampleIdx < outputs[0][0].length; sampleIdx++) {
14+
for (let channelIdx = 0; channelIdx < outputs[0].length; channelIdx++) {
15+
const outputChannel = outputs[0][channelIdx]
16+
const audioData = this.inputBuffer[0]
17+
if (audioData === undefined) {
18+
// ここに来るのは、入力音声データにギャップがあるか、
19+
// デコード処理が詰まっていてデータの到着が遅れているケースが考えられる。
20+
// 後者の場合には、ここでゼロで埋めた分だけ後で破棄しないと、
21+
// 映像とのリップシンクがズレていってしまう。
22+
//
23+
// this.inputBuffer の中にはタイムスタンプの情報も含まれているので、
24+
// それを見て、より正確なゼロ埋めやサンプル破棄を行うことは可能なので、
25+
// 実際にこういったケースが問題になることがあれば対応を検討すること。
26+
outputChannel[sampleIdx] = 0
27+
} else {
28+
outputChannel[sampleIdx] = audioData.samples[this.offset]
29+
this.offset++
30+
if (this.offset === this.inputBuffer[0].samples.length) {
31+
this.inputBuffer.shift()
32+
this.offset = 0
33+
}
34+
}
35+
}
36+
}
37+
return true
38+
}
39+
}
40+
41+
registerProcessor('mp4-media-stream-audio-worklet-processor', Mp4MediaStreamAudioWorkletProcessor)

packages/mp4-media-stream/src/mp4_media_stream.ts

Lines changed: 68 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
const WASM_BASE64 = '__WASM__'
22

3+
// biome-ignore lint/style/noUnusedTemplateLiteral: audio_processor.js 内で文字列を扱いたいので `` で囲んでいる
4+
const AUDIO_WORKLET_PROCESSOR_CODE = `__AUDIO_PROCESSOR__`
5+
const AUDIO_WORKLET_PROCESSOR_NAME = 'mp4-media-stream-audio-worklet-processor'
6+
37
/**
48
* {@link Mp4MediaStream.play} に指定可能なオプション
59
*/
@@ -36,18 +40,13 @@ class Mp4MediaStream {
3640
* 実行環境が必要な機能をサポートしているかどうかを判定します
3741
*
3842
* 以下のクラスが利用可能である必要があります:
39-
* - MediaStreamTrackGenerator
4043
* - AudioDecoder
4144
* - VideoDecoder
4245
*
4346
* @returns サポートされているかどうか
4447
*/
4548
static isSupported(): boolean {
46-
return !(
47-
typeof MediaStreamTrackGenerator === 'undefined' ||
48-
typeof AudioDecoder === 'undefined' ||
49-
typeof VideoDecoder === 'undefined'
50-
)
49+
return typeof AudioDecoder !== 'undefined' && typeof VideoDecoder !== 'undefined'
5150
}
5251

5352
/**
@@ -141,7 +140,7 @@ class Mp4MediaStream {
141140
* ようにしないと、WebRTC の受信側で映像のフレームレートが極端に下がったり、止まったりする現象が確認されています。
142141
* なお、VideoElement はミュートかつ hidden visibility でも問題ありません。
143142
*/
144-
play(options: PlayOptions = {}): MediaStream {
143+
play(options: PlayOptions = {}): Promise<MediaStream> {
145144
if (this.info === undefined) {
146145
// ここには来ないはず
147146
throw new Error('bug')
@@ -150,7 +149,7 @@ class Mp4MediaStream {
150149
const playerId = this.nextPlayerId
151150
this.nextPlayerId += 1
152151

153-
const player = new Player(this.info.audioConfigs.length > 0, this.info.videoConfigs.length > 0)
152+
const player = new Player(this.info.audioConfigs, this.info.videoConfigs)
154153
this.players.set(playerId, player)
155154
;(this.wasm.exports.play as CallableFunction)(
156155
this.engine,
@@ -242,26 +241,17 @@ class Mp4MediaStream {
242241

243242
const init = {
244243
output: async (frame: VideoFrame) => {
245-
if (player.videoWriter === undefined) {
246-
// writer の出力先がすでに閉じられている場合などにここに来る可能性がある
244+
if (player.canvas === undefined || player.canvasCtx === undefined) {
247245
return
248246
}
249247

250248
try {
251-
await player.videoWriter.write(frame)
249+
player.canvas.width = frame.displayWidth
250+
player.canvas.height = frame.displayHeight
251+
player.canvasCtx.drawImage(frame, 0, 0)
252+
frame.close()
252253
} catch (error) {
253-
// 書き込みエラーが発生した場合には再生を停止する
254-
255-
if (error instanceof DOMException && error.name === 'InvalidStateError') {
256-
// 出力先の MediaStreamTrack が停止済み、などの理由で write() が失敗した場合にここに来る。
257-
// このケースは普通に発生し得るので正常系の一部。
258-
// writer はすでに閉じているので、重複 close() による警告ログ出力を避けるために undefined に設定する。
259-
player.videoWriter = undefined
260-
await this.stopPlayer(playerId)
261-
return
262-
}
263-
264-
// 想定外のエラーの場合は再送する
254+
// エラーが発生した場合には再生を停止する
265255
await this.stopPlayer(playerId)
266256
throw error
267257
}
@@ -296,26 +286,25 @@ class Mp4MediaStream {
296286
const config = this.wasmJsonToValue(configWasmJson) as AudioDecoderConfig
297287
const init = {
298288
output: async (data: AudioData) => {
299-
if (player.audioWriter === undefined) {
300-
// writer の出力先がすでに閉じられている場合などにここに来る可能性がある
289+
if (player.audioInputNode === undefined) {
301290
return
302291
}
303292

304293
try {
305-
await player.audioWriter.write(data)
306-
} catch (e) {
307-
// 書き込みエラーが発生した場合には再生を停止する
308-
309-
if (e instanceof DOMException && e.name === 'InvalidStateError') {
310-
// 出力先の MediaStreamTrack が停止済み、などの理由で write() が失敗した場合にここに来る。
311-
// このケースは普通に発生し得るので正常系の一部。
312-
// writer はすでに閉じているので、重複 close() による警告ログ出力を避けるために undefined に設定する。
313-
player.audioWriter = undefined
314-
await this.stopPlayer(playerId)
315-
return
294+
if (data.format !== 'f32') {
295+
// フォーマットは f32 だけが来る想定。
296+
// もし他のフォーマットが来ることがあれば、その都度対応すること。
297+
throw Error(`Unsupported audio data format: ${data.format}"`)
316298
}
317299

318-
// 想定外のエラーの場合は再送する
300+
const samples = new Float32Array(data.numberOfFrames * data.numberOfChannels)
301+
data.copyTo(samples, { planeIndex: 0 })
302+
data.close()
303+
304+
const timestamp = data.timestamp
305+
player.audioInputNode.port.postMessage({ timestamp, samples }, [samples.buffer])
306+
} catch (e) {
307+
// エラーが発生した場合には再生を停止する
319308
await this.stopPlayer(playerId)
320309
throw e
321310
}
@@ -433,27 +422,49 @@ type Mp4Info = {
433422
class Player {
434423
private audio: boolean
435424
private video: boolean
425+
private numberOfChannels = 1
426+
private sampleRate = 48000
436427
audioDecoder?: AudioDecoder
437428
videoDecoder?: VideoDecoder
438-
audioWriter?: WritableStreamDefaultWriter
439-
videoWriter?: WritableStreamDefaultWriter
440-
441-
constructor(audio: boolean, video: boolean) {
442-
this.audio = audio
443-
this.video = video
429+
canvas?: HTMLCanvasElement
430+
canvasCtx?: CanvasRenderingContext2D
431+
audioContext?: AudioContext
432+
audioInputNode?: AudioWorkletNode
433+
434+
constructor(audioConfigs: AudioDecoderConfig[], videoConfigs: VideoDecoderConfig[]) {
435+
this.audio = audioConfigs.length > 0
436+
this.video = videoConfigs.length > 0
437+
438+
if (audioConfigs.length > 0) {
439+
// [NOTE] 今は複数音声入力トラックには未対応なので、最初の一つに決め打ちでいい
440+
this.numberOfChannels = audioConfigs[0].numberOfChannels
441+
this.sampleRate = audioConfigs[0].sampleRate
442+
}
444443
}
445444

446-
createMediaStream(): MediaStream {
445+
async createMediaStream(): Promise<MediaStream> {
447446
const tracks = []
448447
if (this.audio) {
449-
const generator = new MediaStreamTrackGenerator({ kind: 'audio' })
450-
tracks.push(generator)
451-
this.audioWriter = generator.writable.getWriter()
448+
const blob = new Blob([AUDIO_WORKLET_PROCESSOR_CODE], { type: 'application/javascript' })
449+
this.audioContext = new AudioContext({ sampleRate: this.sampleRate })
450+
await this.audioContext.audioWorklet.addModule(URL.createObjectURL(blob))
451+
452+
this.audioInputNode = new AudioWorkletNode(this.audioContext, AUDIO_WORKLET_PROCESSOR_NAME, {
453+
outputChannelCount: [this.numberOfChannels],
454+
})
455+
456+
const destination = this.audioContext.createMediaStreamDestination()
457+
this.audioInputNode.connect(destination)
458+
tracks.push(destination.stream.getAudioTracks()[0])
452459
}
453460
if (this.video) {
454-
const generator = new MediaStreamTrackGenerator({ kind: 'video' })
455-
tracks.push(generator)
456-
this.videoWriter = generator.writable.getWriter()
461+
this.canvas = document.createElement('canvas')
462+
const canvasCtx = this.canvas.getContext('2d')
463+
if (canvasCtx === null) {
464+
throw Error('Failed to create 2D canvas context')
465+
}
466+
this.canvasCtx = canvasCtx
467+
tracks.push(this.canvas.captureStream().getVideoTracks()[0])
457468
}
458469
return new MediaStream(tracks)
459470
}
@@ -512,25 +523,14 @@ class Player {
512523
await this.closeAudioDecoder()
513524
await this.closeVideoDecoder()
514525

515-
if (this.audioWriter !== undefined) {
516-
try {
517-
await this.audioWriter.close()
518-
} catch (e) {
519-
// writer がエラー状態になっている場合などには close() に失敗する模様
520-
// 特に対処法も実害もなさそうなので、ログだけ出して無視しておく
521-
console.log(`[WARNING] ${e}`)
522-
}
523-
this.audioWriter = undefined
526+
if (this.audioContext !== undefined) {
527+
await this.audioContext.close()
528+
this.audioContext = undefined
529+
this.audioInputNode = undefined
524530
}
525-
if (this.videoWriter !== undefined) {
526-
try {
527-
await this.videoWriter.close()
528-
} catch (e) {
529-
// writer がエラー状態になっている場合などには close() に失敗する模様
530-
// 特に対処法も実害もなさそうなので、ログだけ出して無視しておく
531-
console.log(`[WARNING] ${e}`)
532-
}
533-
this.videoWriter = undefined
531+
if (this.canvas !== undefined) {
532+
this.canvas = undefined
533+
this.canvasCtx = undefined
534534
}
535535
}
536536
}

0 commit comments

Comments
 (0)