diff --git a/examples/index.html b/examples/index.html index c3955bca..47569c57 100644 --- a/examples/index.html +++ b/examples/index.html @@ -17,6 +17,7 @@
  • サイマルキャスト配信/視聴サンプル
  • メッセージングサンプル
  • 送信音声ビットレートサンプル
  • +
  • 送信音声コーディックサンプル
  • diff --git a/examples/sendonly_audio_codec/index.html b/examples/sendonly_audio_codec/index.html new file mode 100644 index 00000000..1c2983fa --- /dev/null +++ b/examples/sendonly_audio_codec/index.html @@ -0,0 +1,31 @@ + + + + + + Sendonly Audio Codec test + + + +
    +

    Sendonly Audio Codec test

    + + + +
    +
    + + +
    + +
    +
    +
    + + + + \ No newline at end of file diff --git a/examples/sendonly_audio_codec/main.mts b/examples/sendonly_audio_codec/main.mts new file mode 100644 index 00000000..0cfcbd38 --- /dev/null +++ b/examples/sendonly_audio_codec/main.mts @@ -0,0 +1,126 @@ +import Sora, { + type SignalingNotifyMessage, + type ConnectionPublisher, + type SoraConnection, +} from 'sora-js-sdk' + +document.addEventListener('DOMContentLoaded', async () => { + const SORA_SIGNALING_URL = import.meta.env.VITE_SORA_SIGNALING_URL + const SORA_CHANNEL_ID_PREFIX = import.meta.env.VITE_SORA_CHANNEL_ID_PREFIX || '' + const SORA_CHANNEL_ID_SUFFIX = import.meta.env.VITE_SORA_CHANNEL_ID_SUFFIX || '' + const ACCESS_TOKEN = import.meta.env.VITE_ACCESS_TOKEN || '' + + const client = new SoraClient( + SORA_SIGNALING_URL, + SORA_CHANNEL_ID_PREFIX, + SORA_CHANNEL_ID_SUFFIX, + ACCESS_TOKEN, + ) + + document.querySelector('#start')?.addEventListener('click', async () => { + const stream = await navigator.mediaDevices.getUserMedia({ video: false, audio: true }) + + const audioCodecType = document.getElementById('audio-codec-type') as HTMLSelectElement + const selectedCodecType = audioCodecType.value === 'OPUS' ? audioCodecType.value : undefined + + await client.connect(stream, selectedCodecType) + }) + + document.querySelector('#stop')?.addEventListener('click', async () => { + await client.disconnect() + }) + + document.querySelector('#get-stats')?.addEventListener('click', async () => { + const statsReport = await client.getStats() + const statsDiv = document.querySelector('#stats-report') as HTMLElement + const statsReportJsonDiv = document.querySelector('#stats-report-json') + if (statsDiv && statsReportJsonDiv) { + let statsHtml = '' + const statsReportJson: Record[] = [] + // biome-ignore lint/complexity/noForEach: + statsReport.forEach((report) => { + statsHtml += `

    Type: ${report.type}

    ' + statsReportJson.push(reportJson) + }) + statsDiv.innerHTML = statsHtml + // データ属性としても保存(オプション) + statsDiv.dataset.statsReportJson = JSON.stringify(statsReportJson) + } + }) +}) + +class SoraClient { + private debug = false + private channelId: string + private metadata: { access_token: string } + private options: object = {} + + private sora: SoraConnection + private connection: ConnectionPublisher + + constructor( + signaling_url: string, + channel_id_prefix: string, + channel_id_suffix: string, + access_token: string, + ) { + this.sora = Sora.connection(signaling_url, this.debug) + + // channel_id の生成 + this.channelId = `${channel_id_prefix}sendonly_audio_codec${channel_id_suffix}` + // access_token を指定する metadata の生成 + this.metadata = { access_token: access_token } + + this.connection = this.sora.sendonly(this.channelId, this.metadata, this.options) + this.connection.on('notify', this.onnotify.bind(this)) + } + + async connect(stream: MediaStream, audioCodecType?: string): Promise { + if (audioCodecType && audioCodecType === 'OPUS') { + // 音声コーディックを上書きする + this.connection.options.audioCodecType = audioCodecType + } + await this.connection.connect(stream) + + const audioElement = document.querySelector('#local-audio') + if (audioElement !== null) { + audioElement.srcObject = stream + } + } + + async disconnect(): Promise { + await this.connection.disconnect() + + const audioElement = document.querySelector('#local-audio') + if (audioElement !== null) { + audioElement.srcObject = null + } + } + + getStats(): Promise { + if (this.connection.pc === null) { + return Promise.reject(new Error('PeerConnection is not ready')) + } + return this.connection.pc.getStats() + } + + private onnotify(event: SignalingNotifyMessage): void { + if ( + event.event_type === 'connection.created' && + this.connection.connectionId === event.connection_id + ) { + const connectionIdElement = document.querySelector('#connection-id') + if (connectionIdElement) { + connectionIdElement.textContent = event.connection_id + } + } + } +} diff --git a/tests/sendonly_audio_codec.spec.ts b/tests/sendonly_audio_codec.spec.ts new file mode 100644 index 00000000..30535a77 --- /dev/null +++ b/tests/sendonly_audio_codec.spec.ts @@ -0,0 +1,57 @@ +import { expect, test } from '@playwright/test' + +test('sendonly audio codec type pages', async ({ browser }) => { + const sendonly = await browser.newPage() + await sendonly.goto('http://localhost:9000/sendonly_audio_codec/') + + // select 要素から直接オプションを取得してランダムに選択 + const randomAudioCodec = await sendonly.evaluate(() => { + const select = document.querySelector('#audio-codec-type') as HTMLSelectElement + const options = Array.from(select.options) + const randomOption = options[Math.floor(Math.random() * options.length)] + select.value = randomOption.value + return randomOption.value + }) + + // 選択したコーディックタイプをログに出力 + console.log('Selected codec:', randomAudioCodec) + + // Start ボタンクリック + await sendonly.click('#start') + await sendonly.waitForSelector('#connection-id:not(:empty)') + + // #connection-id 要素の内容を取得 + const sendonlyConnectionId = await sendonly.$eval('#connection-id', (el) => el.textContent) + console.log(`Selected codec: connectionId=${sendonlyConnectionId}`) + + // レース対策 + await sendonly.waitForTimeout(3000) + + // 'Get Stats' ボタンをクリックして統計情報を取得 + await sendonly.click('#get-stats') + // 統計情報が表示されるまで待機 + await sendonly.waitForSelector('#stats-report') + + // データセットから統計情報を取得 + const sendonlyStatsReportJson: Record[] = await sendonly.evaluate(() => { + const statsReportDiv = document.querySelector('#stats-report') as HTMLDivElement + return statsReportDiv ? JSON.parse(statsReportDiv.dataset.statsReportJson || '[]') : [] + }) + + const sendonlyAudioCodecStats = sendonlyStatsReportJson.find( + (report) => report.type === 'codec' && report.mimeType === 'audio/opus', + ) + + // 今は指定してもしなくても OPUS のみ + expect(sendonlyAudioCodecStats).toBeDefined() + const sendonlyAudioOutboundRtp = sendonlyStatsReportJson.find( + (report) => report.type === 'outbound-rtp' && report.kind === 'audio', + ) + // 音声が正常に送れているかの確認 + expect(sendonlyAudioOutboundRtp).toBeDefined() + expect(sendonlyAudioOutboundRtp?.bytesSent).toBeGreaterThan(0) + expect(sendonlyAudioOutboundRtp?.packetsSent).toBeGreaterThan(0) + + await sendonly.click('#stop') + await sendonly.close() +}) diff --git a/vite.config.mts b/vite.config.mts index ac84d1f2..baf68aac 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -17,6 +17,7 @@ export default defineConfig({ spotlight_sendonly: resolve(__dirname, 'examples/spotlight_sendonly/index.html'), spotlight_recvonly: resolve(__dirname, 'examples/spotlight_recvonly/index.html'), sendonly_audio_bit_rate: resolve(__dirname, 'examples/sendonly_audio_bit_rate/index.html'), + sendonly_audio_codec: resolve(__dirname, 'examples/sendonly_audio_codec/index.html'), messaging: resolve(__dirname, 'examples/messaging/index.html'), }, },