平衡点
2026/05/27
_ Beamer と pdfpc の組み合わせで発表原稿を表示させつつ, 読み上げをする
読んで字の如く.
発表資料の作成
\usepackage{pdfpc}
\newcommand<>{\talknote}[1]{\only#2{\pdfpcnote{#1}\relax}}
とでもしておいて, スライドの overlay に合わせて
\talknote{
Thank you for the introduction.
<br><br>
Hello everyone.
My name is Youhei Sasaki from Hokkaido Information University.
<br><br>
Today, I would like to talk about This titie.
<br><br>
To put it simply, this is a story about ...
}
とでも書いておくと pdfpc の presenter screen に原稿が表示される.
読み上げ音声の作成
あとは TeX ないの \talknote をパースして,
Google の Cloud Text-to-Speech API に投げると,
良い感じに読み上げ音声が出てくる(ので, あとはシャドーイング).
スクリプトはこんなん.
import os
import re
import subprocess
from google.cloud import texttospeech
def main():
# ------------------------------------------------------------------------
# 設定エリア
# ------------------------------------------------------------------------
TEX_FILENAME = 'your_presentation.tex'
SPEAKING_RATE = 0.7
PITCH = 0.0
OUTPUT_DIR = "audio_output"
# --- 結合時の設定 -------------------------------------------------------
# スライド間の無音時間(秒)
SILENCE_DURATION_SEC = 2
client = texttospeech.TextToSpeechClient()
with open(TEX_FILENAME, 'r', encoding='utf-8') as f:
tex_content = f.read()
frames = re.split(r'\\begin\{frame\}', tex_content)[1:]
os.makedirs(OUTPUT_DIR, exist_ok=True)
voice = texttospeech.VoiceSelectionParams(language_code="en-US", name="en-US-Journey-D")
audio_config = texttospeech.AudioConfig(
audio_encoding=texttospeech.AudioEncoding.MP3,
speaking_rate=SPEAKING_RATE,
pitch=PITCH
)
generated_files = []
for frame_idx, frame_content in enumerate(frames, start=1):
# --------------------------------------------------------------------
# \talknote を探す前に、TeXのコメント(%から行末まで)を削除
# --------------------------------------------------------------------
frame_content_uncommented = re.sub(r'(?<!\\)%.*', '', frame_content)
# コメントが除去されたクリーンな文字列から \talknote を抽出
talknotes = re.finditer(r'\\talknote\s*(?:<([^>]+)>)?\s*\{(.*?)\}', frame_content_uncommented, re.DOTALL)
for note_idx, note in enumerate(talknotes, start=1):
overlay = note.group(1) or "1"
raw_text = note.group(2)
# <br><br> (あるいは <br>) を区切りとして処理
chunks = re.split(r'<br\s*/?>\s*<br\s*/?>|<br\s*/?>', raw_text)
processed_chunks = []
for chunk in chunks:
chunk = re.sub(r'\s+', ' ', chunk).strip()
if chunk:
processed_chunks.append(chunk)
final_text = '\n\n'.join(processed_chunks)
print("-" * 50)
print(f"Slide {frame_idx} (Overlay {overlay}) の処理を開始...")
if not final_text:
print(f" -> 原稿が空のため、音声生成をスキップします。")
continue
print(f" [DEBUG] 送信テキスト:\n{final_text}\n")
filename = f"slide_{frame_idx:02d}_overlay{overlay}.mp3"
filepath = os.path.join(OUTPUT_DIR, filename)
try:
res = client.synthesize_speech(
input=texttospeech.SynthesisInput(text=final_text),
voice=voice,
audio_config=audio_config
)
with open(filepath, "wb") as out:
out.write(res.audio_content)
generated_files.append(filepath)
print(f" 生成完了: {filename}")
except Exception as e:
print(f" エラー発生: {e}")
# ------------------------------------------------------------------------
# ffmpeg を利用した無劣化結合 (無音区間の挿入)
# ------------------------------------------------------------------------
if generated_files:
print("=" * 50)
print(f"ffmpeg を使用して結合し、間に {SILENCE_DURATION_SEC} 秒の無音を挿入...")
silence_file = os.path.join(OUTPUT_DIR, "silence.mp3")
subprocess.run([
"ffmpeg", "-y", "-f", "lavfi", "-i", "anullsrc=r=24000:cl=mono",
"-t", str(SILENCE_DURATION_SEC), "-c:a", "libmp3lame", silence_file
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
list_file = os.path.join(OUTPUT_DIR, "concat_list.txt")
with open(list_file, "w", encoding="utf-8") as lf:
for i, fpath in enumerate(generated_files):
lf.write(f"file '{os.path.basename(fpath)}'\n")
if i < len(generated_files) - 1:
lf.write(f"file 'silence.mp3'\n")
combined_filepath = os.path.join(OUTPUT_DIR, "presentation_all.mp3")
subprocess.run([
"ffmpeg", "-y", "-f", "concat", "-safe", "0",
"-i", list_file, "-c", "copy", combined_filepath
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if os.path.exists(list_file): os.remove(list_file)
if os.path.exists(silence_file): os.remove(silence_file)
print(f" 結合完了: {combined_filepath}")
print("=" * 50)
else:
print("結合するファイルがありませんでした。")
if __name__ == "__main__":
main()
- 音声ファイルの結合に
ffmpegを使ってるので別途インストールしておく. - Goocle Cloud API で 「Cloud Text-to-Speech API」を使うので
- Google Cloud Console でプロジェクトを作成し,「Cloud Text-to-Speech API」を有効化
- 「IAMと管理」>「サービス アカウント」からキー(JSONファイル)を作成・ダウンロード
- ダウンロードした JSON ファイルのパスを,環境変数に設定
$ export GOOGLE_APPLICATION_CREDENTIALS="/path/to/your/service-account-file.json"
- venv で必要なライブラリのインストール
- 仮想環境 (.venv) を作成
$ python3 -m venv .venv $ source .venv/bin/activate
- 必要な Python パッケージをインストール
$ pip install google-cloud-texttospeech
- 仮想環境 (.venv) を作成
でまあ, あとは適宜実行する.
忘れそうなのでここに書いておく.
_ Beamer で画像も covered/uncover を使いたい
これ, デフォルトでこうなっていて欲しんだけれどなぁ.
\RequirePackage{tikz}
% 透過度の設定(デフォルト15%. お好みで)
\newcommand{\coveredopacity}{0.15}
% \coveredincludegraphics 命令の定義
% d<> でオーバーレイ指定, O{} でオプション引数, m で必須引数を受け取る
\NewDocumentCommand{\coveredincludegraphics}{ d<> O{} m }{%
\IfNoValueTF{#1}{%
% オーバーレイ指定 (<...>) がない場合は, 通常の \includegraphics
\includegraphics[#2]{#3}%
}{%
% オーバーレイ指定がある場合は, TikZ ノードの opacity を切り替え
\begin{tikzpicture}[baseline=(current bounding box.south)]%
\alt<#1>{%
% 指定されたスライド (uncovered) では opacity=1 (100%)
\node[opacity=1, inner sep=0pt, outer sep=0pt] {\includegraphics[#2]{#3}};
}{%
% それ以外のスライド (covered) では opacity=\coveredopacity (15%)
\node[opacity=\coveredopacity, inner sep=0pt, outer sep=0pt] {\includegraphics[#2]{#3}};
}%
\end{tikzpicture}%
}%
}
[ツッコミを入れる]