こんにちはsuginokoです。
今回の記事は
【AI】ヤスカワ君と会話したい~前編~【Spineアニメーション】 の続きです。
早速書いていこうと思いますが、前回の復習として、「やりたいこと」と「使用ツール、環境」について載せておきます。
やりたいこと
弊社 の安川社長を二次元キャラクター「ヤスカワ君」として登場させ、会話できるようにします。
- ヤスカワ君を描く
- ヤスカワ君を動かす
- ヤスカワ君をWebブラウザに表示する
- 自分の声をテキストとして表示する
- そのテキストをヤスカワ君を通して回答させる。(その際、ヤスカワ君は話しているようなアニメーションをする)
- ヤスカワ君の回答をテキストで表示させるだけでなく、ヤスカワ君のような音声でも出力させる(明るい感じの日本人青年男性声)
後編では4~6の対応についてです。
前編でSpineデータをブラウザに出力するところまでできていますので、4の項目から進めるのですが色々と用意するものがありますので準備する段階から記載していきます。
※ローカル環境のみでの検証であり、デプロイは考慮してません。
使用ツール、環境
- Procreate
- Adobe Photoshop
- Spine(ESS) v4.2
- React v19
- PIXI.js(PIXI.js React)
- Google Cloud Speech-To-Text
- Google Cloud Text-To-Speech
- GAS(Google Apps Script)
- Gemini AI(テキスト生成)
全て個人のアカウントで試しています。
Google系取り扱いの準備
ヤスカワ君と会話する実装を行う前の準備です。
色々調べてた時にAPIキーを使うやり方をよく見かけたのですが、Webフロント(クライアントを指す)だけで解決させるにはセキュリティ的に好ましくなかったので、GAS(Google Apps Script) を使うことにしました。GASとGoogle Cloud(Speech-To-TextとText-to-Speechの場合)は連携ができるとAPIキーが不要になる(連携後にアクセストークンは必要)ため、この方法で進めます。また、Geminiの場合はAPIキーが必要なのでそれは別途用意しておきます。
GeminiのAPIキーはGAS上の「スクリプトプロパティ」にて追加して使用するようにしましょう。
Googleとの連携周りは検索すると多くの情報が出てくるので割愛しますが、GASとGoogle Cloudと連携する方法は資料があまり見当たらなかったのでここで触れておきます。
Google Cloud側で
- Google Cloudでプロジェクトを作成し、「APIとサービス」を有効にする
・ここではText-to-SpeechとSpeech-To-Textを有効にしておきます - 作成したプロジェクトのプロジェクト番号を控えておく
GAS側で
- 「プロジェクトの設定」にてGoogle Cloud Platformプロジェクトの欄まで進む
- Google Cloud側でコピーしたプロジェクト番号を追加しておく
これで準備完了です。
自分の声を認識してテキストに出力させる
自身の声をテキストに変換させるにはSpeech-To-Textを使って対応します。
音声をテキストに変換させる大元の部分をGASで実装し、レスポンスとして音声をテキスト化されたものが返ってくるようにします。
ポイントは、APIキーは不要ですが、アクセストークンは必要なので、取得してから Authorization
に入れてAPIを叩けるようにすることです。
GAS側
// リクエストタイプを決めておいて、doPostでhandleSpeechToTextを叩けるようにしておく
/**
* Speech To Text
*
*/
function handleSpeechToText (payload) {
const base64AudioContent = payload.audioContent;
const sampleRateHertz = payload.sampleRateHertz || 48000
const languageCode = payload.languageCode || 'ja-JP'
const accessToken = ScriptApp.getOAuthToken()
if (!base64AudioContent) {
return { error: 'No audio content provided', statusCode: 400 }
}
const speechRequestBody = {
config: {
encoding: 'WEBM_OPUS', // クライアントの音声形式に合わせる
sampleRateHertz,
languageCode
},
audio: {
content: base64AudioContent
}
}
const options = {
method: 'post',
contentType: 'application/json; charset=utf-8',
payload: JSON.stringify(speechRequestBody),
muteHttpExceptions: true,
headers: {
'Authorization': 'Bearer ' + accessToken,
}
}
// API叩いてテキストもらう
try {
const response = UrlFetchApp.fetch(SPEECH_TO_TEXT_API, options)
const responseCode = response.getResponseCode()
const responseBody = response.getContentText()
if (responseCode == 200) {
const speechApiResult = JSON.parse(responseBody)
const transcript = speechApiResult.results && speechApiResult.results.length ? speechApiResult.results[0].alternatives[0].transcript : '認識されたテキストはありませんでした'
return { data: { transcript } }
} else {
console.error(`Speech To Text Api Error: ${responseCode} - ${responseBody}`)
return { error: `Speech To Text Api Error: ${responseBody}`, statusCode: responseCode }
}
} catch (err) {
console.error('UrlFetchApp for faild', err)
return { error: `Failed to connect to API: ${err.message}`, statusCode: 500 }
}
}
React側(カスタムフック)
const useSpeechToText = (GAS_WEB_API_URL :string) => {
const [isRecognizing, setIsRecognizing] = useState(false);
const [transcript, setTranscript] = useState('');
const [error, setError] = useState<string | null>(null);
const audioChunkRef = useRef<Blob[]>([]);
const streamRef = useRef<MediaStream | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
useEffect(() => {
// アンマウント処理
return () => {
if (mediaRecorderRef?.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef?.current.stop();
}
if (streamRef?.current) {
streamRef?.current.getTracks().forEach(track => track.stop());
}
}
}, [])
const startRecording = async () => {
setError(null);
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
mediaRecorderRef.current = new MediaRecorder(stream, { mimeType: 'audio/webm' });
audioChunkRef.current = [];
mediaRecorderRef.current.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunkRef.current.push(event.data);
}
};
mediaRecorderRef.current.onstop = async () => {
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
}
const audioBlob = new Blob(audioChunkRef.current, { type: 'audio/webm' });
if (audioBlob.size == 0) {
setTranscript('音声が検出されませんでした。');
setError('音声が検出されませんでした。');
setIsRecognizing(false);
return;
}
try {
const reader = new FileReader();
reader.readAsDataURL(audioBlob);
reader.onloadend = async () => {
console.log("reader.result の型:", typeof reader.result);
console.log("reader.result の中身:", reader.result); // これで ArrayBuffer が表示されるか確認
const base64Audio = typeof reader.result === 'string' ? reader.result.split(',')[1] : '';
// Speech-to-Text APIの呼び出し
const requestBodyToGas = {
type: 'speechToText',
audioContent: base64Audio,
sampleRateHertz: 48000,
languageCode: 'ja-JP',
}
const response = await fetch(GAS_WEB_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBodyToGas),
})
if (!response.ok) {
const errorData = await response.json();
setIsRecognizing(false);
throw new Error (`GAS ERROR: ${response.status} - ${errorData.error || 'Unknown error from GAS'}`);
}
const data = await response.json();
if (data && data.transcript) {
setTranscript(data.transcript); // STTの結果をセット
} else if (data && data.error) {
setTranscript('認識に失敗しました')
setError(`GAS ERROR: ${data.error}`);
} else {
setTranscript('認識されたテキストはありませんでした')
}
setIsRecognizing(false);
}
reader.onerror = (error) => {
console.error('FileReader error:', error);
setError('音声ファイルの読み込みに失敗しました。');
setIsRecognizing(false);
}
} catch (err) {
console.error('STT GAS Proxy call failed', err);
setError(`音声認識リクエスト中にエラーが発生しました: ${err}`);
setTranscript('音声認識に失敗しました。');
setIsRecognizing(false);
}
}
mediaRecorderRef.current.start();
setIsRecognizing(true);
} catch (err) {
console.error('マイクアクセスエラー:', err);
setError('マイクへのアクセスに失敗しました。ブラウザの設定を確認してください。');
setTranscript('マイクへのアクセスに失敗しました。');
setIsRecognizing(false);
}
}
const stopRecording = () => {
if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') {
mediaRecorderRef.current.stop();
setIsRecognizing(false);
}
}
return {
isRecognizing,
transcript,
error,
startRecording,
stopRecording,
}
}
export default useSpeechToText;
音声取得失敗時の処理がまだ甘いですが、正常系であればなんとか形になりました。
ヤスカワ君の回答がほしい
本来であれば弊社の安川の情報などを学習させたAIを組み込みたかったのですが、まずは一旦は回答を得られればいいと考えたのでGemini AIを使って回答させてみることにしました。
自身の声をテキストに変換できたので、そのテキストを使ってAIから回答してもらう部分を作成します。
GAS側
// リクエストタイプを決めておいて、doPostでhandleGeminitextGenerationを叩けるようにしておく
/**
* Geminiを使ってテキストからの回答を行う
*/
function handleGeminitextGeneration (payload) {
const prompt = payload.prompt
if (!prompt) {
return { error: 'GeminiAPIに対してプロンプトがありません', statusCode: 400 }
}
const geminiRequestBody = {
contents: [{
parts: [{
text: prompt
}]
}]
}
const options = {
method: 'post',
contentType: 'application/json; charset=utf-8',
payload: JSON.stringify(geminiRequestBody),
muteHttpExceptions: true,
headers: {
'x-goog-api-key': GEMINI_API_KEY // APIキーをヘッダーで送る場合
}
}
try {
const response = UrlFetchApp.fetch(GEMINI_API, options);
const responseCode = response.getResponseCode();
const responseBody = response.getContentText();
if (responseCode === 200) {
const geminiApiResult = JSON.parse(responseBody);
// Gemini のレスポンスからテキストを抽出
const generatedText = geminiApiResult.candidates && geminiApiResult.candidates.length > 0 &&
geminiApiResult.candidates[0].content && geminiApiResult.candidates[0].content.parts &&
geminiApiResult.candidates[0].content.parts.length > 0 && geminiApiResult.candidates[0].content.parts[0].text
? geminiApiResult.candidates[0].content.parts[0].text
: "Geminiからの応答がありませんでした。";
return { data: { response: generatedText } };
} else {
console.error(`Gemini API Error: ${responseCode} - ${responseBody}`);
return { error: `Gemini API Error: ${responseBody}`, statusCode: responseCode };
}
} catch (err) {
console.error("UrlFetchApp for Gemini failed:", err);
return { error: `Gemini API アクセス失敗: ${err.message}`, statusCode: 500 };
}
}
React側
const useGeminiText = (GAS_WEB_API_URL: string) => {
const [geminiResponse, setGeminiResponse] = useState<string>('');
const [isLoadingGemini, setIsLoadingGemini] = useState<boolean>(false);
const [geminiError, setGeminiError] = useState<string | null>(null);
// Gemini AI へのリクエストを送信する関数
const getGeminiResponse = useCallback(async (prompt: string) => {
if (!prompt) {
console.error('プロンプトが空です。');
return;
}
setIsLoadingGemini(true);
setGeminiError(null);
setGeminiResponse(''); // 前のレスポンスをクリア
try {
const requestBodyToGas = {
prompt,
type: 'geminiTextGeneration'
}
const response = await fetch(GAS_WEB_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(requestBodyToGas)
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(`GAS ERROR: ${response.status} - ${errorData.error || 'Unknown error from GAS'}`);
}
const data = await response.json();
if (data.response) {
setGeminiResponse(data.response);
return data.response;
} else if (data && data.error) {
setGeminiError(`Gemini空のエラー: ${data.error}`);
return '';
} else {
setGeminiResponse('Geminiからの応答がありませんでした。');
return 'Geminiからの応答がありませんでした。';
}
} catch (err) {
console.error('Geminiリクエスト中にエラーが発生:', err);
setGeminiError(`Geminiリクエスト中にエラーが発生: ${err}`);
} finally {
setIsLoadingGemini(false);
}
}, [isLoadingGemini, GAS_WEB_API_URL]);
return {
getGeminiResponse,
geminiResponse,
isLoadingGemini,
geminiError,
}
}
export default useGeminiText;
GAS側で headers
の x-goog-api-key
にAPIキーをつけていますが、URLに含める形でもいけたと思います。
また、Reactのカスタムフック側でstate
に回答を含めて返そうとしたのですが再レンダリングの諸々でうまくいかなかったのでthen
から返される値を使うようにしたらうまくいきました。
回答を音声に変換する
回答が返されるのを確認したら、そのテキストを音声に変更してもらいます。
流れとしてはText-to-Speechとほぼ同じです。
ヤスカワ君は日本人男性なので日本人の男性の声で設定しておきます。
GAS側
// リクエストタイプを決めておいて、doPostでhandleGeminitextGenerationを叩けるようにしておく
/**
* Text To Speech
*
*/
function handleTextToSpeech (payload) {
const text = payload.text;
const languageCode = payload.languageCode || "ja-JP";
const voiceName = payload.voiceName || "ja-JP-Wavenet-A"; // デフォルトの日本語Wavenet音声
const ssmlGender = payload.ssmlGender || "MALE"; // デフォルトの性別
const audioEncoding = payload.audioEncoding || "MP3"; // クライアントから指定されたエンコーディング
const accessToken = ScriptApp.getOAuthToken()
if (!text) {
return { error: "No text content provided for TTS.", statusCode: 400 };
}
const ttsRequestBody = {
input: { text: text },
voice: { languageCode: languageCode, name: voiceName, ssmlGender: ssmlGender },
audioConfig: { audioEncoding: audioEncoding }, // クライアントから受け取ったエンコーディングを使用
};
const options = {
method: "post",
contentType: "application/json",
payload: JSON.stringify(ttsRequestBody),
muteHttpExceptions: true,
headers: {
'Authorization': 'Bearer ' + accessToken,
}
};
try {
// TTS API は、GASが紐付けられたGoogle Cloudプロジェクトのサービスアカウントで認証されるのでアクセストークンだけでOK
const response = UrlFetchApp.fetch(TEXT_TO_SPEECH_API, options);
const responseCode = response.getResponseCode();
const responseBody = response.getContentText();
if (responseCode === 200) {
const ttsApiResult = JSON.parse(responseBody);
if (ttsApiResult.audioContent) {
// Base64エンコードされた音声データが audioContent として返される
return { data: { audioContent: ttsApiResult.audioContent } };
} else {
return { error: "No audio content returned from TTS API.", statusCode: 500 };
}
} else {
console.error(`Text-to-Speech API Error: ${responseCode} - ${responseBody}`);
return { error: `Text-to-Speech API Error: ${responseBody}`, statusCode: responseCode };
}
} catch (e) {
console.error("TTS アクセス失敗:", e);
return { error: `Failed to connect to TTS API: ${e.message}`, statusCode: 500 };
}
}
React側
const useTextToSpeech = (GAS_WEB_API_URL: string) => {
const [ isPlaying, setIsPlaying ] = useState(false); // 音声再生中かどうか
const [ ttsEror, setTtsError ] = useState<string | null>(null); // TTSエラー
const [isLoadingTts, setIsLoadingTts] = useState<boolean>(false); // TTSの読み込み状態
// テキストを音声に変換して再生する関数
const synthesizePLaySpeech = useCallback(async (text: string) => {
if (!text || isLoadingTts) {
console.error('テキストが空またはTTSが読み込み中です。');
return
}
setIsLoadingTts(true);
setTtsError(null);
try {
const requestBodyToGas = {
type: 'textToSpeech',
text,
languageCode: 'ja-JP', // 日本語の言語コード
voiceName: 'ja-JP-Wavenet-C', // 日本語の音声名
ssmlGender: 'MALE', // 音声の性別
audioEncoding: 'MP3' // 音声のエンコード形式
}
const response = await fetch(GAS_WEB_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(requestBodyToGas)
})
if (!response.ok) {
const errorData = await response.json();
throw new Error(`GAS ERROR: ${response.status} - ${errorData.error || 'Unknown error from GAS'}`);
}
const data = await response.json();
console.log('TTS APIレスポンス:', data);
if (data && data.audioContent) {
const audioBase64 = data.audioContent;
const audioSrc = `data:audio/mp3;base64,${audioBase64}`;
const audio = new Audio(audioSrc);
audio.onplaying = () => setIsPlaying(true);
audio.onended = () => setIsPlaying(false);
audio.onerror = (error) => {
console.error('Audio playback error:', error);
setTtsError('音声の再生中にエラーが発生しました。');
setIsPlaying(false);
};
audio.play()
} else if (data && data.error) {
setTtsError(`TTSエラー: ${data.error}`);
console.error('TTSエラー:', data.error);
} else {
setTtsError('音声データがありませんでした。');
console.error('音声データがありませんでした。');
}
} catch (error) {
console.error('TTS APIリクエスト中にエラーが発生:', error);
setTtsError(`TTS APIリクエスト中にエラーが発生: ${error}`);
} finally {
setIsLoadingTts(false);
}
}, [isLoadingTts])
return {
isPlaying,
ttsEror,
isLoadingTts,
synthesizePLaySpeech
}
}
export default useTextToSpeech;
こちらもほぼ正常系だけの対応で、エラー処理は甘いですが、これで自分の問いに対して音声で回答が返ってくるようになりました。
成果物
UX的な観点ではもっとこだわれたはずですし、ヤスカワ君を本来の弊社安川社長に似せたかった気持ちは多少ありますが、目標は達成できましたので、これにて検証は終えようと思います。以下が成果物です。
回答がどうしてもAIっぽいことや、回答の「どういたしましてどういたしまして」といった違和感がある回答もあるため、回答の精度や音声のAIらしさは今後の改善
に期待というところなんでしょうね。
作ってみて
「楽しそう」という単なる好奇心で取り組んでみましたが、正直アニメーション部分はSpineじゃなくてもよかったかなと思いました。個人的に遊ぶ分にはいいですし、滑らかに動くのは確かですが、WebブラウザにおけるアニメーションはAPNGやLottieもあるため、この程度のアニメーションなら重さ的にもSpine、というかCanvas使うほどのものでもないと感じました。ソースコードの記述量もLottieに比べると多い印象です。
そもそもSpineは2Dゲーム開発でよく使われるソフトウェアなので、非ゲーム(サービス系)を取り扱う弊社ではあまり縁はないかもしれません。
しかし、Spineで作成した通りの滑らかさがブラウザ上で再現できるのであれば「魅せる」という点において、Webブラウザ上でダイナミックさを表現できるのは一つのいいポイントなのかもしれません。ハードルは高いとは感じますが。
結果として会社で使う分には微妙だなという感想にしても、以前から「やってみたい」と思ってたことを達成できたので今回はそれでよしとします。
長文になりましたが読んでいただきありがとうございました。