🎙️

ベータ無料!Aivis Cloud APIを使ってバイブサンプル作ったらレイテンシーがやばかった!

に公開

Aivisサーバは先日Dockerベースで立ち上げました
そしてAivisCloudAPIがベータリリースされました
触ってみたくなったので触りました!

最初にサンプル作ったものドーン

https://x.com/i/status/1948028883577844159

ずんだもんかわいすぎ

Aivis Cloude APIのベータ

今回CloudAPIのリリースでベータ期間無料という破格の状態だったので、試してみようと思います!

まずは登録
AivisProcjectへアクセス

「ダッシュボードにアクセス」ボタンを押下

今回は、Googleで登録します

「新規登録時、連携先 Google アカウントの名前とアイコンを登録情報として利用する」は好みで

PopupでGoogleアカウント選択になるので、紐づかせたいアカウントを選択

aivis-project.com にログインで「次へ」

ダッシュボードに入りました!

APIキーの発行

(APIキー管理画面)[https://hub.aivis-project.com/cloud-api/api-keys]へアクセス
「最初のAPIキーを作成」を押下

新しいAPIキーを作成のポップアップで以下を入力して「作成」ボタン押下

  • APIキー名:{お好きなKEY}
  • 課金モード:現状従量課金

APIキーが発行されます

aivis_で始まるキーが発行される
「コピー」ボタンを押して大事大事保存する
保存後、「確認しました」を押下

※画像内のキーは既に殺してます

登録完了

では、実装

とっても親切なことにCORSが許可されているので

なんと、ローカル環境にHTML作ってブラウザで開くだけで確認が可能です!!

今、使わなきゃいつ使うの!?

結果として冒頭で作成した内容を作成しました。

とりあえず枠作ってイコライザー風エフェクト作成は「kiro」さんにお願いしましたw
いやほんとバイブコーディングはとりあえず作るに強いですねぇ・・・。

最後に

これだけのレイテンシーなら、リアルタイムAI生成の言葉を読ませることができるので、

  • AIチャット
  • 占い結果
  • AI恋愛シミュレーションをフルボイス

とか夢があるなととても思いました!
とりあえず、ずんだもんの声が簡単に聞けますので、皆さんも一度さわってみるをおすすめしま~す!

ボイスパターンに関して

ボイスを変えたい場合は、以下にボイスのHubが存在します。
https://hub.aivis-project.com/
好きなキャラを選択して、選択したキャラの詳細画面で
「モデルUUID」という項目がありますので

モデルUUIDをコピーして後述コードの以下部分を書き換えてお楽しみください

 const model_uuid = `b7be910e-d703-4b3d-80e4-02d1426d21d0`;

おまけ(サンプルコード全文)

つくったらコード長くなったりましたので、最後に記載です

以下が公式実装を基にしたサンプルですが、如何せんサクッと作ること優先したのでぐちゃぐちゃしてますが、ご容赦ください!
利用する場合は「YOUR_API_KEY」部分を発行したAPIキーに書き換えてください~

<html>
<head>
    <style>
        body {
            display: flex;
            width: 100%;
            align-items: center;
            justify-content: center;
            margin: 0;
            background: #1a1a1a;
            color: white;
            font-family: Arial, sans-serif;
        }
        .field {
            display: flex;
            flex-direction: column;
            align-items: center;
            padding: 2em;
            border: solid 1px #444;
            border-radius: 15px;
            background: #2a2a2a;
            text-align: center;
        }
        .image-container {
            position: relative;
            display: inline-block;
            margin: 60px 0;
            padding: 80px;
        }
        .circular-image {
            width: 200px;
            height: 200px;
            border-radius: 50%;
            object-fit: cover;
            border: 3px solid #4a9eff;
            transition: all 0.3s ease;
        }
        .spectrum-container {
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            width: 360px;
            height: 390px;
            pointer-events: none;
            opacity: 0.3;
            transition: opacity 0.3s ease;
        }
        .spectrum-active {
            opacity: 1;
        }
        .spectrum-bar {
            position: absolute;
            width: 3px;
            background: linear-gradient(to top, #ff0080, #ff4000, #ffff00, #00ff40, #00ffff, #4000ff, #8000ff);
            border-radius: 2px;
            transform-origin: bottom center;
            box-shadow: 0 0 10px currentColor;
            transition: all 0.1s ease;
        }
        textarea {
            width: 300px;
            height: 80px;
            margin: 10px 0;
            padding: 10px;
            border: 1px solid #555;
            border-radius: 8px;
            background: #333;
            color: white;
            resize: vertical;
        }
        button {
            padding: 12px 24px;
            margin: 10px;
            border: none;
            border-radius: 8px;
            background: #4a9eff;
            color: white;
            font-size: 16px;
            cursor: pointer;
            transition: background 0.3s ease;
        }
        button:hover:not(:disabled) {
            background: #3a8eef;
        }
        button:disabled {
            background: #666;
            cursor: not-allowed;
        }
    </style>
</head>
<body>
   <div class="field">
        <h1>おしゃべりずんだもん</h1>
        <div class="image-container">
            <img class="circular-image"
                src="https://assets.aivis-project.com/aivm-models/a0a37be6-3646-4716-a72e-cf6294f670f0/speakers/5fa1c6e2-1cc0-44d6-90d1-f26e37acc40e/icon.jpg" />
	            <div class="spectrum-container" id="spectrumContainer"></div>
        </div>
        <textarea placeholder="ずんだもんに話してほしいことを入力" id="zundaWord"></textarea>
        <button id="speak">話してずんだもん!</button>
        <audio id="audio"></audio>
    </div>
    <script>
        // 円形オーディオスペクトラムビジュアライザー
        class CircularAudioSpectrum {
            constructor(audioElement, spectrumContainer) {
                this.audioElement = audioElement;
                this.spectrumContainer = spectrumContainer;
                this.audioContext = null;
                this.analyser = null;
                this.dataArray = null;
                this.animationId = null;
                this.spectrumBars = [];
                this.innerRings = [];
                this.waveRipples = [];
                this.isActive = false;
                this.barCount = 64;
                this.createSpectrumElements();
            }
            createSpectrumElements() {
                // 放射状のスペクトラムバー(画像の縁から始まる)
                for (let i = 0; i < this.barCount; i++) {
                    const bar = document.createElement('div');
                    bar.className = 'spectrum-bar';
                    const angle = (i / this.barCount) * 2 * Math.PI;
                    // 画像の半径(100px)+ ボーダー(3px)から開始
                    const imageRadius = 103;
                    const x = Math.cos(angle) * imageRadius;
                    const y = Math.sin(angle) * imageRadius;
                    // コンテナの中心(180px)から配置
                    bar.style.left = `${180 + x - 1.5}px`; // バーの幅の半分を引く
                    bar.style.top = `${180 + y}px`;
                    bar.style.height = '15px';
                    bar.style.transform = `rotate(${angle + Math.PI/2}rad)`;
                    bar.style.transformOrigin = 'bottom center';
                    // レインボーカラーのグラデーション
                    const hue = (i / this.barCount) * 360;
                    bar.style.background = `linear-gradient(to top, hsl(${hue}, 100%, 50%), hsl(${(hue + 60) % 360}, 100%, 70%))`;
                    bar.style.boxShadow = `0 0 8px hsl(${hue}, 100%, 50%)`;
                    this.spectrumContainer.appendChild(bar);
                    this.spectrumBars.push(bar);
                }
            }
            async initAudioContext() {
                if (!this.audioContext) {
                    this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
                    this.analyser = this.audioContext.createAnalyser();
                    this.analyser.fftSize = 256;
                    const source = this.audioContext.createMediaElementSource(this.audioElement);
                    source.connect(this.analyser);                    this.analyser.connect(this.audioContext.destination);
                    this.dataArray = new Uint8Array(this.analyser.frequencyBinCount);
                }
            }
            startVisualizer() {
                if (!this.isActive) {
                    this.isActive = true;
                    this.spectrumContainer.classList.add('spectrum-active');
                    this.animate();
                }
            }
            stopVisualizer() {
                this.isActive = false;
                this.spectrumContainer.classList.remove('spectrum-active');
                if (this.animationId) {
                    cancelAnimationFrame(this.animationId);
                }
            }
            animate() {
                if (!this.isActive) return;
                this.analyser.getByteFrequencyData(this.dataArray);
                // スペクトラムバーを更新
                this.spectrumBars.forEach((bar, index) => {
                    const dataIndex = Math.floor(index * this.dataArray.length / this.barCount);
                    const value = this.dataArray[dataIndex];
                    const height = Math.max(15, (value / 255) * 60);
                    const intensity = value / 255;
                    bar.style.height = `${height}px`;
                    bar.style.opacity = 0.8 + (intensity * 0.2);
                    // 動的な色の変化
                    const hue = (index / this.barCount) * 360;
                    bar.style.background = `linear-gradient(to top, hsl(${hue}, 100%, ${50 + intensity * 30}%), hsl(${(hue + 60) % 360}, 100%, ${70 + intensity * 20}%))`;
                    bar.style.boxShadow = `0 0 ${8 + intensity * 15}px hsl(${hue}, 100%, 50%)`;
                });
                this.animationId = requestAnimationFrame(() => this.animate());
            }
        }
        // 以下のコードは PC 版 Chrome の DevTools 、Mac 版 Safari の Web インスペクタで動作します (Firefox では動作しません) 。
        // Safari では、表示中ページのサイト設定から「自動再生」を「すべてのメディアを自動再生」に設定する必要があります。
        (async () => {
            const speakBtn = document.querySelector("#speak");
            const zundaWord = document.querySelector("#zundaWord");
            const audioElement = document.querySelector("#audio");
            const spectrumContainer = document.querySelector("#spectrumContainer");
            const model_uuid = `b7be910e-d703-4b3d-80e4-02d1426d21d0`;
            // 円形オーディオスペクトラムインスタンスを作成
            const spectrum = new CircularAudioSpectrum(audioElement, spectrumContainer);
            // オーディオイベントリスナー
            audioElement.addEventListener('play', async () => {
                await spectrum.initAudioContext();
                spectrum.startVisualizer();
            });
            audioElement.addEventListener('pause', () => {
                spectrum.stopVisualizer();
            });
            audioElement.addEventListener('ended', () => {
                spectrum.stopVisualizer();
            });
            speakBtn.addEventListener("click", async () => {
                const zundaSpeakWord = zundaWord.value;
                if (!zundaSpeakWord) {
                    alert("ずんだもんに話してほしいことを入力してください!");
                    return;
                }
                // ロック
                speakBtn.disabled = true;
                const res = await fetch('https://api.aivis-project.com/v1/tts/synthesize', {
                    method: 'POST',
                    headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json' },
                    body: JSON.stringify({
                        model_uuid,
                        text: zundaSpeakWord,
                        use_ssml: true,
                        output_format: 'mp3', // ストリーミングのために MP3 を指定
                    }),
                });
                if (!res.ok) throw new Error(`HTTP ${res.status}`);
                // MediaSource / ManagedMediaSource (iOS Safari) ですべての生成が終わる前にストリーミング再生
                // iOS Safari は MediaSource 非対応だが、iOS 17.1 以降では代わりに ManagedMediaSource を利用できる
                const mediaSource = self.MediaSource ? new self.MediaSource() : new self.ManagedMediaSource();
                // const audio = new Audio(URL.createObjectURL(mediaSource));
                const audio = audioElement;
                audio.src = URL.createObjectURL(mediaSource);
                audio.disableRemotePlayback = true; // ManagedMediaSource での再生に必要
                audio.play().catch(console.error);
                console.log('Streaming audio data...');
                mediaSource.addEventListener('sourceopen', async () => {
                    const sb = mediaSource.addSourceBuffer('audio/mpeg');
                    // updating フラグが立っていたら updateend まで待つ
                    const waitForIdle = () =>
                        sb.updating ? new Promise(r => sb.addEventListener('updateend', r, { once: true })) : Promise.resolve();
                    const reader = res.body.getReader();
                    while (true) {
                        const { value, done } = await reader.read();
                        if (done) {
                            await waitForIdle(); // 最後の書き込みを待つ
                            console.log('Streaming audio data finished.');
                            mediaSource.endOfStream();
                            // 終わったらロック解除
                            speakBtn.disabled = false;
                            break;
                        }
                        await waitForIdle();
                        sb.appendBuffer(value);
                        await waitForIdle();
                    }
                });
            });
        })();
    </script>
</body>
</html>

Discussion