
【日本語学習者向け】日本語を話したらGeminiがあとから訂正してくれるシステムの開発【柏の葉高校情報理数科】
課題研究の技術ブログを公開します!
このブログは、情報理数科3年「課題研究」という授業のまとめブログです。
来年以降の情報理数科の皆さんに向けて18期生からのメッセージだと思ってください!
どんなシステム?
私たちは課題研究の授業で、外国人労働者の日本語力を補うためのシステムを開発しました。
接客中にピンマイクをつけて自分の音声を録音すると、接客終了後に発言内容の誤りや訂正案を表示します。

使用したもの
・Raspberry Pi 4B
・Raspberry Pi用モニター(ELECROW 3.5インチ HDMIディスプレイ)
・USBピンマイク(ANOTHER DIMENSION USBマイク)
・モバイルバッテリー(Anker PowerCore 10000 PD Redux 25W)
・Gemini 1.5 pro

ラズパイの上にモニターを接続。モニターの接続方法は後程解説します。
Geminiとは?
GeminiとはGoogleが開発したマルチモーダル生成AIモデルのことです。生成AIを使用することにより、複雑な処理を行わずに本システムを実行可能にします。
モニターの接続方法
ラズパイの端子にモニターを刺します。HDMI端子同士を接続します。中に入っている説明書の画像通りに行えばできます。
刺すだけで映りますが、タッチペンでの操作を行うにはhttps://github.com/goodtft/LCD-show で公開されているドライバのインストールが必要です。
$ sudo rm -rfLCD-show
$ git clone https://github.com/goodtft/LCD-show.git
$ chmod -R 755 LCD-show
$ cd LCD-show/
$ sudo ./MPI3508_480_320-show
上記のコードをターミナルで実行していってください。自動的に再起動し、その後タッチペン操作が可能になります。
※注意! この状態だとデスクトップPCに映りません。
タッチパネル反応用
$ cd LCD-show/
$ sudo ./MPI3508-show
モニター表示用
$ cd LCD-show/
$ sudo ./LCD-hdmi
それぞれターミナルで実行してからじゃないとできないので気を付けてください。
(ダウンロードファイルの方からでも実行できます…該当ファイルを押せば「実行しますか?」ってでてきました…他の方のブログ等に書いていなかったので責任は取りませんが…)(沢山のファイルがインストールされると思います…自分が使っていない製品名のファイルは消しても問題なかったです…自己責任でどうぞ…)
今のままだと画面が見にくいと思うので、解像度を変更することをおすすめします。800*480、800*600くらいが見やすいと思います。各自何度も変更して最適解を見つけてください。
日本語訂正のプログラム
画面上のボタンで録音を開始、終了します。取得した音声ファイルをGeminiに送信し、日本語の誤り、訂正案を生成させ、画面に表示させています。
app.py
from flask import Flask, render_template, request #インストール必要(webアプリ用の枠組み)
from flask_socketio import SocketIO #インストール必要(音声をリアルタイムでやり取りするために)
import google.generativeai as genai #インストール必要(Gemini)
import ffmpeg #インストール必要(音声フォーマット変換用)
import os
import base64 #送られたデータ(Base64)を音声データに変換する用
#FlaskとSocketIOの事前準備
app = Flask(__name__)
socketio = SocketIO(app)
#Geminiの事前準備
os.environ["API_KEY"]="**************************" #ここにAPIキー
genai.configure(api_key=os.environ["API_KEY"])
audio_chunks = {}
#音声ファイルを読み込んでGeminiに文字起こし、文章校正させる
def recognize_file(filepath):
audio_file = genai.upload_file(path=filepath)
#今回は高性能なGemini 1.5 Proを利用
model = genai.GenerativeModel(model_name="gemini-1.5-pro")
prompt = "接客中の店員の音声を聞いて、日本語が間違っているところ,間違っている理由,正しい日本語を,ごとに/を入れて丁寧に教えて。複数間違っているところがある時は間違いがあるたびに^を入れて。間違っていない文章は出力しないで。日本語能力試験N3の方向けの文章にして、漢字はひらがなになおして。---より上は出力しないで"
#命令文を音声ファイルと一緒にGeminiに送る
response = model.generate_content([prompt, audio_file])
return response.text
#html返すだけ
@app.route('/')
def index():
return render_template('index.html')
#定期的に録音データが送られた時の動作
@socketio.on('sending_recdata')
def sending_recdata(data):
print('on recording')
session_id = request.sid
chunk_data = data.get('webmdata')
#まだ録音データ収納用配列がなかった場合、新しく録音データ収納用配列を追加
if session_id not in audio_chunks:
audio_chunks[session_id] = []
#録音データを追加
audio_chunks[session_id].append(base64.b64decode(chunk_data))
#録音終了時の動作
@socketio.on('stop_recording')
def stop_recording(data):
session_id = request.sid
chunk_data = data.get('webmdata')
# session_id に対応するエントリーが存在しない場合は、新しく作成する
if session_id not in audio_chunks:
audio_chunks[session_id] = []
audio_chunks[session_id].append(base64.b64decode(chunk_data)) # 最後のチャンクを追加
input_data = b''.join(audio_chunks.get(session_id, [])) #webmデータの取得
# ffmpegプロセスの実行(webmをwavに変換する)
process = (
ffmpeg
.input('pipe:0', format='webm')
.output('pipe:1', format='wav')
.run_async(pipe_stdin=True, pipe_stdout=True, pipe_stderr=True)
)
# ffmpegにデータを渡して、出力データを取得
output_data, err = process.communicate(input=input_data)
# 出力データをwavファイルとして一時的に書き込む
filename = f"{session_id}.wav"
with open(filename, 'wb') as f:
f.write(output_data)
#文字起こし、文章校正実行
textres = recognize_file(filename)
#一時ファイル削除
os.remove(filename)
# エラーメッセージを表示(必要に応じて)
if process.returncode != 0:
print("Error:", err.decode())
socketio.emit('response_data', {'res_stt': 'error',"text":""}, room=session_id)
# 処理後、辞書からエントリーを削除
if session_id in audio_chunks:
audio_chunks.pop(session_id)
#文字起こし結果を渡す
socketio.emit('response_data', {'res_stt': '',"text":textres}, room=session_id)
#ローカルサーバー実行
if __name__ == '__main__':
socketio.run(app, port=5000, debug=True, allow_unsafe_werkzeug=True)
script.js
let mediaRecorder;
let audioChunks = [];
let recordingInterval;
const RECORDING_INTERVAL_MS = 5000; // 5秒ごとにデータを送信
// Socket.IOの接続設定
var socketUrl;
var options = { transports: ['websocket'] }; // WebSocketのみを使用
if (location.protocol === 'https:') {
socketUrl = 'https://' + document.domain;
} else {
socketUrl = 'http://' + document.domain + ':5000';
}
var socket = io.connect(socketUrl, options);
//文字起こしが完了したときの動作
socket.on("response_data", function (data) {
const messageBox = document.getElementById("response");
messageBox.innerHTML = data.text.replaceAll('/', '<br>').replaceAll('^', '<br><br>');//"/"で改行、"^"で二行改行させる
document.getElementById("status").textContent = "";
});
//録音開始時の動作
function startRecording() {
navigator.mediaDevices.getUserMedia({ audio: true })
.then(stream => {
const options = { mimeType: 'audio/webm' };
mediaRecorder = new MediaRecorder(stream, options);
mediaRecorder.ondataavailable = async event => {
if (mediaRecorder.state === "recording") {
//録音データをbase64として送る
const base64Data = await getBase64Data(event.data);
socket.emit('sending_recdata', { webmdata: base64Data });
} else if (mediaRecorder.state === "inactive") {
// 録音が停止したときに最後のデータを送信
const base64Data = await getBase64Data(event.data);
socket.emit('stop_recording', { webmdata: base64Data });
}
};
mediaRecorder.start(RECORDING_INTERVAL_MS);
});
}
async function stopRecording() {
mediaRecorder.stop(); // stop すると最後のデータが ondataavailable で取得される
}
//取得した音声データをBase64に変換する
async function getBase64Data(blob) {
const arrayBuffer = await blob.arrayBuffer();
return btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
}
//ボタンの文字変更
let buttondata = document.getElementById("btn_recording_start");
buttondata.addEventListener("click", async function() {
if (buttondata.innerHTML === "録音開始"){
startRecording();//録音してないなら録音開始
document.getElementById("status").textContent = "録音中...";
buttondata.innerHTML = "録音停止";
document.getElementById("response").innerHTML = "";
} else {
await stopRecording();//録音してるなら録音終了
document.getElementById("status").textContent = "音声認識中...";
buttondata.innerHTML = "録音開始";
}
});
function requestFullscreen(elem) {
// 全画面表示をリクエストするメソッドを取得
const method = elem.requestFullscreen || elem.webkitRequestFullscreen || elem.mozRequestFullScreen || elem.msRequestFullscreen;
if (method) {
method.call(elem); // 全画面表示をリクエスト
}
}
//全画面表示する
function startFullscreen() {
const elem = document.documentElement;
requestFullscreen(elem);
}
//ボックス内をスクロールできるようにする
function mousedragscrollable(element){
let target;
const elms = document.querySelectorAll(element);
for(let i=0; i<elms.length; i++){
elms[i].addEventListener('mousedown', function(evt){
evt.preventDefault();
target = elms[i];
target.dataset.down = 'true';
target.dataset.move = 'false';
target.dataset.x = evt.clientX;
target.dataset.y = evt.clientY;
target.dataset.scrollleft = target.scrollLeft;
target.dataset.scrolltop = target.scrollTop;
evt.stopPropagation();
});
elms[i].addEventListener('click', function(evt){
if(elms[i].detaset != null && elms[i].detaset.move == 'true') evt.stopPropagation();
});
}
document.addEventListener('mousemove', function(evt){
if(target != null && target.dataset.down == 'true'){
evt.preventDefault();
let move_x = parseInt(target.dataset.x) - evt.clientX;
let move_y = parseInt(target.dataset.y) - evt.clientY;
if (move_x !== 0 || move_y !== 0) {
target.dataset.move = 'true';
} else {
return;
}
target.scrollLeft = parseInt(target.dataset.scrollleft) + move_x;
target.scrollTop = parseInt(target.dataset.scrolltop) + move_y;
evt.stopPropagation();
}
});
document.addEventListener('mouseup', function(evt){
if(target != null && target.dataset.down == 'true'){
target.dataset.down = 'false';
evt.stopPropagation();
}
});
}
window.addEventListener('DOMContentLoaded', function(){
mousedragscrollable('.box');
});
style.css
#first{
width: 100%;
}
#first p{
height: auto;
margin: 2.5% auto;
font-size: 1.75rem;
}
button{
font-size: 1.25rem;
}
#startfullscreen{
position: fixed;
right: 0;
left: auto;
margin: 1% 2.5%;
}
.box {
display: inline-block;
border: 1px solid #000000;
width: 100%;
height: 50vh;
box-sizing: border-box;
overflow: auto;
padding: 5px;
top: 0;
left: 0;
font-size: 1.75rem;
}
#status {
width: 100%;
height: 10%;
font-size: 1.5rem;
top: 0;
left: 0;
margin: 2.5% auto;
}