diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..7225945 --- /dev/null +++ b/.env.template @@ -0,0 +1,4 @@ +# 設定については README.md を参照してください +VITE_SORA_SIGNALING_URL= +VITE_SORA_CHANNEL_ID_PREFIX= +VITE_SECRET_KEY= \ No newline at end of file diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000..be4995b --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,20 @@ +# Dependency Review Action +# +# This Action will scan dependency manifest files that change as part of a Pull Request, surfacing known-vulnerable versions of the packages declared or updated in the PR. Once installed, if the workflow run is marked as required, PRs introducing known-vulnerable packages will be blocked from merging. +# +# Source repository: https://github.com/actions/dependency-review-action +# Public documentation: https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement +name: 'Dependency Review' +on: [pull_request] + +permissions: + contents: read + +jobs: + dependency-review: + runs-on: ubuntu-24.04 + steps: + - name: 'Checkout Repository' + uses: actions/checkout@v4 + - name: 'Dependency Review' + uses: actions/dependency-review-action@v4 diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml new file mode 100644 index 0000000..023e6f2 --- /dev/null +++ b/.github/workflows/e2e-test.yml @@ -0,0 +1,75 @@ +name: e2e-test + +on: + push: + branches: + - main + - feature/* + paths-ignore: + - "**.md" + - "LICENSE" + - "NOTICE" + schedule: + # UTC 時間で毎日 2:00 (JST で 11:00) に実行、月曜日から金曜日 + - cron: "0 2 * * 1-5" + +jobs: + e2e-test: + timeout-minutes: 20 + runs-on: ubuntu-24.04 + strategy: + matrix: + node: ["20", "22", "23"] + # browser: ["chromium", "firefox", "webkit"] + browser: ["chromium"] + env: + VITE_SORA_SIGNALING_URL: ${{ secrets.TEST_SIGNALING_URL }} + VITE_SORA_CHANNEL_ID_PREFIX: ${{ secrets.TEST_CHANNEL_ID_PREFIX }} + VITE_SECRET_KEY: ${{ secrets.TEST_SECRET_KEY }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + - uses: pnpm/action-setup@v4 + - run: pnpm --version + - run: pnpm install + - run: pnpm exec playwright install ${{ matrix.browser }} --with-deps + - run: pnpm exec playwright test --project=${{ matrix.browser }} + env: + VITE_SORA_CHANNEL_ID_SUFFIX: _${{ matrix.node }} + # - uses: actions/upload-artifact@v4 + # if: always() + # with: + # name: playwright-report + # path: playwright-report/ + # retention-days: 30 + + # slack_notify_succeeded: + # needs: [e2e-test] + # runs-on: ubuntu-24.04 + # if: success() + # steps: + # - name: Slack Notification + # if: success() + # uses: rtCamp/action-slack-notify@v2 + # env: + # SLACK_CHANNEL: sora-js-sdk + # SLACK_COLOR: good + # SLACK_TITLE: Succeeded + # SLACK_ICON_EMOJI: ":star-struck:" + # SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} + slack_notify_failed: + needs: [e2e-test] + runs-on: ubuntu-24.04 + if: failure() + steps: + - name: Slack Notification + if: failure() + uses: rtCamp/action-slack-notify@v2 + env: + SLACK_CHANNEL: sora-js-sdk + SLACK_COLOR: danger + SLACK_TITLE: Failed + SLACK_ICON_EMOJI: ":japanese_ogre:" + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} diff --git a/.gitignore b/.gitignore index c6bba59..a708a38 100644 --- a/.gitignore +++ b/.gitignore @@ -1,130 +1,12 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -lerna-debug.log* -.pnpm-debug.log* - -# Diagnostic reports (https://nodejs.org/api/report.html) -report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json - -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage -*.lcov - -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# Bower dependency directory (https://bower.io/) -bower_components - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (https://nodejs.org/api/addons.html) -build/Release - -# Dependency directories -node_modules/ -jspm_packages/ - -# Snowpack dependency directory (https://snowpack.dev/) -web_modules/ - -# TypeScript cache -*.tsbuildinfo - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Optional stylelint cache -.stylelintcache - -# Microbundle cache -.rpt2_cache/ -.rts2_cache_cjs/ -.rts2_cache_es/ -.rts2_cache_umd/ - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' -*.tgz - -# Yarn Integrity file -.yarn-integrity - -# dotenv environment variable files -.env -.env.development.local -.env.test.local -.env.production.local -.env.local - -# parcel-bundler cache (https://parceljs.org/) -.cache -.parcel-cache - -# Next.js build output -.next -out - -# Nuxt.js build / generate output -.nuxt -dist - -# Gatsby files -.cache/ -# Comment in the public line in if your project uses Gatsby and not Next.js -# https://nextjs.org/blog/next-9-1#public-directory-support -# public - -# vuepress build output -.vuepress/dist - -# vuepress v2.x temp and cache directory -.temp -.cache - -# Docusaurus cache and generated files -.docusaurus - -# Serverless directories -.serverless/ - -# FuseBox cache -.fusebox/ - -# DynamoDB Local files -.dynamodb/ - -# TernJS port file -.tern-port - -# Stores VSCode versions used for testing VSCode extensions -.vscode-test - -# yarn v2 -.yarn/cache -.yarn/unplugged -.yarn/build-state.yml -.yarn/install-state.gz -.pnp.* +node_modules +dist/ + +# .env +.env* +!.env.template + +# playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/README.md b/README.md index 589c2e1..4753f2b 100644 --- a/README.md +++ b/README.md @@ -1 +1,48 @@ -# sora-js-sdk-examples \ No newline at end of file +# Sora JavaScript SDK サンプル + +## 使い方 + +```bash +$ git clone git@github.com:shiguredo/sora-js-sdk-examples.git +$ cd sora-js-sdk-examples +# .env.local を作成して適切な値を設定してください +$ cp .env.template .env.local +$ pnpm install +$ pnpm dev +``` + +### Sora Labo を利用する場合の .env.local の設定 + +```bash +# Sora Labo の Signaling URL を指定してください +VITE_SORA_SIGNALING_URL=wss://sora.sora-labo.shiguredo.app/signaling +# Sora Labo にログインした GitHub ログイン名と GitHub ID を指定してください +# {GitHubLoginName}_{GitHubID}_ の用に指定してください +VITE_SORA_CHANNEL_ID_PREFIX={GitHubLoginName}_{GitHubId}_ +# Sora Labo の Secret Key を指定してください +VITE_SECRET_KEY=SecretKey +``` + +### Sora Cloud を利用する場合の .env.local の設定 + +```bash +# Sora Cloud の Signaling URL を指定してください +VITE_SORA_SIGNALING_URL=wss://sora.sora-cloud.shiguredo.app/signaling +# Sora Cloud のプロジェクト ID + @ を指定してください +VITE_SORA_CHANNEL_ID_PREFIX={ProjectId}@ +# Sora Cloud の API Key を指定してください +VITE_SECRET_KEY=SecretKey +``` + +### Sora を利用する場合の .env.local の設定 + +```bash +# Sora の Signaling URL を指定してください +VITE_SORA_SIGNALING_URL=wss://sora.example.com/signaling +# 好きな文字列を指定してください +VITE_SORA_CHANNEL_ID_PREFIX=example +# 設定不要です +VITE_SECRET_KEY= +``` + +## ライセンス diff --git a/biome.jsonc b/biome.jsonc new file mode 100644 index 0000000..701180b --- /dev/null +++ b/biome.jsonc @@ -0,0 +1,34 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "organizeImports": { + "enabled": true + }, + "files": { + "include": ["*.mjs", "*.mts", "*.ts", "*.json", "*.jsonc"] + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "formatter": { + "enabled": true, + "indentStyle": "space" + }, + "json": { + "parser": { + "allowComments": true + }, + "formatter": { + "enabled": true, + "indentStyle": "space" + } + }, + "javascript": { + "formatter": { + "enabled": true, + "indentStyle": "space" + } + } +} diff --git a/check_stereo/index.html b/check_stereo/index.html new file mode 100644 index 0000000..af1d484 --- /dev/null +++ b/check_stereo/index.html @@ -0,0 +1,54 @@ + + + + + Stereo Check シンプルサンプル + + + + + +

Stereo Check サンプル

+
+

Sendonly

+ +
+ + +
+ +
+
+

Waveform

+
+
+ +
+
+
+

Recvonly

+
+ +
+ +
+
+

Waveform

+
+ +
+ +
+ + + + + \ No newline at end of file diff --git a/check_stereo/main.ts b/check_stereo/main.ts new file mode 100644 index 0000000..78786f4 --- /dev/null +++ b/check_stereo/main.ts @@ -0,0 +1,539 @@ +import Sora, { + type SoraConnection, + type SignalingNotifyMessage, + type ConnectionPublisher, + type ConnectionSubscriber, + type ConnectionOptions, +} from "sora-js-sdk"; +import { generateJwt } from "../src/misc"; + +document.addEventListener("DOMContentLoaded", async () => { + // 環境変数の読み込み + const signalingUrl = import.meta.env.VITE_SORA_SIGNALING_URL; + const channelIdPrefix = import.meta.env.VITE_SORA_CHANNEL_ID_PREFIX; + const channelIdSuffix = import.meta.env.VITE_SORA_CHANNEL_ID_SUFFIX; + const secretKey = import.meta.env.VITE_SECRET_KEY; + + // Sora クライアントの初期化 + const sendonly = new SendonlyClient( + signalingUrl, + channelIdPrefix, + channelIdSuffix, + secretKey, + ); + + const recvonly = new RecvonlyClient( + signalingUrl, + channelIdPrefix, + channelIdSuffix, + secretKey, + ); + + // デバイスリストの取得と設定 + await updateDeviceLists(); + + // デバイスの変更を監視 + navigator.mediaDevices.addEventListener("devicechange", updateDeviceLists); + + document + .querySelector("#sendonly-connect") + ?.addEventListener("click", async () => { + const audioInputSelect = document.querySelector( + "#sendonly-audio-input", + ); + const selectedAudioDeviceId = audioInputSelect?.value; + const stream = await navigator.mediaDevices.getUserMedia({ + video: false, + audio: { + deviceId: selectedAudioDeviceId + ? { exact: selectedAudioDeviceId } + : undefined, + echoCancellation: false, + noiseSuppression: false, + autoGainControl: false, + channelCount: 2, + sampleRate: 48000, + sampleSize: 16, + }, + }); + await sendonly.connect(stream); + }); + + document + .querySelector("#recvonly-connect") + ?.addEventListener("click", async () => { + await recvonly.connect(); + }); +}); + +// デバイスリストを更新する関数 +async function updateDeviceLists() { + const devices = await navigator.mediaDevices.enumerateDevices(); + + const audioInputSelect = document.querySelector( + "#sendonly-audio-input", + ); + + if (audioInputSelect) { + audioInputSelect.innerHTML = ""; + const audioInputDevices = devices.filter( + (device) => device.kind === "audioinput", + ); + for (const device of audioInputDevices) { + const option = document.createElement("option"); + option.value = device.deviceId; + option.text = device.label || `マイク ${audioInputSelect.length + 1}`; + audioInputSelect.appendChild(option); + } + } +} + +class SendonlyClient { + private debug = false; + + private channelId: string; + private options: ConnectionOptions; + + private secretKey: string; + + private sora: SoraConnection; + private connection: ConnectionPublisher; + + private canvas: HTMLCanvasElement | null = null; + private canvasCtx: CanvasRenderingContext2D | null = null; + + private channelCheckInterval: number | undefined; + + constructor( + signalingUrl: string, + channelIdPrefix: string, + channelIdSuffix: string, + secretKey: string, + options: ConnectionOptions = {}, + ) { + this.channelId = `${channelIdPrefix}:check_stereo:${channelIdSuffix}`; + this.secretKey = secretKey; + this.options = options; + + this.sora = Sora.connection(signalingUrl, this.debug); + this.connection = this.sora.sendonly(this.channelId, null, this.options); + this.connection.on("notify", this.onnotify.bind(this)); + + this.initializeCanvas(); + } + + async connect(stream: MediaStream): Promise { + if (this.secretKey !== "") { + const jwt = await generateJwt(this.channelId, this.secretKey); + this.connection.metadata = { + access_token: jwt, + }; + } + + const audioTrack = stream.getAudioTracks()[0]; + if (!audioTrack) { + throw new Error("Audio track not found"); + } + + await this.connection.connect(stream); + this.analyzeAudioStream(new MediaStream([audioTrack])); + + // チャネル数の定期チェックを開始 + this.startChannelCheck(); + } + + async getChannels(): Promise { + if (!this.connection.pc) { + return undefined; + } + const sender = this.connection.pc + .getSenders() + .find((sender) => sender.track?.kind === "audio"); + if (!sender) { + return undefined; + } + return sender.getParameters().codecs[0].channels; + } + + private initializeCanvas() { + this.canvas = + document.querySelector("#sendonly-waveform"); + if (this.canvas) { + this.canvasCtx = this.canvas.getContext("2d"); + } + } + + analyzeAudioStream(stream: MediaStream) { + const audioContext = new AudioContext({ + sampleRate: 48000, + latencyHint: "interactive", + }); + const source = audioContext.createMediaStreamSource(stream); + const splitter = audioContext.createChannelSplitter(2); + const analyserL = audioContext.createAnalyser(); + const analyserR = audioContext.createAnalyser(); + + source.connect(splitter); + splitter.connect(analyserL, 0); + splitter.connect(analyserR, 1); + + analyserL.fftSize = 2048; + analyserR.fftSize = 2048; + + const bufferLength = analyserL.frequencyBinCount; + const dataArrayL = new Float32Array(bufferLength); + const dataArrayR = new Float32Array(bufferLength); + + const analyze = () => { + analyserL.getFloatTimeDomainData(dataArrayL); + analyserR.getFloatTimeDomainData(dataArrayR); + + this.drawWaveforms(dataArrayL, dataArrayR); + + let difference = 0; + for (let i = 0; i < dataArrayL.length; i++) { + difference += Math.abs(dataArrayL[i] - dataArrayR[i]); + } + + const isStereo = difference !== 0; + const result = isStereo ? "Stereo" : "Mono"; + + // differenceの値を表示する要素を追加 + const differenceElement = document.querySelector( + "#sendonly-difference-value", + ); + if (differenceElement) { + differenceElement.textContent = `Difference: ${difference.toFixed(6)}`; + } + + // sendonly-stereo 要素に結果を反映 + const sendonlyStereoElement = + document.querySelector("#sendonly-stereo"); + if (sendonlyStereoElement) { + sendonlyStereoElement.textContent = result; + } + + requestAnimationFrame(analyze); + }; + + analyze(); + + if (audioContext.state === "suspended") { + audioContext.resume(); + } + } + + private drawWaveforms(dataArrayL: Float32Array, dataArrayR: Float32Array) { + if (!this.canvasCtx || !this.canvas) return; + + const width = this.canvas.width; + const height = this.canvas.height; + const bufferLength = dataArrayL.length; + + this.canvasCtx.fillStyle = "rgb(240, 240, 240)"; + this.canvasCtx.fillRect(0, 0, width, height); + const drawChannel = ( + dataArray: Float32Array, + color: string, + offset: number, + ) => { + if (!this.canvasCtx) return; + + this.canvasCtx.lineWidth = 3; + this.canvasCtx.strokeStyle = color; + this.canvasCtx.beginPath(); + + const sliceWidth = (width * 1.0) / bufferLength; + let x = 0; + + for (let i = 0; i < bufferLength; i++) { + const v = dataArray[i]; + const y = height / 2 + v * height * 0.8 + offset; + + if (i === 0) { + this.canvasCtx?.moveTo(x, y); + } else { + this.canvasCtx?.lineTo(x, y); + } + + x += sliceWidth; + } + + this.canvasCtx?.lineTo(width, height / 2 + offset); + this.canvasCtx?.stroke(); + }; + + // 左チャンネル(青)を少し上にずらして描画 + this.canvasCtx.globalAlpha = 0.7; + drawChannel(dataArrayL, "rgb(0, 0, 255)", -10); + + // 右チャンネル(赤)を少し下にずらして描画 + this.canvasCtx.globalAlpha = 0.7; + drawChannel(dataArrayR, "rgb(255, 0, 0)", 10); + + // モノラルかステレオかを判定して表示 + const isMonaural = this.isMonaural(dataArrayL, dataArrayR); + this.canvasCtx.fillStyle = "black"; + this.canvasCtx.font = "20px Arial"; + this.canvasCtx.fillText(isMonaural ? "Monaural" : "Stereo", 10, 30); + } + + private isMonaural( + dataArrayL: Float32Array, + dataArrayR: Float32Array, + ): boolean { + const threshold = 0.001; + for (let i = 0; i < dataArrayL.length; i++) { + if (Math.abs(dataArrayL[i] - dataArrayR[i]) > threshold) { + return false; + } + } + return true; + } + + private onnotify(event: SignalingNotifyMessage) { + // 自分の connection_id を取得する + if ( + event.event_type === "connection.created" && + this.connection.connectionId === event.connection_id + ) { + const connectionIdElement = document.querySelector( + "#sendonly-connection-id", + ); + if (connectionIdElement) { + connectionIdElement.textContent = event.connection_id; + } + } + } + + private startChannelCheck() { + this.channelCheckInterval = window.setInterval(async () => { + const channels = await this.getChannels(); + const channelElement = + document.querySelector("#sendonly-channels"); + if (channelElement) { + channelElement.textContent = + channels !== undefined + ? `getParameters codecs channels: ${channels}` + : "undefined"; + } + }, 1000); // 1秒ごとにチェック + } +} + +class RecvonlyClient { + private debug = false; + + private channelId: string; + private options: object = { + video: false, + audio: true, + }; + + private secretKey: string; + + private sora: SoraConnection; + private connection: ConnectionSubscriber; + + private canvas: HTMLCanvasElement | null = null; + private canvasCtx: CanvasRenderingContext2D | null = null; + + constructor( + signalingUrl: string, + channelIdPrefix: string, + channelIdSuffix: string, + secretKey: string, + ) { + this.channelId = `${channelIdPrefix}:check_stereo:${channelIdSuffix}`; + this.secretKey = secretKey; + + this.sora = Sora.connection(signalingUrl, this.debug); + this.connection = this.sora.recvonly( + this.channelId, + undefined, + this.options, + ); + this.connection.on("notify", this.onnotify.bind(this)); + this.connection.on("track", this.ontrack.bind(this)); + + this.initializeCanvas(); + } + + async connect(): Promise { + const jwt = await generateJwt(this.channelId, this.secretKey); + this.connection.metadata = { access_token: jwt }; + + const forceStereoOutputElement = + document.querySelector("#forceStereoOutput"); + const forceStereoOutput = forceStereoOutputElement + ? forceStereoOutputElement.checked + : false; + this.connection.options.forceStereoOutput = forceStereoOutput; + + await this.connection.connect(); + } + + private initializeCanvas() { + this.canvas = + document.querySelector("#recvonly-waveform"); + if (this.canvas) { + this.canvasCtx = this.canvas.getContext("2d"); + } + } + + analyzeAudioStream(stream: MediaStream) { + const audioContext = new AudioContext({ + sampleRate: 48000, + latencyHint: "interactive", + }); + const source = audioContext.createMediaStreamSource(stream); + const splitter = audioContext.createChannelSplitter(2); + const analyserL = audioContext.createAnalyser(); + const analyserR = audioContext.createAnalyser(); + + source.connect(splitter); + splitter.connect(analyserL, 0); + splitter.connect(analyserR, 1); + + analyserL.fftSize = 2048; + analyserR.fftSize = 2048; + + const bufferLength = analyserL.frequencyBinCount; + const dataArrayL = new Float32Array(bufferLength); + const dataArrayR = new Float32Array(bufferLength); + + const analyze = () => { + analyserL.getFloatTimeDomainData(dataArrayL); + analyserR.getFloatTimeDomainData(dataArrayR); + + this.drawWaveforms(dataArrayL, dataArrayR); + + let difference = 0; + for (let i = 0; i < dataArrayL.length; i++) { + difference += Math.abs(dataArrayL[i] - dataArrayR[i]); + } + + const isStereo = difference !== 0; + const result = isStereo ? "Stereo" : "Mono"; + + // differenceの値を表示する要素を追加 + const differenceElement = document.querySelector( + "#recvonly-difference-value", + ); + if (differenceElement) { + differenceElement.textContent = `Difference: ${difference.toFixed(6)}`; + } + + // 既存のコード + const recvonlyStereoElement = + document.querySelector("#recvonly-stereo"); + if (recvonlyStereoElement) { + recvonlyStereoElement.textContent = result; + } + + requestAnimationFrame(analyze); + }; + + analyze(); + + if (audioContext.state === "suspended") { + audioContext.resume(); + } + } + + private drawWaveforms(dataArrayL: Float32Array, dataArrayR: Float32Array) { + if (!this.canvasCtx || !this.canvas) return; + + const width = this.canvas.width; + const height = this.canvas.height; + const bufferLength = dataArrayL.length; + + this.canvasCtx.fillStyle = "rgb(240, 240, 240)"; + this.canvasCtx.fillRect(0, 0, width, height); + const drawChannel = ( + dataArray: Float32Array, + color: string, + offset: number, + ) => { + if (!this.canvasCtx) return; + + this.canvasCtx.lineWidth = 3; + this.canvasCtx.strokeStyle = color; + this.canvasCtx.beginPath(); + + const sliceWidth = (width * 1.0) / bufferLength; + let x = 0; + + for (let i = 0; i < bufferLength; i++) { + const v = dataArray[i]; + const y = height / 2 + v * height * 0.8 + offset; + + if (i === 0) { + this.canvasCtx?.moveTo(x, y); + } else { + this.canvasCtx?.lineTo(x, y); + } + + x += sliceWidth; + } + + this.canvasCtx?.lineTo(width, height / 2 + offset); + this.canvasCtx?.stroke(); + }; + + this.canvasCtx.globalAlpha = 0.7; + drawChannel(dataArrayL, "rgb(0, 0, 255)", -10); + drawChannel(dataArrayR, "rgb(255, 0, 0)", 10); + + const isMonaural = this.isMonaural(dataArrayL, dataArrayR); + this.canvasCtx.fillStyle = "black"; + this.canvasCtx.font = "20px Arial"; + this.canvasCtx.fillText(isMonaural ? "Monaural" : "Stereo", 10, 30); + } + + private isMonaural( + dataArrayL: Float32Array, + dataArrayR: Float32Array, + ): boolean { + const threshold = 0.001; + for (let i = 0; i < dataArrayL.length; i++) { + if (Math.abs(dataArrayL[i] - dataArrayR[i]) > threshold) { + return false; + } + } + return true; + } + + private onnotify(event: SignalingNotifyMessage) { + // 自分の connection_id を取得する + if ( + event.event_type === "connection.created" && + this.connection.connectionId === event.connection_id + ) { + const connectionIdElement = document.querySelector( + "#recvonly-connection-id", + ); + if (connectionIdElement) { + connectionIdElement.textContent = event.connection_id; + } + } + } + + private ontrack(event: RTCTrackEvent) { + // Sora の場合、event.streams には MediaStream が 1 つだけ含まれる + const stream = event.streams[0]; + if (event.track.kind === "audio") { + this.analyzeAudioStream(new MediaStream([event.track])); + + //