From 98033b961a9520568361fd8fbecbd0c7d2fc3fa8 Mon Sep 17 00:00:00 2001
From: okathira <43328895+okathira@users.noreply.github.com>
Date: Mon, 26 May 2025 01:28:35 +0900
Subject: [PATCH] =?UTF-8?q?feat:=20=E8=A4=87=E6=95=B0=E3=81=AE=E9=9F=B3?=
=?UTF-8?q?=E5=A3=B0=E5=87=BA=E5=8A=9B=E3=83=87=E3=83=90=E3=82=A4=E3=82=B9?=
=?UTF-8?q?=E3=81=8B=E3=82=89=E5=90=8C=E6=99=82=E3=81=AB=E9=9F=B3=E3=82=92?=
=?UTF-8?q?=E5=87=BA=E3=81=99=E3=83=97=E3=83=AD=E3=82=B8=E3=82=A7=E3=82=AF?=
=?UTF-8?q?=E3=83=88=E3=82=92=E6=96=B0=E8=A6=8F=E4=BD=9C=E6=88=90?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
Scratchpad.md | 150 +++++++++++-
eslint.config.js | 8 +-
src/audio-multi-output/App.tsx | 154 ++++++++++++
src/audio-multi-output/README.md | 125 ++++++++++
src/audio-multi-output/atoms/globalAtoms.ts | 8 +
src/audio-multi-output/consts/audioConsts.ts | 15 ++
.../features/DeviceManager/DeviceManager.tsx | 92 ++++++++
.../features/DeviceManager/DeviceSelector.tsx | 58 +++++
.../features/DeviceManager/atom.ts | 15 ++
.../features/DeviceManager/consts.ts | 12 +
.../features/DeviceManager/functions.ts | 77 ++++++
.../features/DeviceManager/index.ts | 13 ++
.../OscillatorControl/FrequencySlider.tsx | 32 +++
.../OscillatorControl/OscillatorControl.tsx | 97 ++++++++
.../OscillatorControl/PhaseSwitch.tsx | 31 +++
.../OscillatorControl/WaveformSelector.tsx | 39 ++++
.../features/OscillatorControl/atom.ts | 12 +
.../features/OscillatorControl/consts.ts | 28 +++
.../features/OscillatorControl/index.ts | 9 +
.../features/OutputController/DevicePanel.tsx | 68 ++++++
.../OutputController/OutputController.tsx | 221 ++++++++++++++++++
.../features/OutputController/PlayButton.tsx | 46 ++++
.../features/OutputController/atom.ts | 20 ++
.../features/OutputController/functions.ts | 123 ++++++++++
.../features/OutputController/index.ts | 7 +
src/audio-multi-output/index.html | 13 ++
src/audio-multi-output/main.tsx | 10 +
src/audio-multi-output/utils/audioUtils.ts | 41 ++++
src/index.html | 4 +
vite.config.ts | 1 +
30 files changed, 1513 insertions(+), 16 deletions(-)
create mode 100644 src/audio-multi-output/App.tsx
create mode 100644 src/audio-multi-output/README.md
create mode 100644 src/audio-multi-output/atoms/globalAtoms.ts
create mode 100644 src/audio-multi-output/consts/audioConsts.ts
create mode 100644 src/audio-multi-output/features/DeviceManager/DeviceManager.tsx
create mode 100644 src/audio-multi-output/features/DeviceManager/DeviceSelector.tsx
create mode 100644 src/audio-multi-output/features/DeviceManager/atom.ts
create mode 100644 src/audio-multi-output/features/DeviceManager/consts.ts
create mode 100644 src/audio-multi-output/features/DeviceManager/functions.ts
create mode 100644 src/audio-multi-output/features/DeviceManager/index.ts
create mode 100644 src/audio-multi-output/features/OscillatorControl/FrequencySlider.tsx
create mode 100644 src/audio-multi-output/features/OscillatorControl/OscillatorControl.tsx
create mode 100644 src/audio-multi-output/features/OscillatorControl/PhaseSwitch.tsx
create mode 100644 src/audio-multi-output/features/OscillatorControl/WaveformSelector.tsx
create mode 100644 src/audio-multi-output/features/OscillatorControl/atom.ts
create mode 100644 src/audio-multi-output/features/OscillatorControl/consts.ts
create mode 100644 src/audio-multi-output/features/OscillatorControl/index.ts
create mode 100644 src/audio-multi-output/features/OutputController/DevicePanel.tsx
create mode 100644 src/audio-multi-output/features/OutputController/OutputController.tsx
create mode 100644 src/audio-multi-output/features/OutputController/PlayButton.tsx
create mode 100644 src/audio-multi-output/features/OutputController/atom.ts
create mode 100644 src/audio-multi-output/features/OutputController/functions.ts
create mode 100644 src/audio-multi-output/features/OutputController/index.ts
create mode 100644 src/audio-multi-output/index.html
create mode 100644 src/audio-multi-output/main.tsx
create mode 100644 src/audio-multi-output/utils/audioUtils.ts
diff --git a/Scratchpad.md b/Scratchpad.md
index 1089ce0c..6226cede 100644
--- a/Scratchpad.md
+++ b/Scratchpad.md
@@ -5,20 +5,49 @@
## 現在のタスク
-リポジトリの全体像とルールの整合性確認
+audio-multi-outputプロジェクトのリンクまとめページやVite設定、SNSリンクの設置
+audio-multi-outputプロジェクトのGrid2への更新
+audio-multi-outputプロジェクトのPlayButtonをSwitchに変更
+audio-multi-outputプロジェクトの全体再生ボタンの動作変更
+audio-multi-outputプロジェクトの重複再生問題の修正
+audio-multi-outputプロジェクトのトグル状態変化による制御の実装
+audio-multi-outputプロジェクトの位相反転スイッチの注意書き削除
## 進捗状況
-[X] リポジトリ構造の確認
-[X] 各ルールファイルの内容確認
-[X] ルール間の整合性分析
-[X] Scratchpad情報の更新
-[X] global.mdcとScratchpad.mdの関係明確化
-[X] Lessons.mdファイルの作成と関連ルールの整備
-[X] global.mdcファイルの日本語化
-[X] global.mdcのコマンド例の文字列を原文に修正
-[X] README.mdの更新(開発サポートファイルとルールファイルの情報追加)
-[X] repository.mdcの更新(ルールとサポートファイルの情報追加)
+[X] プロジェクトディレクトリの作成
+[X] 基本ファイル構造の作成
+[X] DeviceManagerの実装
+[X] OscillatorControlの実装
+[X] OutputControllerの実装
+[X] メインAppコンポーネントの実装
+[X] スタイリングの追加
+[ ] 動作確認とテスト
+[X] Vite設定への追加
+[X] メインindex.htmlへのリンク追加
+[X] SNSリンクの設置(Reactコンポーネント使用)
+[X] Grid2への更新
+[X] PlayButtonのSwitch化
+[X] 全体再生ボタンの動作変更
+[X] 重複再生問題の修正
+[X] トグル状態変化による制御の実装
+[X] 位相反転スイッチの注意書き削除
+
+## タスク詳細
+
+調整済みの設計に基づいて、audio-multi-outputプロジェクトを実装します。
+WebAudio APIとAudio Output Devices APIを使用して、複数の音声出力デバイスから独立した波形を同時再生できるWebアプリケーションを構築します。
+
+### 主な調整ポイント
+- ディレクトリ構造をコロケーション原則に従って調整
+- 状態管理(Jotai)の使用方法をルールに合わせて調整
+- 型定義の配置を適切な場所に調整
+- インポート/エクスポート方法をnamed importに統一
+
+## メモと反省
+
+- 新しいプロジェクト設計の調整作業を開始
+- 既存のコーディングルールとの整合性を確保することが重要
## リポジトリ構造分析結果
@@ -48,4 +77,101 @@
- ルールの分割と再構成により、責任範囲がより明確になった
- 日本語化によりグローバルルールの一貫性が向上した
- コマンド例やプロンプトなどの技術的な文字列は原文のままにすることで正確性を確保
-- ドキュメントとルールファイルの相互参照により、プロジェクト全体の把握がしやすくなった
\ No newline at end of file
+- ドキュメントとルールファイルの相互参照により、プロジェクト全体の把握がしやすくなった
+
+## 実装完了
+
+audio-multi-outputプロジェクトの実装が完了しました。
+
+### 実装された機能
+- DeviceManager: 音声出力デバイスの検出、選択、権限管理
+- OscillatorControl: 各デバイスごとの波形、周波数、位相反転設定
+- OutputController: 音声出力の制御、再生/停止機能
+- App: メインアプリケーション、ブラウザサポートチェック
+- 完全なREADMEドキュメント
+
+### 技術的特徴
+- コロケーション原則に基づく設計
+- Jotaiによる状態管理(カスタムフック提供方式)
+- TypeScriptによる型安全性
+- Web Audio API、Audio Output Devices API、Media Devices APIの活用
+- レスポンシブなUI設計
+
+## 追加タスク詳細
+
+他のプロジェクトと同様に以下の設定を行います:
+- Vite設定ファイルにaudio-multi-outputプロジェクトを追加
+- メインのindex.htmlにプロジェクトリンクを追加
+- プロジェクト内にSNSリンクを設置
+
+## 設定完了
+
+audio-multi-outputプロジェクトの統合設定が完了しました。
+
+### 完了した設定
+- Vite設定ファイル(vite.config.ts)にaudio-multi-outputプロジェクトを追加
+- メインのindex.htmlにプロジェクトリンクを追加(「Audio Multi Output - 複数デバイス音声出力」)
+- ReactコンポーネントのSocialIconsを使用してSNSリンクを設置
+- MUIベースの統一されたデザインでSNSリンクを表示
+
+### 設定内容
+- プロジェクトは他のプロジェクトと同様にマルチページアプリケーションとして設定
+- 右上にSNSリンクが固定表示される(MUIのIconButtonとSvgIconを使用)
+- 目次アイコンからメインページに戻ることが可能
+- ブラウザサポートエラー画面でもSNSリンクが表示される
+
+## API実装の更新
+
+### 変更内容
+- `selectAudioOutput` APIの代わりに`mediaDevices.enumerateDevices`と`AudioContext.setSinkId`を組み合わせた実装に変更
+- `HTMLAudioElement.setSinkId`の代わりに`AudioContext.setSinkId`を使用
+- `MediaStreamDestination`と`HTMLAudioElement`を使用した複雑な実装を、直接`AudioContext.destination`に接続するシンプルな実装に変更
+
+### 技術的改善
+- MDNドキュメントに基づく標準的なWeb Audio API実装
+- Chrome 110以降でサポートされる`AudioContext.setSinkId`を活用
+- 権限要求は`getUserMedia`を使用してデバイス列挙権限を取得
+- ブラウザサポートチェックを`AudioContext.setSinkId`の実際の存在確認に更新
+
+## ユーザビリティ改善
+
+### 位相反転機能の制限事項説明追加
+- メインページのフッターに「重要な制限事項」セクションを追加
+- 位相反転機能がノイズキャンセリングには期待通りに機能しない可能性について説明
+- PhaseSwitchコンポーネントに具体的な注意書きを追加
+
+### アクセシビリティ改善
+- 見出しレベルの階層を修正(h1 → h2 → h3の適切な順序)
+- App.tsxのフッター見出しをh4からh2に変更
+- DevicePanel.tsxの見出しをh4からh3に変更
+
+## MUI適用
+
+### 全コンポーネントのMUI化完了
+- **DeviceManager**: Box、Typography、Alert、Button、Checkbox、FormControlLabel
+- **DeviceSelector**: Box、Button、Checkbox、FormControlLabel、Typography
+- **OscillatorControl**: Box、Typography、Card、CardContent
+- **WaveformSelector**: FormControl、InputLabel、Select、MenuItem、Box
+- **FrequencySlider**: Box、Typography、Slider
+- **PhaseSwitch**: Box、FormControlLabel、Switch、Typography
+- **OutputController**: Box、Typography、Divider
+- **PlayButton**: Button、PlayArrow、Stopアイコン
+- **DevicePanel**: Card、CardContent、Typography、Box、Chip、Stack
+- **App**: Container、Grid2、Typography、Alert、Box、Paper、List、ListItem、ListItemText
+
+### デザイン改善
+- 統一されたMUIテーマによる一貫したデザイン
+- レスポンシブレイアウト(Grid2、Container)
+- 適切な余白とスペーシング(sx prop)
+- カード形式による情報の整理
+- アイコン付きボタンによる直感的なUI
+- Chipによる設定値の視覚的表示
+
+### 最新の更新
+- 非推奨の`Grid`コンポーネントから`Grid2`に変更
+- `Grid2`の新しいAPIに対応(`size`プロパティの使用)
+- `PlayButton`コンポーネントを`Switch`に変更してトグルスイッチによる直感的なUI操作を実現
+- 全体再生ボタンを実際の音声再生ではなく、各デバイスのトグルスイッチ状態を一括操作する機能に変更
+- 重複再生問題を修正:既に再生中のデバイスでは新しい音声を開始しない、全体スイッチの状態を実際の再生状態に基づいて更新
+- トグル状態変化による制御:スイッチのON/OFF状態変化で直接再生・停止を制御、より直感的なインターフェースを実現
+- 位相反転スイッチの注意書きを削除してUIをすっきりと整理
diff --git a/eslint.config.js b/eslint.config.js
index 950beb4b..1f701fb9 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -4,7 +4,7 @@ import typescriptEslintParser from "@typescript-eslint/parser";
import eslintConfigPrettier from "eslint-config-prettier";
// @ts-expect-error 型定義がない
import html from "eslint-plugin-html";
-import importX from "eslint-plugin-import-x";
+import { flatConfigs } from "eslint-plugin-import-x";
// reactPlugin.configs.flatの型定義が曖昧 ref: https://github.com/jsx-eslint/eslint-plugin-react/issues/3878
import reactPlugin from "eslint-plugin-react";
// @ts-expect-error 型定義がない ref: https://github.com/facebook/react/issues/30119
@@ -39,10 +39,10 @@ export default config(
tsEslintConfigs.recommendedTypeChecked,
// @ts-expect-error 型定義が曖昧
reactPlugin.configs.flat.recommended,
- // @ts-expect-error型定義が曖昧
+ // @ts-expect-error 型定義が曖昧
reactPlugin.configs.flat["jsx-runtime"],
- importX.flatConfigs.recommended,
- importX.flatConfigs.typescript,
+ flatConfigs.recommended,
+ flatConfigs.typescript,
{
// eslint-plugin-react-hooks
plugins: {
diff --git a/src/audio-multi-output/App.tsx b/src/audio-multi-output/App.tsx
new file mode 100644
index 00000000..e9104e58
--- /dev/null
+++ b/src/audio-multi-output/App.tsx
@@ -0,0 +1,154 @@
+import {
+ Container,
+ Grid2,
+ Typography,
+ Alert,
+ Box,
+ Paper,
+ List,
+ ListItem,
+ ListItemText,
+} from "@mui/material";
+import { useEffect, useState } from "react";
+
+import { DeviceManager } from "./features/DeviceManager";
+import { OscillatorControl } from "./features/OscillatorControl";
+import { OutputController } from "./features/OutputController";
+import { checkBrowserSupport } from "./utils/audioUtils";
+import { SocialIcons } from "../shared/components/SocialIcons";
+
+import type React from "react";
+
+export const App: React.FC = () => {
+ const [browserSupport, setBrowserSupport] = useState({
+ webAudio: false,
+ audioOutput: false,
+ mediaDevices: false,
+ });
+
+ useEffect(() => {
+ setBrowserSupport(checkBrowserSupport());
+ }, []);
+
+ const isSupported = browserSupport.webAudio && browserSupport.mediaDevices;
+
+ if (!isSupported) {
+ return (
+
+
+
+ Audio Multi Output
+
+
+
+ ブラウザサポートエラー
+
+
+ {!browserSupport.webAudio && (
+
+
+
+ )}
+ {!browserSupport.mediaDevices && (
+
+
+
+ )}
+ {!browserSupport.audioOutput && (
+
+
+
+ )}
+
+ 最新のブラウザをご利用ください。
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ Audio Multi Output
+
+
+ 複数の音声出力デバイスから独立した波形を同時再生
+
+ {!browserSupport.audioOutput && (
+
+ Audio Output Devices API
+ がサポートされていないため、デバイス選択機能が制限されます。
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 使用方法:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 制限事項:
+
+
+
+
+
+ 位相反転機能について:
+ {" "}
+ 実装方法や出力デバイス間の環境差により(?)、位相反転はノイズキャンセリングなどのシビアな要件には期待通りに機能しない場合があります。
+
+ }
+ />
+
+
+
+
+
+ );
+};
diff --git a/src/audio-multi-output/README.md b/src/audio-multi-output/README.md
new file mode 100644
index 00000000..f1df2841
--- /dev/null
+++ b/src/audio-multi-output/README.md
@@ -0,0 +1,125 @@
+# Audio Multi Output
+
+WebAudio APIとAudio Output Devices APIを活用した、複数の音声出力デバイスから独立した波形を同時再生できるWebアプリケーションです。
+
+## 機能
+
+- 複数の音声出力デバイスの検出と選択
+- デバイスごとの独立した音声設定
+ - 波形タイプ(sine, triangle, square, sawtooth)
+ - 周波数(20Hz - 20kHz)
+ - 位相反転
+- リアルタイムでの設定変更
+- 全デバイス一括制御と個別デバイス制御
+
+## 技術仕様
+
+### 使用技術
+
+- React + TypeScript
+- Jotai(状態管理)
+- Web Audio API
+- Audio Output Devices API
+- Media Devices API
+
+### ブラウザサポート
+
+- Web Audio API対応ブラウザ
+- Media Devices API対応ブラウザ
+- Audio Output Devices API対応ブラウザ(推奨)
+
+### 必要条件
+
+- HTTPS接続
+- ユーザーの操作(AudioContextの制約)
+- マイクロフォンアクセス権限(デバイス一覧取得のために一瞬だけ必要)
+
+## ディレクトリ構造
+
+```plaintext
+src/audio-multi-output/
+├── features/
+│ ├── DeviceManager/ # デバイス管理機能
+│ │ ├── DeviceManager.tsx # メインコンポーネント
+│ │ ├── DeviceSelector.tsx # デバイス選択UI
+│ │ ├── atom.ts # 状態管理
+│ │ ├── functions.ts # デバイス管理ロジック
+│ │ ├── consts.ts # 型定義・定数
+│ │ └── index.ts # 再エクスポート
+│ ├── OscillatorControl/ # オシレーター制御機能
+│ │ ├── OscillatorControl.tsx # メインコンポーネント
+│ │ ├── WaveformSelector.tsx # 波形選択UI
+│ │ ├── FrequencySlider.tsx # 周波数調整UI
+│ │ ├── PhaseSwitch.tsx # 位相反転UI
+│ │ ├── atom.ts # 状態管理
+│ │ ├── consts.ts # 型定義・定数
+│ │ └── index.ts # 再エクスポート
+│ └── OutputController/ # 音声出力制御機能
+│ ├── OutputController.tsx # メインコンポーネント
+│ ├── DevicePanel.tsx # デバイス制御パネル
+│ ├── PlayButton.tsx # 再生/停止ボタン
+│ ├── atom.ts # 状態管理
+│ ├── functions.ts # 音声出力ロジック
+│ └── index.ts # 再エクスポート
+├── atoms/
+│ └── globalAtoms.ts # グローバル状態管理
+├── consts/
+│ └── audioConsts.ts # プロジェクト全体定数
+├── utils/
+│ └── audioUtils.ts # 汎用ユーティリティ
+├── App.tsx # メインアプリケーション
+├── main.tsx # エントリーポイント
+├── index.html # HTMLテンプレート
+└── README.md # このファイル
+```
+
+## 使用方法
+
+1. **デバイス選択権限の取得**
+
+ - 「デバイス選択権限を要求」ボタンをクリック
+ - ブラウザの権限ダイアログで許可
+
+2. **音声出力デバイスの選択**
+
+ - 検出されたデバイス一覧から使用したいデバイスを選択
+ - 複数デバイスの同時選択が可能
+
+3. **音声設定の調整**
+
+ - 各デバイスごとに波形、周波数、位相反転を設定
+ - 設定は再生中でもリアルタイムで反映
+
+4. **音声再生**
+ - 「再生」ボタンで全デバイス一括再生
+ - 各デバイスパネルの再生ボタンで個別制御も可能
+
+## 注意事項
+
+- この機能にはHTTPS接続が必要です
+- 初回再生時にはユーザーの操作が必要です(Web Audio APIの制約)
+- ブラウザによっては複数デバイスへの同時出力に制限がある場合があります
+- Audio Output Devices APIは実験的な機能のため、一部ブラウザでは利用できない可能性があります。
+
+## 開発
+
+### コーディングルール
+
+このプロジェクトは、リポジトリ全体のコーディングルールに従って実装されています:
+
+- コロケーション原則に基づくディレクトリ構造
+- Jotaiを使用した状態管理(atomの直接エクスポートを避け、カスタムフックで提供)
+- Named importの使用
+- TypeScriptによる型安全性の確保
+
+### 状態管理
+
+- グローバル状態: `atoms/globalAtoms.ts`
+- 機能別状態: 各機能ディレクトリの`atom.ts`
+- カスタムフックによる状態アクセス
+
+## 参考資料
+
+- [Web Audio API - MDN](https://developer.mozilla.org/ja/docs/Web/API/Web_Audio_API)
+- [Audio Output Devices API](https://developer.mozilla.org/ja/docs/Web/API/Audio_Output_Devices_API)
+- [Media Devices API - MDN](https://developer.mozilla.org/ja/docs/Web/API/MediaDevices)
diff --git a/src/audio-multi-output/atoms/globalAtoms.ts b/src/audio-multi-output/atoms/globalAtoms.ts
new file mode 100644
index 00000000..47375df7
--- /dev/null
+++ b/src/audio-multi-output/atoms/globalAtoms.ts
@@ -0,0 +1,8 @@
+import { atom, useAtomValue, useSetAtom } from "jotai";
+
+// グローバル再生状態
+const isPlayingAtom = atom(false);
+
+// カスタムフックで提供
+export const useIsPlayingValue = () => useAtomValue(isPlayingAtom);
+export const useSetIsPlaying = () => useSetAtom(isPlayingAtom);
diff --git a/src/audio-multi-output/consts/audioConsts.ts b/src/audio-multi-output/consts/audioConsts.ts
new file mode 100644
index 00000000..fd591181
--- /dev/null
+++ b/src/audio-multi-output/consts/audioConsts.ts
@@ -0,0 +1,15 @@
+// 音声出力デバイス関連
+export const DEFAULT_DEVICE_LABEL = "デフォルト";
+
+// 音声コンテキスト関連
+export const SAMPLE_RATE = 44100;
+export const BUFFER_SIZE = 2048;
+
+// エラーメッセージ
+export const ERROR_MESSAGES = {
+ DEVICE_ENUMERATION_FAILED: "デバイスの取得に失敗しました",
+ AUDIO_CONTEXT_FAILED: "AudioContextの作成に失敗しました",
+ DEVICE_SELECTION_FAILED: "デバイスの選択に失敗しました",
+ HTTPS_REQUIRED: "この機能にはHTTPS接続が必要です",
+ USER_INTERACTION_REQUIRED: "ユーザーの操作が必要です",
+} as const;
diff --git a/src/audio-multi-output/features/DeviceManager/DeviceManager.tsx b/src/audio-multi-output/features/DeviceManager/DeviceManager.tsx
new file mode 100644
index 00000000..fa6502fa
--- /dev/null
+++ b/src/audio-multi-output/features/DeviceManager/DeviceManager.tsx
@@ -0,0 +1,92 @@
+import { Box, Typography, Alert } from "@mui/material";
+import { useEffect, useCallback } from "react";
+
+import {
+ useDeviceListValue,
+ useSetDeviceList,
+ useSelectedDevicesValue,
+ useSetSelectedDevices,
+} from "./atom";
+import { DeviceSelector } from "./DeviceSelector";
+import {
+ enumerateAudioDevices,
+ startDeviceChangeMonitoring,
+ requestAudioOutputPermission,
+} from "./functions";
+
+import type React from "react";
+
+export const DeviceManager: React.FC = () => {
+ const devices = useDeviceListValue();
+ const setDevices = useSetDeviceList();
+ const selectedDevices = useSelectedDevicesValue();
+ const setSelectedDevices = useSetSelectedDevices();
+
+ // デバイス一覧を更新
+ const updateDevices = useCallback(async () => {
+ try {
+ const deviceList = await enumerateAudioDevices();
+ setDevices(deviceList);
+ } catch (error) {
+ console.error("デバイス一覧の更新に失敗:", error);
+ }
+ }, [setDevices]);
+
+ // デバイス選択の切り替え
+ const handleDeviceToggle = useCallback(
+ (deviceId: string) => {
+ setSelectedDevices((prev) =>
+ prev.includes(deviceId)
+ ? prev.filter((id) => id !== deviceId)
+ : [...prev, deviceId],
+ );
+ },
+ [setSelectedDevices],
+ );
+
+ // デバイス選択権限の要求
+ const handleRequestPermission = useCallback(() => {
+ void (async () => {
+ const granted = await requestAudioOutputPermission();
+ if (granted) {
+ // 権限が付与されたらデバイス一覧を更新
+ await updateDevices();
+ }
+ })();
+ }, [updateDevices]);
+
+ // 初期化とデバイス変更監視
+ useEffect(() => {
+ void updateDevices();
+
+ // デバイス変更の監視を開始
+ const cleanup = startDeviceChangeMonitoring(() => {
+ void updateDevices();
+ });
+
+ return cleanup;
+ }, [updateDevices]);
+
+ return (
+
+
+ デバイス管理
+
+
+
+
+ {selectedDevices.length > 0 && (
+
+
+ 選択されたデバイス: {selectedDevices.length}個
+
+
+ )}
+
+ );
+};
diff --git a/src/audio-multi-output/features/DeviceManager/DeviceSelector.tsx b/src/audio-multi-output/features/DeviceManager/DeviceSelector.tsx
new file mode 100644
index 00000000..d49654b7
--- /dev/null
+++ b/src/audio-multi-output/features/DeviceManager/DeviceSelector.tsx
@@ -0,0 +1,58 @@
+import {
+ Box,
+ Button,
+ Checkbox,
+ FormControlLabel,
+ Typography,
+} from "@mui/material";
+
+import type { AudioOutputDevice } from "./consts";
+import type React from "react";
+
+interface DeviceSelectorProps {
+ devices: AudioOutputDevice[];
+ selectedDevices: string[];
+ onDeviceToggle: (deviceId: string) => void;
+ onRequestPermission: () => void;
+}
+
+export const DeviceSelector: React.FC = ({
+ devices,
+ selectedDevices,
+ onDeviceToggle,
+ onRequestPermission,
+}) => {
+ return (
+
+
+ 音声出力デバイス選択
+
+
+
+
+
+ {devices.length === 0 ? (
+
+ デバイスが見つかりません
+
+ ) : (
+ devices.map((device) => (
+ onDeviceToggle(device.deviceId)}
+ />
+ }
+ label={device.label}
+ sx={{ display: "block", mb: 1 }}
+ />
+ ))
+ )}
+
+
+ );
+};
diff --git a/src/audio-multi-output/features/DeviceManager/atom.ts b/src/audio-multi-output/features/DeviceManager/atom.ts
new file mode 100644
index 00000000..57e5a41f
--- /dev/null
+++ b/src/audio-multi-output/features/DeviceManager/atom.ts
@@ -0,0 +1,15 @@
+import { atom, useAtomValue, useSetAtom } from "jotai";
+
+import type { AudioOutputDevice } from "./consts";
+
+// デバイス一覧の状態
+const deviceListAtom = atom([]);
+
+// 選択されたデバイスIDの状態
+const selectedDevicesAtom = atom([]);
+
+// カスタムフックで提供
+export const useDeviceListValue = () => useAtomValue(deviceListAtom);
+export const useSetDeviceList = () => useSetAtom(deviceListAtom);
+export const useSelectedDevicesValue = () => useAtomValue(selectedDevicesAtom);
+export const useSetSelectedDevices = () => useSetAtom(selectedDevicesAtom);
diff --git a/src/audio-multi-output/features/DeviceManager/consts.ts b/src/audio-multi-output/features/DeviceManager/consts.ts
new file mode 100644
index 00000000..17fbdc3b
--- /dev/null
+++ b/src/audio-multi-output/features/DeviceManager/consts.ts
@@ -0,0 +1,12 @@
+// 音声出力デバイスの型定義
+export type AudioOutputDevice = {
+ deviceId: string;
+ label: string;
+};
+
+// デバイス関連定数
+export const DEVICE_TYPES = {
+ AUDIO_OUTPUT: "audiooutput",
+} as const;
+
+export const DEFAULT_DEVICE_ID = "default";
diff --git a/src/audio-multi-output/features/DeviceManager/functions.ts b/src/audio-multi-output/features/DeviceManager/functions.ts
new file mode 100644
index 00000000..60189bd3
--- /dev/null
+++ b/src/audio-multi-output/features/DeviceManager/functions.ts
@@ -0,0 +1,77 @@
+import { DEVICE_TYPES, DEFAULT_DEVICE_ID } from "./consts";
+import { DEFAULT_DEVICE_LABEL, ERROR_MESSAGES } from "../../consts/audioConsts";
+
+import type { AudioOutputDevice } from "./consts";
+
+// 音声出力デバイス一覧を取得
+export const enumerateAudioDevices = async (): Promise => {
+ try {
+ if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) {
+ throw new Error(ERROR_MESSAGES.DEVICE_ENUMERATION_FAILED);
+ }
+
+ const devices = await navigator.mediaDevices.enumerateDevices();
+ const audioOutputDevices = devices
+ .filter((device) => device.kind === DEVICE_TYPES.AUDIO_OUTPUT)
+ .map((device) => ({
+ deviceId: device.deviceId || DEFAULT_DEVICE_ID,
+ label: device.label || DEFAULT_DEVICE_LABEL,
+ }));
+
+ // デフォルトデバイスが含まれていない場合は追加
+ if (
+ !audioOutputDevices.some(
+ (device) => device.deviceId === DEFAULT_DEVICE_ID,
+ )
+ ) {
+ audioOutputDevices.unshift({
+ deviceId: DEFAULT_DEVICE_ID,
+ label: DEFAULT_DEVICE_LABEL,
+ });
+ }
+
+ return audioOutputDevices;
+ } catch (error) {
+ console.error("デバイス取得エラー:", error);
+ throw new Error(ERROR_MESSAGES.DEVICE_ENUMERATION_FAILED);
+ }
+};
+
+// デバイス変更の監視を開始
+export const startDeviceChangeMonitoring = (
+ onDeviceChange: () => void,
+): (() => void) => {
+ if (!navigator.mediaDevices) {
+ return () => {};
+ }
+
+ navigator.mediaDevices.addEventListener("devicechange", onDeviceChange);
+
+ // クリーンアップ関数を返す
+ return () => {
+ navigator.mediaDevices.removeEventListener("devicechange", onDeviceChange);
+ };
+};
+
+// 音声出力デバイスの選択権限を要求
+export const requestAudioOutputPermission = async (): Promise => {
+ try {
+ // MediaDevices APIが利用可能かチェック
+ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
+ console.warn("MediaDevices APIが利用できません");
+ return false;
+ }
+
+ // マイクへのアクセス権限を要求してデバイス列挙の権限を取得
+ // これによりenumerateDevices()でデバイスのラベルが取得可能になる
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
+
+ // ストリームを即座に停止(権限取得が目的のため)
+ stream.getTracks().forEach((track) => track.stop());
+
+ return true;
+ } catch (error) {
+ console.error("音声デバイスアクセス権限の要求に失敗:", error);
+ return false;
+ }
+};
diff --git a/src/audio-multi-output/features/DeviceManager/index.ts b/src/audio-multi-output/features/DeviceManager/index.ts
new file mode 100644
index 00000000..9a4767b0
--- /dev/null
+++ b/src/audio-multi-output/features/DeviceManager/index.ts
@@ -0,0 +1,13 @@
+export { DeviceManager } from "./DeviceManager";
+export {
+ useDeviceListValue,
+ useSetDeviceList,
+ useSelectedDevicesValue,
+ useSetSelectedDevices,
+} from "./atom";
+export {
+ enumerateAudioDevices,
+ startDeviceChangeMonitoring,
+ requestAudioOutputPermission,
+} from "./functions";
+export type { AudioOutputDevice } from "./consts";
diff --git a/src/audio-multi-output/features/OscillatorControl/FrequencySlider.tsx b/src/audio-multi-output/features/OscillatorControl/FrequencySlider.tsx
new file mode 100644
index 00000000..54b43d9e
--- /dev/null
+++ b/src/audio-multi-output/features/OscillatorControl/FrequencySlider.tsx
@@ -0,0 +1,32 @@
+import { Box, Typography, Slider } from "@mui/material";
+
+import { FREQUENCY_RANGE } from "./consts";
+
+import type React from "react";
+
+interface FrequencySliderProps {
+ value: number;
+ onChange: (frequency: number) => void;
+}
+
+export const FrequencySlider: React.FC = ({
+ value,
+ onChange,
+}) => {
+ return (
+
+
+ 周波数: {value} Hz
+
+ onChange(newValue as number)}
+ min={FREQUENCY_RANGE.min}
+ max={FREQUENCY_RANGE.max}
+ step={1}
+ valueLabelDisplay="auto"
+ valueLabelFormat={(value) => `${value} Hz`}
+ />
+
+ );
+};
diff --git a/src/audio-multi-output/features/OscillatorControl/OscillatorControl.tsx b/src/audio-multi-output/features/OscillatorControl/OscillatorControl.tsx
new file mode 100644
index 00000000..4d52f5b7
--- /dev/null
+++ b/src/audio-multi-output/features/OscillatorControl/OscillatorControl.tsx
@@ -0,0 +1,97 @@
+import { Box, Typography, Card, CardContent } from "@mui/material";
+import { useCallback } from "react";
+
+import { useSelectedDevicesValue } from "../DeviceManager";
+import { useOscillatorSettingsValue, useSetOscillatorSettings } from "./atom";
+import { DEFAULT_OSCILLATOR_SETTINGS } from "./consts";
+import { FrequencySlider } from "./FrequencySlider";
+import { PhaseSwitch } from "./PhaseSwitch";
+import { WaveformSelector } from "./WaveformSelector";
+
+import type { OscillatorSettings } from "./consts";
+import type React from "react";
+
+export const OscillatorControl: React.FC = () => {
+ const selectedDevices = useSelectedDevicesValue();
+ const oscillatorSettings = useOscillatorSettingsValue();
+ const setOscillatorSettings = useSetOscillatorSettings();
+
+ // デバイス設定の更新
+ const updateDeviceSettings = useCallback(
+ (deviceId: string, updates: Partial) => {
+ setOscillatorSettings((prev) => ({
+ ...prev,
+ [deviceId]: {
+ ...DEFAULT_OSCILLATOR_SETTINGS,
+ ...prev[deviceId],
+ ...updates,
+ },
+ }));
+ },
+ [setOscillatorSettings],
+ );
+
+ // デバイス設定の取得
+ const getDeviceSettings = useCallback(
+ (deviceId: string): OscillatorSettings => {
+ return oscillatorSettings[deviceId] || DEFAULT_OSCILLATOR_SETTINGS;
+ },
+ [oscillatorSettings],
+ );
+
+ if (selectedDevices.length === 0) {
+ return (
+
+
+ オシレーター制御
+
+
+ デバイスを選択してください
+
+
+ );
+ }
+
+ return (
+
+
+ オシレーター制御
+
+
+ {selectedDevices.map((deviceId) => {
+ const settings = getDeviceSettings(deviceId);
+
+ return (
+
+
+
+ デバイス: {deviceId === "default" ? "デフォルト" : deviceId}
+
+
+
+ updateDeviceSettings(deviceId, { waveform })
+ }
+ />
+
+
+ updateDeviceSettings(deviceId, { frequency })
+ }
+ />
+
+
+ updateDeviceSettings(deviceId, { phaseInvert })
+ }
+ />
+
+
+ );
+ })}
+
+ );
+};
diff --git a/src/audio-multi-output/features/OscillatorControl/PhaseSwitch.tsx b/src/audio-multi-output/features/OscillatorControl/PhaseSwitch.tsx
new file mode 100644
index 00000000..41468e6f
--- /dev/null
+++ b/src/audio-multi-output/features/OscillatorControl/PhaseSwitch.tsx
@@ -0,0 +1,31 @@
+import { Box, FormControlLabel, Switch, Typography } from "@mui/material";
+
+import type React from "react";
+
+interface PhaseSwitchProps {
+ value: boolean;
+ onChange: (phaseInvert: boolean) => void;
+}
+
+export const PhaseSwitch: React.FC = ({
+ value,
+ onChange,
+}) => {
+ return (
+
+ onChange(e.target.checked)}
+ />
+ }
+ label={
+
+ 位相反転
+
+ }
+ />
+
+ );
+};
diff --git a/src/audio-multi-output/features/OscillatorControl/WaveformSelector.tsx b/src/audio-multi-output/features/OscillatorControl/WaveformSelector.tsx
new file mode 100644
index 00000000..3bca035a
--- /dev/null
+++ b/src/audio-multi-output/features/OscillatorControl/WaveformSelector.tsx
@@ -0,0 +1,39 @@
+import { FormControl, InputLabel, Select, MenuItem, Box } from "@mui/material";
+import { useId } from "react";
+
+import { WAVEFORM_TYPES } from "./consts";
+
+import type { OscillatorType } from "./consts";
+import type React from "react";
+
+interface WaveformSelectorProps {
+ value: OscillatorType;
+ onChange: (waveform: OscillatorType) => void;
+}
+
+export const WaveformSelector: React.FC = ({
+ value,
+ onChange,
+}) => {
+ const waveformSelectId = `waveform-select-${useId()}`;
+
+ return (
+
+
+ 波形
+
+
+
+ );
+};
diff --git a/src/audio-multi-output/features/OscillatorControl/atom.ts b/src/audio-multi-output/features/OscillatorControl/atom.ts
new file mode 100644
index 00000000..bc938627
--- /dev/null
+++ b/src/audio-multi-output/features/OscillatorControl/atom.ts
@@ -0,0 +1,12 @@
+import { atom, useAtomValue, useSetAtom } from "jotai";
+
+import type { OscillatorSettings } from "./consts";
+
+// オシレーター設定の状態(デバイスIDをキーとする)
+const oscillatorSettingsAtom = atom>({});
+
+// カスタムフックで提供
+export const useOscillatorSettingsValue = () =>
+ useAtomValue(oscillatorSettingsAtom);
+export const useSetOscillatorSettings = () =>
+ useSetAtom(oscillatorSettingsAtom);
diff --git a/src/audio-multi-output/features/OscillatorControl/consts.ts b/src/audio-multi-output/features/OscillatorControl/consts.ts
new file mode 100644
index 00000000..6706768c
--- /dev/null
+++ b/src/audio-multi-output/features/OscillatorControl/consts.ts
@@ -0,0 +1,28 @@
+// オシレーター設定の型定義
+export type OscillatorSettings = {
+ waveform: OscillatorType;
+ frequency: number;
+ phaseInvert: boolean;
+};
+
+// 波形タイプ
+export const WAVEFORM_TYPES = {
+ sine: "正弦波",
+ triangle: "三角波",
+ square: "矩形波",
+ sawtooth: "のこぎり波",
+} as const;
+
+export type OscillatorType = keyof typeof WAVEFORM_TYPES;
+
+// デフォルト値
+export const DEFAULT_FREQUENCY = 440;
+export const FREQUENCY_RANGE = { min: 20, max: 20000 };
+export const DEFAULT_PHASE_INVERT = false;
+
+// デフォルト設定
+export const DEFAULT_OSCILLATOR_SETTINGS: OscillatorSettings = {
+ waveform: "sine",
+ frequency: DEFAULT_FREQUENCY,
+ phaseInvert: DEFAULT_PHASE_INVERT,
+};
diff --git a/src/audio-multi-output/features/OscillatorControl/index.ts b/src/audio-multi-output/features/OscillatorControl/index.ts
new file mode 100644
index 00000000..89383277
--- /dev/null
+++ b/src/audio-multi-output/features/OscillatorControl/index.ts
@@ -0,0 +1,9 @@
+export { OscillatorControl } from "./OscillatorControl";
+export { useOscillatorSettingsValue, useSetOscillatorSettings } from "./atom";
+export type { OscillatorSettings } from "./consts";
+export {
+ WAVEFORM_TYPES,
+ DEFAULT_FREQUENCY,
+ FREQUENCY_RANGE,
+ DEFAULT_OSCILLATOR_SETTINGS,
+} from "./consts";
diff --git a/src/audio-multi-output/features/OutputController/DevicePanel.tsx b/src/audio-multi-output/features/OutputController/DevicePanel.tsx
new file mode 100644
index 00000000..d7af943f
--- /dev/null
+++ b/src/audio-multi-output/features/OutputController/DevicePanel.tsx
@@ -0,0 +1,68 @@
+import { Box, Card, CardContent, Typography, Chip, Stack } from "@mui/material";
+
+import { PlayButton } from "./PlayButton";
+
+import type { OscillatorSettings } from "../OscillatorControl";
+import type React from "react";
+
+interface DevicePanelProps {
+ deviceId: string;
+ deviceLabel: string;
+ settings: OscillatorSettings;
+ isPlaying: boolean;
+ onChange: (playing: boolean) => void;
+}
+
+export const DevicePanel: React.FC = ({
+ deviceId: _deviceId,
+ deviceLabel,
+ settings,
+ isPlaying,
+ onChange,
+}) => {
+ return (
+
+
+
+
+ {deviceLabel}
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/audio-multi-output/features/OutputController/OutputController.tsx b/src/audio-multi-output/features/OutputController/OutputController.tsx
new file mode 100644
index 00000000..ca63bd54
--- /dev/null
+++ b/src/audio-multi-output/features/OutputController/OutputController.tsx
@@ -0,0 +1,221 @@
+import { Box, Typography, Divider } from "@mui/material";
+import { useCallback, useEffect } from "react";
+
+import { useIsPlayingValue, useSetIsPlaying } from "../../atoms/globalAtoms";
+import { useSelectedDevicesValue, useDeviceListValue } from "../DeviceManager";
+import {
+ useAudioContextsValue,
+ useSetAudioContexts,
+ useOscillatorsValue,
+ useSetOscillators,
+ useGainNodesValue,
+ useSetGainNodes,
+} from "./atom";
+import { DevicePanel } from "./DevicePanel";
+import {
+ createAudioOutput,
+ startAudioOutput,
+ stopAudioOutput,
+ updateOscillatorSettings,
+} from "./functions";
+import { PlayButton } from "./PlayButton";
+import {
+ useOscillatorSettingsValue,
+ DEFAULT_OSCILLATOR_SETTINGS,
+} from "../OscillatorControl";
+
+import type React from "react";
+
+export const OutputController: React.FC = () => {
+ const selectedDevices = useSelectedDevicesValue();
+ const deviceList = useDeviceListValue();
+ const oscillatorSettings = useOscillatorSettingsValue();
+ const isPlaying = useIsPlayingValue();
+ const setIsPlaying = useSetIsPlaying();
+
+ const audioContexts = useAudioContextsValue();
+ const setAudioContexts = useSetAudioContexts();
+ const oscillators = useOscillatorsValue();
+ const setOscillators = useSetOscillators();
+ const gainNodes = useGainNodesValue();
+ const setGainNodes = useSetGainNodes();
+
+ // デバイスラベルの取得
+ const getDeviceLabel = useCallback(
+ (deviceId: string): string => {
+ const device = deviceList.find((d) => d.deviceId === deviceId);
+ return device?.label || deviceId;
+ },
+ [deviceList],
+ );
+
+ // 個別デバイスの再生開始
+ const handlePlayDevice = useCallback(
+ async (deviceId: string) => {
+ try {
+ // 既に再生中の場合は何もしない
+ if (audioContexts[deviceId]) {
+ return;
+ }
+
+ const settings =
+ oscillatorSettings[deviceId] || DEFAULT_OSCILLATOR_SETTINGS;
+ const { audioContext, oscillator, gainNode } = await createAudioOutput(
+ deviceId,
+ settings,
+ );
+
+ await startAudioOutput(audioContext, oscillator);
+
+ setAudioContexts((prev) => ({ ...prev, [deviceId]: audioContext }));
+ setOscillators((prev) => ({ ...prev, [deviceId]: oscillator }));
+ setGainNodes((prev) => ({ ...prev, [deviceId]: gainNode }));
+ } catch (error) {
+ console.error(`デバイス ${deviceId} の再生開始に失敗:`, error);
+ }
+ },
+ [
+ audioContexts,
+ oscillatorSettings,
+ setAudioContexts,
+ setOscillators,
+ setGainNodes,
+ ],
+ );
+
+ // 個別デバイスの再生停止
+ const handleStopDevice = useCallback(
+ (deviceId: string) => {
+ try {
+ const audioContext = audioContexts[deviceId];
+ const oscillator = oscillators[deviceId];
+
+ if (audioContext && oscillator) {
+ stopAudioOutput(audioContext, oscillator);
+ }
+
+ setAudioContexts((prev) => {
+ const newContexts = { ...prev };
+ delete newContexts[deviceId];
+ return newContexts;
+ });
+ setOscillators((prev) => {
+ const newOscillators = { ...prev };
+ delete newOscillators[deviceId];
+ return newOscillators;
+ });
+ setGainNodes((prev) => {
+ const newGainNodes = { ...prev };
+ delete newGainNodes[deviceId];
+ return newGainNodes;
+ });
+ } catch (error) {
+ console.error(`デバイス ${deviceId} の再生停止に失敗:`, error);
+ }
+ },
+ [
+ audioContexts,
+ oscillators,
+ setAudioContexts,
+ setOscillators,
+ setGainNodes,
+ ],
+ );
+
+ // 設定変更時のリアルタイム更新
+ useEffect(() => {
+ Object.keys(oscillators).forEach((deviceId) => {
+ const oscillator = oscillators[deviceId];
+ const gainNode = gainNodes[deviceId];
+ const audioContext = audioContexts[deviceId];
+ const settings = oscillatorSettings[deviceId];
+
+ if (oscillator && gainNode && audioContext && settings) {
+ updateOscillatorSettings(oscillator, gainNode, settings, audioContext);
+ }
+ });
+ }, [oscillatorSettings, oscillators, gainNodes, audioContexts]);
+
+ // 全体スイッチの状態を実際の再生状態に基づいて更新
+ useEffect(() => {
+ const playingDevicesCount = selectedDevices.filter(
+ (deviceId) => audioContexts[deviceId],
+ ).length;
+ const allDevicesPlaying =
+ playingDevicesCount === selectedDevices.length &&
+ selectedDevices.length > 0;
+ setIsPlaying(allDevicesPlaying);
+ }, [selectedDevices, audioContexts, setIsPlaying]);
+
+ if (selectedDevices.length === 0) {
+ return (
+
+
+ 音声出力制御
+
+
+ デバイスを選択してください
+
+
+ );
+ }
+
+ return (
+
+
+ 音声出力制御
+
+
+
+ {
+ if (playing) {
+ // 全デバイスを再生(既に再生中のものは重複しない)
+ selectedDevices.forEach((deviceId) => {
+ void handlePlayDevice(deviceId);
+ });
+ } else {
+ // 全デバイスを停止
+ selectedDevices.forEach((deviceId) => {
+ handleStopDevice(deviceId);
+ });
+ }
+ }}
+ disabled={selectedDevices.length === 0}
+ />
+
+
+
+
+
+
+ デバイス別制御
+
+ {selectedDevices.map((deviceId) => {
+ const settings =
+ oscillatorSettings[deviceId] || DEFAULT_OSCILLATOR_SETTINGS;
+ const deviceLabel = getDeviceLabel(deviceId);
+ const isDevicePlaying = !!audioContexts[deviceId];
+
+ return (
+ {
+ if (playing) {
+ void handlePlayDevice(deviceId);
+ } else {
+ handleStopDevice(deviceId);
+ }
+ }}
+ />
+ );
+ })}
+
+
+ );
+};
diff --git a/src/audio-multi-output/features/OutputController/PlayButton.tsx b/src/audio-multi-output/features/OutputController/PlayButton.tsx
new file mode 100644
index 00000000..3e352904
--- /dev/null
+++ b/src/audio-multi-output/features/OutputController/PlayButton.tsx
@@ -0,0 +1,46 @@
+import { PlayArrow, Stop } from "@mui/icons-material";
+import { Box, FormControlLabel, Switch, Typography } from "@mui/material";
+
+import type React from "react";
+
+interface PlayButtonProps {
+ isPlaying: boolean;
+ onChange: (playing: boolean) => void;
+ disabled?: boolean;
+}
+
+export const PlayButton: React.FC = ({
+ isPlaying,
+ onChange,
+ disabled = false,
+}) => {
+ return (
+
+ onChange(e.target.checked)}
+ disabled={disabled}
+ color="primary"
+ />
+ }
+ label={
+
+ {isPlaying ? (
+
+ ) : (
+
+ )}
+
+ {isPlaying ? "停止" : "再生"}
+
+
+ }
+ sx={{ m: 0 }}
+ />
+
+ );
+};
diff --git a/src/audio-multi-output/features/OutputController/atom.ts b/src/audio-multi-output/features/OutputController/atom.ts
new file mode 100644
index 00000000..e32a13a3
--- /dev/null
+++ b/src/audio-multi-output/features/OutputController/atom.ts
@@ -0,0 +1,20 @@
+import { atom, useAtomValue, useSetAtom } from "jotai";
+
+// 各デバイスの音声コンテキスト管理
+const audioContextsAtom = atom>({});
+
+// 各デバイスのオシレーター管理
+const oscillatorsAtom = atom>({});
+
+// 各デバイスのゲインノード管理
+const gainNodesAtom = atom>({});
+
+// カスタムフックで提供
+export const useAudioContextsValue = () => useAtomValue(audioContextsAtom);
+export const useSetAudioContexts = () => useSetAtom(audioContextsAtom);
+
+export const useOscillatorsValue = () => useAtomValue(oscillatorsAtom);
+export const useSetOscillators = () => useSetAtom(oscillatorsAtom);
+
+export const useGainNodesValue = () => useAtomValue(gainNodesAtom);
+export const useSetGainNodes = () => useSetAtom(gainNodesAtom);
diff --git a/src/audio-multi-output/features/OutputController/functions.ts b/src/audio-multi-output/features/OutputController/functions.ts
new file mode 100644
index 00000000..0d4a78b4
--- /dev/null
+++ b/src/audio-multi-output/features/OutputController/functions.ts
@@ -0,0 +1,123 @@
+import { ERROR_MESSAGES } from "../../consts/audioConsts";
+import { isValidDeviceId } from "../../utils/audioUtils";
+
+import type { OscillatorSettings } from "../OscillatorControl";
+
+// AudioContext.setSinkIdの型定義(実験的機能のため)
+interface AudioContextWithSinkId extends AudioContext {
+ setSinkId?: (sinkId: string | { type: "none" }) => Promise;
+}
+
+// 音声出力の作成
+export const createAudioOutput = async (
+ deviceId: string,
+ settings: OscillatorSettings,
+): Promise<{
+ audioContext: AudioContext;
+ oscillator: OscillatorNode;
+ gainNode: GainNode;
+}> => {
+ if (!isValidDeviceId(deviceId)) {
+ throw new Error("無効なデバイスIDです");
+ }
+
+ try {
+ // AudioContextの作成
+ const audioContext = new window.AudioContext() as AudioContextWithSinkId;
+
+ // AudioContext.setSinkIdが利用可能な場合は設定
+ if (audioContext.setSinkId && deviceId !== "default") {
+ try {
+ await audioContext.setSinkId(deviceId);
+ } catch (error) {
+ console.warn(`デバイス ${deviceId} への出力設定に失敗:`, error);
+ }
+ }
+
+ // オシレーターノードの作成
+ const oscillator = audioContext.createOscillator();
+ oscillator.type = settings.waveform;
+ oscillator.frequency.setValueAtTime(
+ settings.frequency,
+ audioContext.currentTime,
+ );
+
+ // ゲインノードの作成(音量制御用)
+ const gainNode = audioContext.createGain();
+ gainNode.gain.setValueAtTime(0.1, audioContext.currentTime); // 初期音量を低めに設定
+
+ // 位相反転の処理
+ if (settings.phaseInvert) {
+ gainNode.gain.setValueAtTime(-0.1, audioContext.currentTime);
+ }
+
+ // ノードの接続(直接AudioContextのdestinationに接続)
+ oscillator.connect(gainNode);
+ gainNode.connect(audioContext.destination);
+
+ return {
+ audioContext,
+ oscillator,
+ gainNode,
+ };
+ } catch (error) {
+ console.error("音声出力の作成に失敗:", error);
+ throw new Error(ERROR_MESSAGES.AUDIO_CONTEXT_FAILED);
+ }
+};
+
+// 音声出力の開始
+export const startAudioOutput = async (
+ audioContext: AudioContext,
+ oscillator: OscillatorNode,
+): Promise => {
+ try {
+ // AudioContextの再開(ユーザー操作が必要)
+ if (audioContext.state === "suspended") {
+ await audioContext.resume();
+ }
+
+ // オシレーターの開始
+ oscillator.start();
+ } catch (error) {
+ console.error("音声出力の開始に失敗:", error);
+ throw error;
+ }
+};
+
+// 音声出力の停止
+export const stopAudioOutput = (
+ audioContext: AudioContext,
+ oscillator: OscillatorNode,
+): void => {
+ try {
+ // オシレーターの停止
+ oscillator.stop();
+
+ // AudioContextのクローズ
+ void audioContext.close();
+ } catch (error) {
+ console.error("音声出力の停止に失敗:", error);
+ }
+};
+
+// オシレーター設定の更新
+export const updateOscillatorSettings = (
+ oscillator: OscillatorNode,
+ gainNode: GainNode,
+ settings: OscillatorSettings,
+ audioContext: AudioContext,
+): void => {
+ try {
+ const currentTime = audioContext.currentTime;
+
+ // 周波数の更新
+ oscillator.frequency.setValueAtTime(settings.frequency, currentTime);
+
+ // 位相反転の更新
+ const volume = settings.phaseInvert ? -0.1 : 0.1;
+ gainNode.gain.setValueAtTime(volume, currentTime);
+ } catch (error) {
+ console.error("オシレーター設定の更新に失敗:", error);
+ }
+};
diff --git a/src/audio-multi-output/features/OutputController/index.ts b/src/audio-multi-output/features/OutputController/index.ts
new file mode 100644
index 00000000..0bfa2ab6
--- /dev/null
+++ b/src/audio-multi-output/features/OutputController/index.ts
@@ -0,0 +1,7 @@
+export { OutputController } from "./OutputController";
+export {
+ createAudioOutput,
+ startAudioOutput,
+ stopAudioOutput,
+ updateOscillatorSettings,
+} from "./functions";
diff --git a/src/audio-multi-output/index.html b/src/audio-multi-output/index.html
new file mode 100644
index 00000000..deeba3c3
--- /dev/null
+++ b/src/audio-multi-output/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Audio Multi Output
+
+
+
+
+
+
diff --git a/src/audio-multi-output/main.tsx b/src/audio-multi-output/main.tsx
new file mode 100644
index 00000000..8250cd8b
--- /dev/null
+++ b/src/audio-multi-output/main.tsx
@@ -0,0 +1,10 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+
+import { App } from "./App";
+
+createRoot(document.getElementById("root")!).render(
+
+
+ ,
+);
diff --git a/src/audio-multi-output/utils/audioUtils.ts b/src/audio-multi-output/utils/audioUtils.ts
new file mode 100644
index 00000000..1b0d393d
--- /dev/null
+++ b/src/audio-multi-output/utils/audioUtils.ts
@@ -0,0 +1,41 @@
+// 周波数の範囲チェック
+export const isValidFrequency = (frequency: number): boolean => {
+ return frequency >= 20 && frequency <= 20000;
+};
+
+// デバイスIDの検証
+export const isValidDeviceId = (deviceId: string): boolean => {
+ return typeof deviceId === "string" && deviceId.length > 0;
+};
+
+// AudioContextの状態チェック
+export const isAudioContextReady = (context: AudioContext): boolean => {
+ return context.state === "running";
+};
+
+// ブラウザサポートチェック
+export const checkBrowserSupport = (): {
+ webAudio: boolean;
+ audioOutput: boolean;
+ mediaDevices: boolean;
+} => {
+ // AudioContext.setSinkIdのサポートチェック
+ const audioOutputSupported = (() => {
+ try {
+ const testContext = new AudioContext();
+ const supported = "setSinkId" in testContext;
+ void testContext.close();
+ return supported;
+ } catch {
+ return false;
+ }
+ })();
+
+ return {
+ webAudio: "AudioContext" in window || "webkitAudioContext" in window,
+ audioOutput: audioOutputSupported,
+ mediaDevices:
+ "mediaDevices" in navigator &&
+ "enumerateDevices" in navigator.mediaDevices,
+ };
+};
diff --git a/src/index.html b/src/index.html
index bd895ef0..3ceb6f82 100644
--- a/src/index.html
+++ b/src/index.html
@@ -82,5 +82,9 @@ リンクまとめページ
>[WIP]tears of overflowed bitsの模倣
+ Audio Multi Output - 複数デバイス音声出力
+