1人だけでも深く学びたいを叶える
背景
「学びをより楽しく深くしたい」という目的に至った背景を理解していただく前提として、私の特性を書く必要がある。私には、聴覚障害がある。なので、Youtube動画やオンラインセミナー、講演などの場面では、音声認識による文字起こしを読むことが当たり前の行為になっている。そして音声認識による文字起こしは、LLM(大規模言語モデル)の台頭と進化によって、かなり進化している。かつては手動で行なっていた「文字起こしの誤認識修正」や「要約の作成」は、AIに置き換えられ、しかもスピードと正確性ともにAIの方が優秀である。さらに聴覚障害者だけではなく、ノートを書かずに要約したいという聴者のニーズもあり、もはや「当たり前のインフラ」になりつつある。
文字起こしとLLMによる誤認識修正や要約作成が「当たり前のインフラ」になった今、二つの問いが生まれていると私は考えている。
- AIが綺麗に文字を起こして、要約してくれる中で、人間がやるべきことは何なのか?
- 音声認識+LLMという技術が、ただの文字起こしや要約を超えて新たに提供できる価値は何か?
1つ目の問いに対しては、「批判的思考や得た知識を自分ごととして落とし込んで考えることをやるべき」と考えている。これは人間しか出来ないことかつ、講演やセミナーを聞く目的の一つだからである。
しかし、1人で聞くという状況では、思考を深めることは簡単ではなくなることが課題となる。思考を深めるのに、自分の頭にある曖昧な思考を外に出して(例: ノートを取る、独り言をつぶやく、Discord に投稿する)言語化を行い、誰かからリアクションやフィードバックを貰うことが効果的だからである。だからテックカンファレンスなどで、講演に対して Discord などで感想や意見を投稿して他の人の投稿も見たり反応したりすることが推奨されている理由もそれではないかと考えている。一方で、独学などで1人で聞いている状況ではそういったことは難しい。
人間には、思考を深めるための「壁打ち相手」が必要である。それも自分が聞いている講演やセミナーの文脈をリアルタイムで共有している相手。ここで2つ目の問いに対する答えが見えてきた。「講演やセミナーの文脈を共有し、人間の壁打ち相手になること」が新たに提供できる価値となるのではないかと考えついた。
こうした経緯から、「1人だけでも深く学びたいを叶える」ことを目的に、このプロトタイプ「PairRadioListening」を開発することにした。
作ったコア機能の目的
講演をユーザー1人で視聴しながら、タイムライン上でユーザーとAIがリアクションし、学びを深める壁打ち体験を提供するコア機能は以下の3つである。
発話内容の文字起こし
AmiVoice により講演の発話をリアルタイムで書き起こし、左パネルの文字起こし画面に時刻付きで表示する機能である。マイク音声や YouTube など別ウィンドウで流している音声を取り込み、発話区間ごとの確定テキストを講義タイムライン上に表示していく。
講演を聞いていると、聴覚障害者も聴者も同じく「あれ、さっき何を言ったんだっけ」と聞き逃すことがある。そこで文字起こしを表示することで、内容を把握し、聞き逃した部分を遡れるようにすることができる。さらに、文字起こしテキストを時刻付きで表示している理由として、ユーザー投稿がどの話への反応として行われたのかをユーザーと AI 両方が把握できるようにするというものがある。
文字起こしがなければ、感想や疑問を書き殴っても「いつの話への反応か」が分かりにくい。講演を後から確認する手間がかかったりそもそもアーカイブがなくて確認できなかったりする場合があり、AI に渡す文脈も作れない。結果として、壁打ちは一般論になりやすく、学びを自分ごとに深める体験につながりにくいと考えられる。
タイムラインへの投稿
聴講中のユーザーが、感想・学び・疑問をその場で書き殴って右パネルのタイムラインに投稿する機能である。時刻を選ぶ UI は持たず、最新の文字起こし位置に投稿を自動で紐づける。
1人で聞いていると湧いた思考を、そのまま外に出す機会が少ない。そこで、湧いた思考を外に出すハードルを下げる手段として、タイムラインへの投稿機能を搭載した。搭載することで、曖昧な思考やメモでも書き殴って投稿し、思考の外化を促し、AI 壁打ちの起点にすることを狙っている。
投稿機能がなければ、頭の中で湧いた疑問や感想は残りにくくなる。また、AI も「ユーザーが何を感じたか」をコンテキストにすることができず、講義に沿った壁打ちができない。その結果、文字起こしだけでは「聞く」はできても「考える・言語化する」にはつながらない。
AIとの壁打ち
ユーザーの投稿をトリガーに、Gemini が直前1分の書き起こしとペルソナ設定を踏まえて対話タイムライン上に返信する機能である。講師発話のたびに AI が割り込むのではなく、ユーザーが外に出した投稿に応じて壁打ちする。
単なる文字起こしや要約では、学びを「楽しく、深く」する相手にはなりにくい。講義の進行に同期した文脈の中で、ユーザー投稿にリアクションすることで、「学びを自分ごとにする壁打ち相手」を届けるためである。
AI 壁打ちがなければ、投稿は自分のメモで終わり、視点の広げ方や深掘りのきっかけが足りない。講義全体を要約するだけでは、その人の疑問や理解度に合わせた対話は生まれない。1人視聴の孤独感も残り、学びが深まりにくい。
提供する価値を支える技術
聞き逃しを遡れる文字起こし — AmiVoice の発話区間確定結果をリアルタイムに蓄積する
1. 内部スピーカー 等から PCM を読む
2. 16 kHz / 16-bit / mono に整形
TARGET_SAMPLE_RATE = 16_000
...
# 音声データをAmiVoiceが指定するフォーマットに整形して、規格化されたバイナリデータを出力する。
def to_16k_mono_pcm_bytes(
pcm_bytes: bytes,
*,
sample_rate: int,
channels: int,
) -> bytes:
mono = pcm_bytes_to_mono_int16(pcm_bytes, channels=channels)
resampled = resample_mono_int16(mono, src_rate=sample_rate)
return resampled.tobytes()
3. AmiVoice API に WebSocket で PCM を送り、確定結果 resultFinalized を受け取る
# AmiVoiceサーバーとのWebSocket接続を確立し、音声ストリームデータを送れる状態にする。
def send_start_command(self, command: str) -> None:
...
...
# 規格化済みのバイナリデータをAmiVoiceサーバーに送信する。
def send_audio(self, pcm_bytes: bytes) -> None:
...
# AmiVoiceサーバーからコールバックされる結果から、不要なデータなどを弾いて
# 「確定結果の文字列」だけを後続の処理に流す。
def resultFinalized(self, result: str) -> None: # noqa: N802
if not result or result.startswith(_R_RESULT_PREFIX):
logger.debug("resultFinalized ignored (empty or R-prefix aggregate)")
return
...
self._on_finalized(result)
# 流れてきた「確定結果の文字列」をJSONとしてパースして、
# 各フィールド(時刻やテキスト)にアクセスできる辞書(Payload)を得る。
def _on_ws_message(self, raw: str) -> None:
try:
payload = json.loads(raw)
except json.JSONDecodeError:
...
return
...
self.handle_payload(payload)
4. 確定結果だけを utterance として文字起こしに蓄積(時刻区間つき)
# JSONの辞書から "starttime", "endtime", "utteranceid", などを抽出して
# 発話データとして扱いやすいオブジェクトを得る。
start_ms = first.get("starttime")
end_ms = first.get("endtime")
utterance_id = body.get("utteranceid")
...
return SpeechRecognitionUtteranceEvent(
lecture_id=lecture_id,
utterance_id=str(utterance_id),
start_ms=int(start_ms),
end_ms=int(end_ms),
transcript=str(body["text"]),
speaker_display_name=speaker_display_name,
)
# 整形した発話データを、講義(lecture)の履歴に「追加または上書き(upsert)」して、
# ユーザーが後から「聞き逃しを遡れる」ように蓄積する。
utterance = Utterance(
id=request.utterance_id,
time_range=time_range,
speech_text=request.speech_text,
speaker=request.speaker,
)
...
lecture.upsert_utterance(utterance)
5. 時刻ラベル・話者名・本文に整形して Vue の文字起こしパネルに表示する
# フロントエンドがそのまま描画できるように、データ構造を整える。
lines = tuple(
TranscriptLineView(
utterance_id=item.utterance_id,
time_label=format_time_label(item.time_range),
speaker_label=item.speaker.display_name,
body=item.speech_text.text,
)
for item in response.items
)
...
# フロントエンドへ渡す
書き殴りでも講義との対応が分かる — 時刻選択なしで、最新の文字起こし位置に投稿を紐づける
1. AmiVoice 確定結果の end_ms を発話データ utterance の一部として記録しておく
# AmiVoiceが認識した発話の「終了時刻(endtime)」を抽出し、
# 「いつ発言が終わったか」という正確なタイムスタンプを得ている。
start_ms = first.get("starttime")
end_ms = first.get("endtime")
utterance_id = body.get("utteranceid")
...
# 終了時刻を含めた発話データを履歴として保存し、
# 後からユーザーの投稿と紐づけるための「アンカー時刻」を作っている。
utterance = Utterance(
id=request.utterance_id,
time_range=time_range, # ← ここにアンカー時刻となる end_ms が含まれている
speech_text=request.speech_text,
speaker=request.speaker,
)
...
lecture.upsert_utterance(utterance)
2. システムが最後に受け取った発話データ utterance から 最新の文字起こしの end_ms を見て latest_anchor_ms を算出する
# 蓄積された文字起こしデータの一覧から、一番最後(最新)の発話の終了時刻を取り出して、
# 「現在、講義がどこまで進んでいるか」を示す最新アンカー時刻(latest_anchor_ms)を設定する。
lines = tuple(
TranscriptLineView(...)
for item in response.items
)
latest_anchor_ms = response.items[-1].time_range.end_ms if response.items else 0
return TranscriptViewModel(
lecture_id=response.lecture_id,
lines=lines,
latest_anchor_ms=latest_anchor_ms,
error_message="",
)
3. ユーザが書き殴って投稿した時に、lecture_time_anchor に latest_anchor_ms を代入して、投稿時刻として対話パネルに表示する
# ユーザーが投稿ボタンを押した瞬間、画面が裏で保持していた「最新アンカー時刻」を自動で付与し、
# ユーザーが手動で時刻を選ぶ手間なく、「今聞いたことに対するリアクション」として紐づける。
async function submitReaction() {
...
const anchor = transcript.value.latest_anchor_ms
if (!id || !text || anchor === 0) return
await postUserReaction(id, {
reaction_text: text,
lecture_time_anchor: anchor,
speaker_display_name: speakerDisplayName.value,
})
}
# 投稿が特定のテキストを引用したものでない(ただの書き殴りの)場合は、
# システム側で自動的に「直近の発話」をターゲットに設定し、
# 「どの発言に対するメモか」とわかるように紐づける。
reply_target = request.reply_target
if reply_target is None:
latest = lecture.latest_utterance()
reply_target = ReplyTarget(
reply_target_kind="utterance",
reply_target_id=str(latest.id),
)
...
reaction = Reaction(
reply_target=reply_target,
lecture_time_anchor=request.lecture_time_anchor,
reaction_text=request.reaction_text,
...
)
# 紐づけられた発話の時刻データ(00:00 - 00:05など)を投稿表示用のモデルに添えることで、
# 後から見返したときに「講義のどの時点でのメモ・疑問か」がぱっと見で分かるUI表示にする。
if kind == "utterance":
time_range = context.utterance_times.get(target_id)
if time_range is not None:
reference_time_label = format_time_label(time_range)
return DialogueLineView(
reaction_id=item.reaction_id,
speaker_label=item.speaker.display_name,
body=item.reaction_text.text,
reference_time_label=reference_time_label,
reference_quote_label=reference_quote_label,
)
的外れな壁打ちがない - ユーザの投稿に、直前1分の文字起こしも踏まえて Gemini が返す
1. ユーザ投稿成功、つまり lecture_time_anchor = t0 を検出する
# ユーザーが投稿したリアクションのアンカー時刻(t0)を基準に、
# AI に渡すべき文字起こしが後続の処理で決定される。
t0 = event.lecture_time_anchor_ms
2. t0 を起点に [t0 - 60秒(60000ms), t0] のutterance を抽出する
# ユーザーの投稿時刻(t0)から過去1分間(60,000ミリ秒)を計算し、
# その時間内に含まれる講師の発話をすべて集めることで、
# AI が文脈を解釈するための「直前の文字起こし」を渡す。
FOCUS_WINDOW_MS = 60_000
start_bound = max(0, t0 - FOCUS_WINDOW_MS)
focused_utterances = [
u for u in lecture.utterances
if start_bound <= u.time_range.end_ms <= t0
]
3. その文字起こしとユーザ投稿とペルソナ設定を Gemini に渡して、返信を生成する
# 「過去1分間の講師の発話」「ユーザーの投稿文」「AI のペルソナ設定」の3つの要素を
# プロンプトとして統合し、Gemini APIを呼び出すことで、
# 講義の流れに沿った的確な「AI の返答テキスト」を得ている。
prompt = build_ai_reply_prompt(
utterances=focused_utterances,
user_reaction=reaction.reaction_text.text,
persona=persona_profile
)
ai_response_text = await gemini_client.generate(prompt)
4. Gemini が生成した返信を受け取り、対話パネルに表示する
# 生成されたAIの回答を新しい対話(Reaction)として追加し、
# フロントエンドがそのまま描画できる表示用のモデルに変換して、
# ユーザーの画面へリアルタイムに届ける。
ai_reaction = Reaction(
lecture_id=lecture_id,
speaker=DialogueSpeaker(role="ai", display_name=persona_profile.display_name),
reaction_text=ReactionText(text=ai_response_text),
lecture_time_anchor=LectureTimeAnchor(start_ms=t0, end_ms=t0),
)
lecture.add_reaction(ai_reaction)
...
return DialogueLineView(
reaction_id=item.reaction_id,
speaker_label=item.speaker.display_name,
body=item.reaction_text.text,
)
