ワンクリックで
kb-web-audio
Web Audio API / WebSocket / 音声対話UIのナレッジ。PCM再生、マイク入力、SigV4認証、トランスクリプト表示
Codex または Claude でインストール この Prompt をコピーして Codex、Claude、または他のアシスタントに貼り付けると、Skill ページを確認してインストールできます。
メニュー
Web Audio API / WebSocket / 音声対話UIのナレッジ。PCM再生、マイク入力、SigV4認証、トランスクリプト表示
Codex または Claude でインストール この Prompt をコピーして Codex、Claude、または他のアシスタントに貼り付けると、Skill ページを確認してインストールできます。
SOC 職業分類に基づく
Tavily APIキーの残りクレジットを確認(.envから取得)。※アプリの利用統計は /check-app-stats を使用
ローカルのマージ済みGitブランチを整理・削除する
リポジトリ内の未プッシュの変更をすべて確認し、適切なコミットに分割してコミット&プッシュする
1Password CLI(op コマンド)のナレッジ。.env.op テンプレートから .env 生成、op-sync、Touch ID最小化、Vault構成、アカウント切替等
AgentCore CDK・デプロイ・ランタイム統合のナレッジ。Runtime作成、JWT認証、SSE、Dockerfile、コンテナ管理、Browser Tool等
AgentCore Identity(アウトバウンド認証)のナレッジ。3LO/M2M/デコレータ分離/callback等
| name | kb-web-audio |
| description | Web Audio API / WebSocket / 音声対話UIのナレッジ。PCM再生、マイク入力、SigV4認証、トランスクリプト表示 |
| user-invocable | true |
| model | sonnet |
ブラウザでの音声処理・WebSocket接続・音声対話UIの学びを記録する。
一般的なフロントエンド開発パターンは /kb-frontend を参照。バックエンドの BidiAgent 実装は /kb-bidi-agent を参照。
低サンプルレート(16kHz)の PCM 音声チャンクをブラウザで安定再生する方式。AudioWorklet リングバッファ方式は macOS で不安定なため、こちらを推奨。
// AudioContext はネイティブサンプルレートで作成(16kHz を強制しない)
const ctx = new AudioContext();
await ctx.resume(); // 自動再生ポリシー対策(必須!)
const nextPlayTimeRef = useRef(0);
const activeSourcesRef = useRef<AudioBufferSourceNode[]>([]);
function playChunk(int16Data: Int16Array) {
const audioBuffer = ctx.createBuffer(1, int16Data.length, 16000);
const channelData = audioBuffer.getChannelData(0);
for (let i = 0; i < int16Data.length; i++) {
channelData[i] = int16Data[i] / 32768; // Int16 → Float32
}
const source = ctx.createBufferSource();
source.buffer = audioBuffer;
source.connect(ctx.destination);
const startTime = Math.max(nextPlayTimeRef.current, ctx.currentTime);
source.start(startTime);
nextPlayTimeRef.current = startTime + audioBuffer.duration;
activeSourcesRef.current.push(source);
source.onended = () => {
activeSourcesRef.current = activeSourcesRef.current.filter(s => s !== source);
};
}
// 割り込み時: 全ソースを停止してバッファクリア
function stopAllPlayback() {
activeSourcesRef.current.forEach(s => { try { s.stop(); } catch {} });
activeSourcesRef.current = [];
nextPlayTimeRef.current = 0;
}
AudioWorklet リングバッファを使わない理由:
AudioContext({ sampleRate: 16000 }) は macOS で不安定(ハードウェアは通常 48kHz)重要: AudioContext.resume() をユーザーインタラクション時に呼ばないと、ブラウザの自動再生ポリシーで音が出ない。
// pcm-capture-processor.js(AudioWorklet)
class PcmCaptureProcessor extends AudioWorkletProcessor {
process(inputs) {
const input = inputs[0][0]; // mono channel
if (!input) return true;
// Float32 → Int16 変換
const int16 = new Int16Array(input.length);
for (let i = 0; i < input.length; i++) {
const s = Math.max(-1, Math.min(1, input[i]));
int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
}
this.port.postMessage(int16.buffer, [int16.buffer]); // Transferable で効率的
return true;
}
}
registerProcessor('pcm-capture-processor', PcmCaptureProcessor);
// メインスレッド側
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const audioCtx = new AudioContext({ sampleRate: 16000 });
await audioCtx.audioWorklet.addModule('/pcm-capture-processor.js');
const source = audioCtx.createMediaStreamSource(stream);
const workletNode = new AudioWorkletNode(audioCtx, 'pcm-capture-processor');
workletNode.port.onmessage = (event) => {
const int16Buffer = new Int16Array(event.data);
const base64 = int16ToBase64(int16Buffer);
websocket.send(JSON.stringify({ type: 'audio', audio: base64 }));
};
source.connect(workletNode);
// base64 → Int16Array(受信した音声データのデコード)
function base64ToInt16(base64: string): Int16Array {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return new Int16Array(bytes.buffer);
}
// Int16Array → base64(マイク入力データのエンコード)
function int16ToBase64(int16: Int16Array): string {
const bytes = new Uint8Array(int16.buffer);
let binary = '';
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
return btoa(binary);
}
ブラウザの WebSocket API はカスタムヘッダー(Authorization)を設定できないため、SigV4 事前署名 URL を使う。
import { SignatureV4 } from '@smithy/signature-v4';
import { HttpRequest } from '@smithy/protocol-http';
import { Sha256 } from '@aws-crypto/sha256-js';
import { fetchAuthSession } from 'aws-amplify/auth';
async function createPresignedWebSocketUrl(hostname: string, path: string, region: string) {
const session = await fetchAuthSession();
const credentials = session.credentials; // Cognito Identity Pool の IAM 認証情報
const signer = new SignatureV4({
service: 'bedrock-agentcore', region,
credentials, sha256: Sha256,
});
const request = new HttpRequest({
method: 'GET', protocol: 'https:', hostname, path,
query: { qualifier: 'DEFAULT' },
headers: { host: hostname },
});
const presigned = await signer.presign(request, { expiresIn: 300 });
// presigned.query から RFC 3986 形式でクエリ文字列を構築
const queryString = Object.entries(presigned.query!)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v as string)}`)
.join('&');
return `wss://${hostname}${path}?${queryString}`;
}
ポイント:
service は bedrock-agentcore(Bedrock AgentCore の WebSocket エンドポイント用)qualifier=DEFAULT は必須Amplify.getConfig() は custom フィールドを返さない → amplify_outputs.json を直接 import音声対話(Nova Sonic 等)のトランスクリプトは音声再生より先にテキストが届く。アシスタントの非final テキストをそのまま表示すると「未来の内容で上書き」される問題がある。
function handleTranscript(role: string, text: string, isFinal: boolean) {
// アシスタント非final → インジケーターのみ(テキストは表示しない)
if (role === 'assistant' && !isFinal) {
setIsAssistantSpeaking(true);
return;
}
// final → インジケーター消去 + 履歴に追加
if (role === 'assistant') setIsAssistantSpeaking(false);
// ユーザー側の非final はリアルタイム表示OK(自分の発話なので先行しても問題ない)
setTranscripts(prev => {
const last = prev[prev.length - 1];
if (last && last.role === role && !last.isFinal) {
// 同一ロールの非final を上書き
return [...prev.slice(0, -1), { role, text, isFinal }];
}
return [...prev, { role, text, isFinal }];
});
}
ポイント:
isFinal の値に関わらず、直前エントリが同じロールで非final なら上書き(重複防止)症状: AudioBufferSourceNode で音声を再生しようとしても無音
原因: ブラウザの自動再生ポリシーにより、ユーザーインタラクションなしでは AudioContext が suspended 状態になる
解決策: ユーザーのボタンクリック等のタイミングで AudioContext.resume() を呼ぶ
const handleStartCall = async () => {
await audioContext.resume(); // 必須!これがないと音が出ない
// ... WebSocket接続等
};
症状: アシスタントの応答が吹き出しで2回表示される
原因: isFinal=false のときだけ直前エントリを上書きしていたため、isFinal=true が来ると新しいエントリとして追加された
解決策: isFinal の値に関わらず、直前エントリが同じロールで isFinal=false なら上書き
setTranscripts(prev => {
const last = prev[prev.length - 1];
if (last && last.role === role && !last.isFinal) {
return [...prev.slice(0, -1), { role, text, isFinal }];
}
return [...prev, { role, text, isFinal }];
});