ベータ無料!Aivis Cloud APIを使ってバイブサンプル作ったらレイテンシーがやばかった!
Aivisサーバは先日Dockerベースで立ち上げました
そしてAivisCloudAPIがベータリリースされました
触ってみたくなったので触りました!
最初にサンプル作ったものドーン
ずんだもんかわいすぎ
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