ステータス: 一部実装済み・一部未決定(下記「実装状況」参照)
本書はui-brain-protocol.mdで定義される一般プロトコルの付録であり、UI層が音声応答UI(AiChatリポジトリ)である場合に固有の仕様・実装判断を定義する。一般プロトコルで規定されない事項(control個々の意味、リトライ方針、失敗時の振る舞い等)はここに書く。
関連: AiChatリポジトリ issue #8(音量の音声指示機能)。過去の中間検討資料はarchive/参照。
issue #8はもともと.envのAUDIO_OUTPUT_VOLUMEによる静的な倍率設定だったが、「ユーザーが音声で指示したら音量が変わる」機能へスコープを変更した。「もう少し大きく」「うるさいから下げて」のような言い回しの揺れを吸収するため、既存の天気・Web検索と同じLLM tool-calling方式で意図認識する。
操作対象はこのアプリケーション内部の再生倍率(_apply_volume_pcm16によるPCM16スケーリング)であり、Windows OS自体のマスター音量ではない。
controlアクション: adjust_volume一般プロトコルのcontrol配列に現れる、音声UI固有のアクション種別。
{"type": "adjust_volume", "direction": "up" | "down"}
direction)のみを判定して返す。数値・現在の音量・ステップ幅など、実装依存の値は一切持たない。_VOLUME_STEP = 0.1)。0.0〜2.0にクランプする(既存AUDIO_OUTPUT_VOLUMEの検証範囲と揃える)。conversation)を信用せず上書きする(Brainは現在値を知らないため、既に上限でも「上げました」と言ってしまう可能性がある)。例: 「これ以上大きくできません」。type・不正なdirectionはログに残して無視する(例外にしない)。controlはconversationを読み上げる前に必ず適用する(新しい音量で読み上げ自体が再生されるように)。conversationが空文字になり得る設計上、既存の「回答が空なら会話を終了する」という分岐は、control適用より先に評価してはならない。無音の成功でもcontrolの適用自体は行い、そのターンを継続する。
conversation, control = self.chat.ask(text)
override = self._apply_control_actions(control)
speak_text = override or conversation
if speak_text:
self._play_ack_tone()
self._speak(speak_text)
microphone.clear()
start_timeout_sec = self.settings.conversation_timeout_sec
_VOLUME_STEP = 0.1
def _apply_control_actions(self, control: list[dict]) -> str | None:
"""controlを適用する。クランプにより実際は変化しなかった場合、代わりに読み上げるべき文言を返す。"""
override: str | None = None
for action in control:
if action.get("type") != "adjust_volume":
logger.warning("未知のcontrolアクションです: %s", action)
continue
direction = action.get("direction")
if direction not in ("up", "down"):
logger.warning("不正なdirectionです: %s", action)
continue
step = _VOLUME_STEP if direction == "up" else -_VOLUME_STEP
before = self.settings.audio_output_volume
after = max(0.0, min(2.0, before + step))
self.settings.audio_output_volume = after
if after == before:
override = "これ以上大きくできません" if direction == "up" else "これ以上小さくできません"
return override
adjust_volumeツール定義(参考。実装場所: AiChatWls側tools.py)Brain側のツール定義そのものだが、その存在理由は音声UIにしかないため、参考としてここに載せる。
def adjust_volume(direction: str) -> dict[str, object]:
if direction not in ("up", "down"):
raise ToolError(f"未知の方向です: {direction}")
return {"type": "adjust_volume", "direction": direction}
ToolSpec(
name="adjust_volume",
description=(
"ユーザーが音量を上げてほしい/下げてほしいと発話した時に呼ぶ"
"(例:「もっと大きく」「うるさいから下げて」「音量下げて」)。"
"具体的な数値は分からないので、directionだけを渡す。"
),
parameters={
"type": "object",
"properties": {
"direction": {
"type": "string",
"enum": ["up", "down"],
"description": "上げるならup、下げるならdown",
},
},
"required": ["direction"],
},
handler=adjust_volume,
is_client_action=True,
)
現状app.pyのAssistant.__init__はローカルのOllamaChatを直接構築しており、Brainの/askを一切呼んでいない。adjust_volumeをBrain経由の役割分担(Brainが意図認識、音声UIが状態適用)で動かすには、この接続(OllamaChat→RemoteChat、wsl-brain-separation-plan.mdのステージ1本体カットオーバー)が必要になる。
この前倒しカットオーバーを今回行うかどうかは、まだ決定していない。 議論の経緯:
request_id)を定義した。OllamaChatのままにする、という判断も残っている。次に決めるべきはここ(カットオーバーする/しないの判断)であり、決定後に本節以降を確定させる。
RemoteChat(カットオーバーする場合の実装案、未確定)class RemoteChat:
def __init__(self, brain_url: str, session_id: str) -> None:
self.brain_url = brain_url.rstrip("/")
self.session_id = session_id
self._client = httpx.Client(timeout=httpx.Timeout(30.0, connect=5.0))
def close(self) -> None:
self._client.close()
def health(self) -> None:
self._client.get(f"{self.brain_url}/health/ready").raise_for_status()
def ask(self, text: str, client_type: str = "voice") -> tuple[str, list[dict]]:
request_id = str(uuid4())
payload = {
"session_id": self.session_id,
"request_id": request_id,
"text": text,
"client_type": client_type,
}
last_error: Exception | None = None
for _attempt in range(2): # 初回+1回だけ再試行(値は暫定)
try:
response = self._client.post(f"{self.brain_url}/ask", json=payload)
response.raise_for_status()
data = response.json()
return data["conversation"], data.get("control", [])
except httpx.HTTPError as error:
last_error = error
raise last_error # 呼び出し元(app.py)が失敗時の振る舞いを決める
def clear_history(self) -> None:
response = self._client.post(
f"{self.brain_url}/clear_history", json={"session_id": self.session_id}
)
response.raise_for_status()
close()の呼び出し元は既存のAssistant.close()がself.chat.close()を無条件で呼ぶ構造になっているため、RemoteChatにclose()を実装するだけで自動的に呼ばれる(追加の配線は不要)。
config.pyにBRAIN_URL設定を追加する。Assistant.__init__はself.chat = RemoteChat(settings.brain_url, session_id=str(uuid4()))に置き換える(tools.pyのローカルツール群はBrain側が保持するため不要になる)。
一般プロトコル(ui-brain-protocol.md7節)が明示的にUI固有としている事項。カットオーバーする場合に決めること:
RemoteChatのレスポンスパース(カットオーバーする場合)。_apply_control_actionsによるクランプ(0.0〜2.0)と、クランプ時のoverride文言。controlの適用が読み上げ(_speak)より先に行われること。directionを安全に無視すること。/ask経由で音量指示→適用までを通しで確認する(カットオーバーする場合)。adjust_volumeツール、AskResult、dedup、フォールバック修正): AiChatWls側 実装・テスト済み・push済み。RemoteChat、config.pyのBRAIN_URL、app.pyの切替、_apply_control_actions): 未実装。3節の未決定事項(カットオーバーの是非)が先。