Skip to content

Commit

Permalink
Merge branch 'release/mp4-media-stream-2024.2.0'
Browse files Browse the repository at this point in the history
  • Loading branch information
sile committed Nov 26, 2024
2 parents 330c7f5 + a09d951 commit ffe95e0
Show file tree
Hide file tree
Showing 8 changed files with 133 additions and 72 deletions.
1 change: 0 additions & 1 deletion .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ jobs:
tag_name: ${{ steps.get_version.outputs.VERSION }}
release_name: ${{ steps.get_version.outputs.VERSION }}
draft: true
prerelease: true

notification:
name: Slack Notification
Expand Down
12 changes: 12 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,18 @@

## develop

## mp4-media-stream-2024.2.0

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

## mp4-media-stream-2024.1.2

- [FIX] 音声のみの MP4 をロードした後に `Mp4MediaStream.play()` を呼び出すとエラーになる問題を修正する
Expand Down
4 changes: 2 additions & 2 deletions examples/mp4-media-stream/main.mts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ document.addEventListener('DOMContentLoaded', async () => {
}
}

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

const output = document.getElementById('output')
output.srcObject = stream
Expand Down
1 change: 1 addition & 0 deletions examples/noise-suppression/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
<h1>Media Processors: ノイズ抑制サンプル</h1>

<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>
<br />

マイクに入力した音声が、ノイズ抑制されてスピーカから出力されます。<br />
<strong><span style="color:#F00">※ イヤホン推奨</span></strong><br />
Expand Down
2 changes: 1 addition & 1 deletion packages/mp4-media-stream/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@shiguredo/mp4-media-stream",
"version": "2024.1.2",
"version": "2024.2.0",
"description": "Library to generate MediaStream from MP4 file",
"author": "Shiguredo Inc.",
"license": "Apache-2.0",
Expand Down
8 changes: 8 additions & 0 deletions packages/mp4-media-stream/rollup.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ export default [
__WASM__: () => fs.readFileSync("../../target/wasm32-unknown-unknown/release/mp4_media_stream.wasm", "base64"),
preventAssignment: true
}),
replace({
__AUDIO_PROCESSOR__: () => fs.readFileSync("src/audio_processor.js"),
preventAssignment: true
}),
typescript({module: "esnext"}),
commonjs(),
resolve()
Expand All @@ -42,6 +46,10 @@ export default [
__WASM__: () => fs.readFileSync("../../target/wasm32-unknown-unknown/release/mp4_media_stream.wasm", "base64"),
preventAssignment: true
}),
replace({
__AUDIO_PROCESSOR__: () => fs.readFileSync("src/audio_processor.js"),
preventAssignment: true
}),
typescript({module: "esnext"}),
commonjs(),
resolve()
Expand Down
41 changes: 41 additions & 0 deletions packages/mp4-media-stream/src/audio_processor.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// WebCodecs の音声デコーダーが生成した AudioData の中身を MediaTrack に伝えるためのプロセッサー
class Mp4MediaStreamAudioWorkletProcessor extends AudioWorkletProcessor {
constructor() {
super()
this.inputBuffer = []
this.offset = 0
this.port.onmessage = (e) => {
this.inputBuffer.push(e.data)
}
}

process(inputs, outputs, parameters) {
for (let sampleIdx = 0; sampleIdx < outputs[0][0].length; sampleIdx++) {
for (let channelIdx = 0; channelIdx < outputs[0].length; channelIdx++) {
const outputChannel = outputs[0][channelIdx]
const audioData = this.inputBuffer[0]
if (audioData === undefined) {
// ここに来るのは、入力音声データにギャップがあるか、
// デコード処理が詰まっていてデータの到着が遅れているケースが考えられる。
// 後者の場合には、ここでゼロで埋めた分だけ後で破棄しないと、
// 映像とのリップシンクがズレていってしまう。
//
// this.inputBuffer の中にはタイムスタンプの情報も含まれているので、
// それを見て、より正確なゼロ埋めやサンプル破棄を行うことは可能なので、
// 実際にこういったケースが問題になることがあれば対応を検討すること。
outputChannel[sampleIdx] = 0
} else {
outputChannel[sampleIdx] = audioData.samples[this.offset]
this.offset++
if (this.offset === this.inputBuffer[0].samples.length) {
this.inputBuffer.shift()
this.offset = 0
}
}
}
}
return true
}
}

registerProcessor('mp4-media-stream-audio-worklet-processor', Mp4MediaStreamAudioWorkletProcessor)
136 changes: 68 additions & 68 deletions packages/mp4-media-stream/src/mp4_media_stream.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
const WASM_BASE64 = '__WASM__'

// biome-ignore lint/style/noUnusedTemplateLiteral: audio_processor.js 内で文字列を扱いたいので `` で囲んでいる
const AUDIO_WORKLET_PROCESSOR_CODE = `__AUDIO_PROCESSOR__`
const AUDIO_WORKLET_PROCESSOR_NAME = 'mp4-media-stream-audio-worklet-processor'

/**
* {@link Mp4MediaStream.play} に指定可能なオプション
*/
Expand Down Expand Up @@ -36,18 +40,13 @@ class Mp4MediaStream {
* 実行環境が必要な機能をサポートしているかどうかを判定します
*
* 以下のクラスが利用可能である必要があります:
* - MediaStreamTrackGenerator
* - AudioDecoder
* - VideoDecoder
*
* @returns サポートされているかどうか
*/
static isSupported(): boolean {
return !(
typeof MediaStreamTrackGenerator === 'undefined' ||
typeof AudioDecoder === 'undefined' ||
typeof VideoDecoder === 'undefined'
)
return typeof AudioDecoder !== 'undefined' && typeof VideoDecoder !== 'undefined'
}

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

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

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

try {
await player.videoWriter.write(frame)
player.canvas.width = frame.displayWidth
player.canvas.height = frame.displayHeight
player.canvasCtx.drawImage(frame, 0, 0)
frame.close()
} catch (error) {
// 書き込みエラーが発生した場合には再生を停止する

if (error instanceof DOMException && error.name === 'InvalidStateError') {
// 出力先の MediaStreamTrack が停止済み、などの理由で write() が失敗した場合にここに来る。
// このケースは普通に発生し得るので正常系の一部。
// writer はすでに閉じているので、重複 close() による警告ログ出力を避けるために undefined に設定する。
player.videoWriter = undefined
await this.stopPlayer(playerId)
return
}

// 想定外のエラーの場合は再送する
// エラーが発生した場合には再生を停止する
await this.stopPlayer(playerId)
throw error
}
Expand Down Expand Up @@ -296,26 +286,25 @@ class Mp4MediaStream {
const config = this.wasmJsonToValue(configWasmJson) as AudioDecoderConfig
const init = {
output: async (data: AudioData) => {
if (player.audioWriter === undefined) {
// writer の出力先がすでに閉じられている場合などにここに来る可能性がある
if (player.audioInputNode === undefined) {
return
}

try {
await player.audioWriter.write(data)
} catch (e) {
// 書き込みエラーが発生した場合には再生を停止する

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

// 想定外のエラーの場合は再送する
const samples = new Float32Array(data.numberOfFrames * data.numberOfChannels)
data.copyTo(samples, { planeIndex: 0 })
data.close()

const timestamp = data.timestamp
player.audioInputNode.port.postMessage({ timestamp, samples }, [samples.buffer])
} catch (e) {
// エラーが発生した場合には再生を停止する
await this.stopPlayer(playerId)
throw e
}
Expand Down Expand Up @@ -433,27 +422,49 @@ type Mp4Info = {
class Player {
private audio: boolean
private video: boolean
private numberOfChannels = 1
private sampleRate = 48000
audioDecoder?: AudioDecoder
videoDecoder?: VideoDecoder
audioWriter?: WritableStreamDefaultWriter
videoWriter?: WritableStreamDefaultWriter

constructor(audio: boolean, video: boolean) {
this.audio = audio
this.video = video
canvas?: HTMLCanvasElement
canvasCtx?: CanvasRenderingContext2D
audioContext?: AudioContext
audioInputNode?: AudioWorkletNode

constructor(audioConfigs: AudioDecoderConfig[], videoConfigs: VideoDecoderConfig[]) {
this.audio = audioConfigs.length > 0
this.video = videoConfigs.length > 0

if (audioConfigs.length > 0) {
// [NOTE] 今は複数音声入力トラックには未対応なので、最初の一つに決め打ちでいい
this.numberOfChannels = audioConfigs[0].numberOfChannels
this.sampleRate = audioConfigs[0].sampleRate
}
}

createMediaStream(): MediaStream {
async createMediaStream(): Promise<MediaStream> {
const tracks = []
if (this.audio) {
const generator = new MediaStreamTrackGenerator({ kind: 'audio' })
tracks.push(generator)
this.audioWriter = generator.writable.getWriter()
const blob = new Blob([AUDIO_WORKLET_PROCESSOR_CODE], { type: 'application/javascript' })
this.audioContext = new AudioContext({ sampleRate: this.sampleRate })
await this.audioContext.audioWorklet.addModule(URL.createObjectURL(blob))

this.audioInputNode = new AudioWorkletNode(this.audioContext, AUDIO_WORKLET_PROCESSOR_NAME, {
outputChannelCount: [this.numberOfChannels],
})

const destination = this.audioContext.createMediaStreamDestination()
this.audioInputNode.connect(destination)
tracks.push(destination.stream.getAudioTracks()[0])
}
if (this.video) {
const generator = new MediaStreamTrackGenerator({ kind: 'video' })
tracks.push(generator)
this.videoWriter = generator.writable.getWriter()
this.canvas = document.createElement('canvas')
const canvasCtx = this.canvas.getContext('2d')
if (canvasCtx === null) {
throw Error('Failed to create 2D canvas context')
}
this.canvasCtx = canvasCtx
tracks.push(this.canvas.captureStream().getVideoTracks()[0])
}
return new MediaStream(tracks)
}
Expand Down Expand Up @@ -512,25 +523,14 @@ class Player {
await this.closeAudioDecoder()
await this.closeVideoDecoder()

if (this.audioWriter !== undefined) {
try {
await this.audioWriter.close()
} catch (e) {
// writer がエラー状態になっている場合などには close() に失敗する模様
// 特に対処法も実害もなさそうなので、ログだけ出して無視しておく
console.log(`[WARNING] ${e}`)
}
this.audioWriter = undefined
if (this.audioContext !== undefined) {
await this.audioContext.close()
this.audioContext = undefined
this.audioInputNode = undefined
}
if (this.videoWriter !== undefined) {
try {
await this.videoWriter.close()
} catch (e) {
// writer がエラー状態になっている場合などには close() に失敗する模様
// 特に対処法も実害もなさそうなので、ログだけ出して無視しておく
console.log(`[WARNING] ${e}`)
}
this.videoWriter = undefined
if (this.canvas !== undefined) {
this.canvas = undefined
this.canvasCtx = undefined
}
}
}
Expand Down

0 comments on commit ffe95e0

Please sign in to comment.