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
+
+
+
+
+
+
+
+
+
+
+
Recvonly
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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]));
+
+ //