メインコンテンツへスキップ
見出し画像

「今日の夜ごはん、何にしよう?」に声で答えてくれるAIを、パソコン1台で作ってみた


    「ジャービス、今日の夜ごはん、何にしよう?」

    そう話しかけると、AIが声で答えてくれる。映画の中の話のようですが、これを特別な機材なしで、普段使っているパソコン1台だけで動かしています。

    専用のマイクも、専用のスピーカーも用意していません。今回は、そのジャービスの作り方と、そのまま動くファイル一式をまとめてお渡しします。読み終わるころには、あなたのパソコンにも「話しかけると、声で返してくれる画面」ができあがっているはずです。

    プログラミングの知識は要りません。ファイルを保存して、ソフトを2つ入れて、ダブルクリックするだけです。

    ジャービスって、何ができるの?

    やっていることは、実はとてもシンプルです。マイクに向かって話すと、次の順番で処理が進みます。

    1. あなたの声を、パソコンが文字に変換する

    2. その文字をClaude Codeに渡して、返事を考えてもらう

    3. 返事を、声で読み上げる

    画像

    できることは、あくまで会話と相談だけです。ファイルを消したり、コマンドを実行したりすることはできないようにしてあります。

    声には聞き間違いがつきものです。だからこそ、取り返しのつかない操作は、設計の段階で最初から外しました。安心して話しかけてもらえる作りになっています。

    注意:Apple SiliconのMacが必須です

    最初に、一番大事なことをお伝えします。

    この記事の内容が動くのは、Apple Silicon(M1以降)搭載のMacだけです。

    声を文字にする部分で使っているmlx-whisperは、Appleが開発したMLXという仕組み専用のソフトです。そのため、WindowsのパソコンやIntel製CPUのMacでは、残念ながら動きません。

    お使いのパソコンが対象かどうか、作業を始める前に必ず確認してください。確認方法は、画面左上のアップルメニューから「このMacについて」を開き、「チップ」の欄に「Apple M1」「M2」「M3」「M4」のいずれかが表示されていればOKです。「Intel」と表示されている場合は、今回の方法では作れません。

    用意するもの

    • Apple SiliconのMac(M1以降)←これが無いと始まりません

    • Claude Codeが使える状態(ターミナルで `claude` が動くこと)

    • インターネット環境(準備のときだけ使います)

    今回導入するソフトについては、費用は一切かかりません(Claude Code自体の利用料金は、お使いのプランによります)。かかる時間の目安は30分〜1時間ほどで、ダウンロード待ちの時間も含んでいます。

    部品は、たったの4つ

    ジャービスは、次の4つの部品で成り立っています。

    • 頭脳:質問に答える → Claude Code

    • 耳:声を文字にする → mlx-whisper

    • 口:文字を声にする → VOICEVOX

    • 顔:反応する画面 → ブラウザで開くファイル

    画像

    声を文字にする処理は、パソコンの中だけで完結します。録音した声が外部に送られることはありません。ただし、文字になった後の質問はClaude Codeを経由するので、その時点からはClaudeの利用規約が適用される点だけ、知っておいてください。

    STEP1:フォルダとファイルを用意する

    まず、好きな場所に`jarvis`という名前のフォルダを作ります。その中に、次の3つのファイルを同じ名前で保存してください。

    • `index.html`(画面と音声の処理)

    • `server.py`(聞き取りと返事の窓口)

    • `起動.command`(ダブルクリックで起動するためのファイル)

    テキストエディットを使う場合は、保存の前に「フォーマット」メニューから「標準テキストにする」を選んでおいてください。書式つきのまま保存すると、正しく動きません。

    ここまでできれば、もう半分終わったようなものです。

    画像

    STEP2:声を文字にするソフトを入れる

    画像
    pip3 install mlx-whisper

    最初に声を聞き取るタイミングで、文字起こし用のデータを自動で取得しにいきます。数分かかることがあるので、そのまま待っていてください。

    STEP3:声を出すソフトを入れる

    1. VOICEVOXの公式サイトから本体をダウンロードし、アプリケーションフォルダに入れます

    2. 初回起動時だけ「開発元が確認できません」という表示が出るので、アプリを右クリックして「開く」を選んでください

    3. そのまま起動したままにしておきます

    VOICEVOXを入れなくても、ジャービス自体は動きます(その場合はMac標準の声で読み上げます)。

    STEP4:起動して、話しかけてみる

    1. `起動.command`をダブルクリックします

    2. ブラウザが開き、マイクの使用許可を聞かれたら「許可」を押します

    3. スペースキーを押しながら話しかけて、話し終わったら離します

    「開発元が未確認」という表示が出た場合は、右クリックから「開く」を選んでください。スペースキーを押している間だけ録音される仕組みなので、これが一番確実な話しかけ方です。

    画面の輪が色を変えながら動き、少し間をおいて、声で返事が返ってきます。

    画像
    • 青くゆっくり漂う:待機中

    • 水色で脈打つ:聞き取り中

    • 紫寄りで速く動く:考え中

    • 緑:完了

    • 赤:エラー

    画像
    • スペース(押しながら):話す。離すと処理が始まります。一番確実な基本操作です

    • T:音がきちんと出るか試す

    • F:全画面表示

    • H:まわりの計器類を非表示にする(撮影用)

    • C:字幕の表示切替

    • N / B:読み上げるキャラクターを変更

    • S:同じキャラクターの中で口調(スタイル)を変更

    • , / .:声の高さを調整

    • [ / ]:マイクの感度調整。勝手に反応してしまう場合は`]`

    • V:自動で聞き取るかどうかの切替

    • R:会話の記憶をリセット

    • 1〜5:画面の状態を手動で切り替え

    つまずきやすいところ

    声が出ない場合
    画面を一度クリックしてから、もう一度試してみてください。ブラウザは、何も操作していない状態だと音を鳴らさないことがあります。Macの音量設定や、イヤホンに接続されていないかもあわせて確認してみてください。

    マイクが反応しない場合
    ファイルを直接ダブルクリックして開いてしまうと、ブラウザがマイクの使用を許可してくれません。必ず`起動.command`から開くようにしてください。

    「Address already in use」と表示される場合
    前回起動したサーバーが残っています。ターミナルで次のコマンドを実行してから、もう一度起動してみてください。

    lsof -ti:8123 | xargs kill

    読み上げがMac標準の声のままの場合
    VOICEVOXが起動していない状態です。VOICEVOXを先に開いてから、ジャービスを起動し直してください。

    返事が遅い場合
    最初の1回だけは、準備のために時間がかかります。2回目以降は速くなるので、安心してください。

    公開するときの注意

    VOICEVOXの音声を使った動画や作品を公開する際には、使用したキャラクター名の表記が必要です。たとえば「VOICEVOX:春日部つむぎ」のように記載します。

    キャラクターごとに条件が異なるので、使用するキャラクターが決まったら、公式サイトで利用規約を確認しておくことをおすすめします。

    まとめ:まずはフォルダとファイルから

    最初から全部やろうとしなくて大丈夫です。まずは、フォルダを作って、3つのファイルを保存するところまでやってみてください。

    そのあとソフトを2つ入れて、`起動.command`をダブルクリック。自分の声に、画面がきちんと反応した瞬間が、この製作の一番楽しいところだと思います。

    最後まで読んでいただき、ありがとうございました。もし「面白かった」「作ってみたい」と思っていただけましたら、記事下部のチップから応援していただけると、次の製作の励みになります。


    #AI自作 #ClaudeCode #VOICEVOX #音声アシスタント #ものづくり #DIY #もの好きと繋がりたい


    付録:ファイル一式

    STEP1で保存する3つのファイルです。3つとも、コピーしてそのまま保存すれば動きます。中身を読み解く必要はありません。

    コピーするときは、下のコード枠の中をドラッグして全部選択してからコピーしてください(枠内をクリックしてから `Command + A` でも全選択できます)。

    index.html(画面と音声の処理。これ1枚で完結します)

    <!DOCTYPE html>
    <html lang="ja">
    <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
    <title>JARVIS Visualizer</title>
    <style>
      :root{
        color-scheme: dark;
        --line: rgba(120, 200, 255, .22);
        --dim:  rgba(150, 205, 255, .45);
        --txt:  #cfeaff ;
        --key:  #7fe3ff ;
        --panel: rgba(2, 7, 16, .94);
        --pw-l: 206px;
        --pw-r: 290px;
        --ph-b: 92px;
      }
      *{ margin:0; padding:0; box-sizing:border-box; }
      html,body{ width:100%; height:100%; overflow:hidden; background:#000; }
      body{
        font-family:"SF Mono", Menlo, Consolas, "Hiragino Sans", monospace;
        color:var(--txt); font-size:11px; letter-spacing:.06em;
        -webkit-font-smoothing:antialiased; cursor:none;
      }
      body.showcursor{ cursor:default; }
      #gl { position:fixed; inset:0; width:100%; height:100%; display:block; }
      #chrome { position:fixed; inset:0; pointer-events:none; transition:opacity .45s ease; }
      #chrome .hidden{ opacity:0; }
      .panel{
        position:absolute; background:var(--panel);
        border:1px solid var(--line);
        backdrop-filter: blur(3px);
      }
      .ttl{
        font-size:9px; letter-spacing:.28em; color:var(--dim);
        padding:7px 10px; border-bottom:1px solid var(--line);
      }
      #top { top:0; left:0; right:0; height:34px; display:flex; align-items:center;
            gap:22px; padding:0 16px; border-left:0; border-right:0; border-top:0; }
      #top  b{ font-weight:600; letter-spacing:.42em; color:#eaf7ff; font-size:12px; }
      #top  .sep{ flex:1; }
      #top  .chip{ font-size:9px; letter-spacing:.20em; color:var(--dim); }
      #top  .chip s{ text-decoration:none; color:var(--key); }
      #left { top:34px; left:0; width:var(--pw-l); bottom:var(--ph-b); padding-bottom:6px;
             border-left:0; border-top:0; }
      .meters{ padding:10px 12px; display:flex; flex-direction:column; gap:11px; }
      .m .row{ display:flex; justify-content:space-between; font-size:9px;
               letter-spacing:.18em; color:var(--dim); margin-bottom:4px; }
      .m .row i{ font-style:normal; color:var(--txt); }
      .m .bar{ height:3px; background:rgba(255,255,255,.07); overflow:hidden; }
      .m .bar span{ display:block; height:100%; width:0%; background:var(--key);
                    box-shadow:0 0 8px var(--key); transition:width .07s linear; }
      #stateBox { margin:14px 12px 0; border-top:1px solid var(--line); padding-top:12px; }
      #stateBox  .lb{ font-size:9px; letter-spacing:.24em; color:var(--dim); }
      #stateName { font-size:17px; letter-spacing:.20em; margin-top:7px; color:var(--key);
                  text-shadow:0 0 18px currentColor; transition:color .5s ease; }
      #right { top:34px; right:0; width:var(--pw-r); bottom:var(--ph-b); border-right:0; border-top:0;
              display:flex; flex-direction:column; }
      #gauge { display:flex; align-items:center; gap:14px; padding:12px 14px;
              border-bottom:1px solid var(--line); }
      #gauge  svg{ width:58px; height:58px; flex:none; }
      #gauge  .num{ font-size:19px; color:#eaf7ff; }
      #gauge  .lb{ font-size:9px; letter-spacing:.24em; color:var(--dim); margin-top:3px; }
      #logTtl { font-size:9px; letter-spacing:.28em; color:var(--dim); padding:8px 14px 4px; }
      #log { flex:1; overflow:hidden; padding:0 14px 12px;
            font-size:9.5px; line-height:1.85; color:rgba(190,225,255,.62); }
      #log  div{ white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
      #log  .t{ color:rgba(140,190,235,.42); }
      #log  .k{ color:var(--key); }
      .br{ position:absolute; width:24px; height:24px; border:1px solid rgba(140,215,255,.50); }
      .br.tl{ top:calc(34px + 14px);        left:calc(var(--pw-l) + 14px);  border-right:0; border-bottom:0; }
      .br.tr{ top:calc(34px + 14px);        right:calc(var(--pw-r) + 14px); border-left:0;  border-bottom:0; }
      .br.bl{ bottom:calc(var(--ph-b) + 14px); left:calc(var(--pw-l) + 14px);  border-right:0; border-top:0; }
      .br.br2{ bottom:calc(var(--ph-b) + 14px); right:calc(var(--pw-r) + 14px); border-left:0; border-top:0; }
      #center -read{
        position:absolute; top:calc(34px + 18px);
        left:calc(var(--pw-l) + 14px); right:calc(var(--pw-r) + 14px);
        text-align:center; font-size:9px; letter-spacing:.26em;
        color:rgba(175,220,255,.62); text-shadow:0 0 10px #000 , 0 0 20px #000 ;
      }
      #convo {
        position:absolute; bottom:calc(var(--ph-b) + 44px);
        left:calc(var(--pw-l) + 40px); right:calc(var(--pw-r) + 40px);
        text-align:center; text-shadow:0 0 14px #000 , 0 0 30px #000 ;
      }
      #convo  span{ display:block; }
      #convo .hidden{ display:none; }
      #cvYou { font-size:12px; letter-spacing:.10em; color:rgba(200,232,255,.70); }
      #cvAi { font-size:15px; letter-spacing:.06em; color:var(--key); margin-top:8px; line-height:1.7; }
      @media (max-width: 1100px){
        :root{ --pw-l:160px; --pw-r:220px; }
      }
      @media (max-width: 820px){
        :root{ --pw-l:132px; --pw-r:180px; }
        #log { font-size:8.5px; }
      }
      #bottom { left:0; right:0; bottom:0; height:var(--ph-b); border-left:0; border-right:0; border-bottom:0;
               display:flex; flex-direction:column; }
      #wave { flex:1; min-height:0; width:100%; height:100%; display:block; }
      #keys { text-align:center; padding:6px 0 8px; font-size:9px;
             letter-spacing:.10em; color:rgba(180,220,255,.34); }
      #keys  kbd{ font:inherit; border:1px solid rgba(180,220,255,.24);
                 border-radius:3px; padding:1px 5px; margin:0 2px; }
      #fatal { position:fixed; inset:0; display:none; place-items:center;
              color:#ff8080; font-size:13px; text-align:center; padding:40px; line-height:2; }
    </style>
    </head>
    <body>
    <canvas id="gl"></canvas>
    
    <div id="chrome">
      <div class="panel" id="top">
        <b>J A R V I S</b>
        <span class="chip">MODE <s id="cMode">STANDBY</s></span>
        <span class="chip">MIC <s id="cMic">OFF</s></span>
        <span class="chip">VOICE <s id="cVoice">ON</s></span>
        <span class="chip">DEMO <s id="cDemo">OFF</s></span>
        <span class="sep"></span>
        <span class="chip" id="cClock">--:--:--</span>
      </div>
    
      <div class="panel" id="left">
        <div class="ttl">SYSTEM</div>
        <div class="meters" id="meters"></div>
        <div id="stateBox">
          <div class="lb">STATE</div>
          <div id="stateName">STANDBY</div>
        </div>
      </div>
    
      <div class="panel" id="right">
        <div id="gauge">
          <svg viewBox="0 0 100 100">
            <circle cx="50" cy="50" r="42" fill="none" stroke="rgba(255,255,255,.09)" stroke-width="6"/>
            <circle id="gArc" cx="50" cy="50" r="42" fill="none" stroke="#7fe3ff" stroke-width="6"
                    stroke-linecap="round" transform="rotate(-90 50 50)"
                    stroke-dasharray="264" stroke-dashoffset="264"/>
          </svg>
          <div>
            <div class="num"><span id="gNum">0.0</span>%</div>
            <div class="lb">SIGNAL</div>
          </div>
        </div>
        <div id="logTtl">OPERATION LOG</div>
        <div id="log"></div>
      </div>
    
      <div class="br tl"></div><div class="br tr"></div>
      <div class="br bl"></div><div class="br br2"></div>
      <div id="center-read">CORE REACTOR &nbsp;·&nbsp; VOICE INTERFACE ACTIVE</div>
      <div id="convo" class="hidden"><span id="cvYou"></span><span id="cvAi"></span></div>
    
      <div class="panel" id="bottom">
        <canvas id="wave"></canvas>
        <div id="keys">
          <kbd>Space</kbd>押しながら話す <kbd>V</kbd>自動聞き取り <kbd>[</kbd><kbd>]</kbd>感度
           <kbd>D</kbd>撮影モード <kbd>T</kbd>音を試す <kbd>C</kbd>字幕 <kbd>N</kbd><kbd>B</kbd>キャラクター <kbd>S</kbd>スタイル <kbd>,</kbd><kbd>.</kbd>高さ <kbd>R</kbd>記憶を消す
           <kbd>1</kbd>〜<kbd>5</kbd>状態 <kbd>M</kbd>マイク <kbd>F</kbd>全画面 <kbd>H</kbd>計器を消す
        </div>
      </div>
    </div>
    
    <div id="fatal"></div>
    
    <script>
    const canvas = document.getElementById('gl');
    const gl = canvas.getContext('webgl', { alpha:false, antialias:false, powerPreference:'high-performance' })
            || canvas.getContext('experimental-webgl');
    
    if (!gl) {
      const f = document.getElementById('fatal');
      f.style.display = 'grid';
      f.textContent = 'このブラウザでは WebGL が使えません。Chrome か Safari で開いてください。';
      throw new Error('no webgl');
    }
    
    const VERT = `
    attribute vec2 aPos;
    void main(){ gl_Position = vec4(aPos, 0.0, 1.0); }
    `;
    
    const FRAG = `
    precision highp float;
    
    uniform vec2  uRes;
    uniform float uTime;
    uniform float uLevel;
    uniform float uLow;
    uniform float uHigh;
    uniform float uSpeed;
    uniform float uGain;
    uniform vec3  uColA;
    uniform vec3  uColB;
    uniform vec3  uColC;
     #define  PI 3.14159265359
    
    float hash(float n){ return fract(sin(n) * 43758.5453123); }
    float hash2(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); }
    
    float noise(vec2 p){
      vec2 i = floor(p);
      vec2 f = fract(p);
      vec2 u = f * f * (3.0 - 2.0 * f);
      return mix(mix(hash2(i), hash2(i + vec2(1.0, 0.0)), u.x),
                 mix(hash2(i + vec2(0.0, 1.0)), hash2(i + vec2(1.0, 1.0)), u.x), u.y);
    }
    
    float fbm(vec2 p){
      float v = 0.0;
      float a = 0.5;
      mat2 m = mat2(1.6, 1.2, -1.2, 1.6);
      for (int i = 0; i < 4; i++){
        v += a * noise(p);
        p = m * p;
        a *= 0.5;
      }
      return v;
    }
    
    float ring(float r, float a, float R, float hw, float n, float duty, float rot, float px){
      float line = 1.0 - smoothstep(hw - px, hw + px, abs(r - R));
      if (n > 0.5){
        float e = px * n / (2.0 * PI * R) * 1.5;
        float s = fract((a + rot) / (2.0 * PI) * n);
        line *= smoothstep(0.0, e, s) * (1.0 - smoothstep(duty - e, duty, s));
      }
      return line;
    }
    
    vec3 starLayer(vec2 p, float cell, float density, float baseR, float tw, float seed){
      vec2 g = floor(p / cell);
      vec3 acc = vec3(0.0);
      for (int j = -1; j <= 1; j++){
        for (int i = -1; i <= 1; i++){
          vec2 c = g + vec2(float(i), float(j));
          float h = hash2(c + seed);
          if (h > density) continue;
          vec2 pos = (c + vec2(hash2(c * 1.7 + seed + 3.1), hash2(c * 2.3 + seed + 7.7))) * cell;
          vec2 d = p - pos;
          float dist = length(d);
          float mag = hash2(c * 3.9 + seed + 1.3);
          float rr = baseR * (0.6 + 0.9 * mag);
          float twk = 0.5 + 0.5 * sin(uTime * tw * (0.6 + mag) + h * 60.0);
          float core = 1.0 - smoothstep(rr - 0.6, rr + 0.6, dist);
          float spike = 0.0;
          if (mag > 0.70){
            float L = 6.0 + 22.0 * (mag - 0.70) / 0.30;
            float sx = (1.0 - smoothstep(0.4, 1.1, abs(d.y))) * (1.0 - smoothstep(0.0, L, abs(d.x)));
            float sy = (1.0 - smoothstep(0.4, 1.1, abs(d.x))) * (1.0 - smoothstep(0.0, L, abs(d.y)));
            spike = (sx + sy) * twk;
          }
          vec3 tint = mix(vec3(0.72, 0.86, 1.0), vec3(1.0, 0.90, 0.78), hash2(c * 5.1 + seed));
          acc += tint * (core * (0.45 + 0.55 * twk) + spike * 0.85);
        }
      }
      return acc;
    }
    
    void main(){
      vec2 uv = (gl_FragCoord.xy - 0.5 * uRes) / uRes.y;
      float rS = length(uv);
      const float S = 0.62;
      vec2 u = uv / S;
      float r = length(u);
      float a = atan(uv.y, uv.x);
      float t = uTime * uSpeed;
      float lvl = uLevel;
      float pxs = 1.0 / uRes.y;
      float px = pxs / S;
    
      float k = 1.0 + lvl * 0.14 + uLow * 0.05;
      float th = 1.0 + lvl * 0.35;
    
      vec3 col = vec3(0.0);
    
      vec2 fc = gl_FragCoord.xy;
      col += vec3(0.0, 0.004, 0.016) * (1.0 - fc.y / uRes.y);
      col += starLayer(fc + vec2(uTime * 1.5, 0.0),  36.0, 0.55, 0.9, 1.6, 11.0) * 0.55;
      col += starLayer(fc + vec2(uTime * 3.5, 0.0),  84.0, 0.50, 1.4, 2.1, 23.0) * 0.80;
      col += starLayer(fc + vec2(uTime * 7.0, 0.0), 190.0, 0.60, 2.1, 2.6, 41.0) * 1.00;
    
      float sp = fract(uTime / 7.0 + 0.3);
      vec2 head = vec2(uRes.x * 0.92, uRes.y * 0.88) + vec2(-1.0, -0.55) * uRes.x * 0.85 * sp;
      vec2 tailDir = normalize(vec2(1.0, 0.55));
      vec2 tail = head + tailDir * uRes.x * 0.10;
      vec2 ab = head - tail;
      float hh = clamp(dot(fc - tail, ab) / dot(ab, ab), 0.0, 1.0);
      float sd = length(fc - tail - ab * hh);
      float shoot = (1.0 - smoothstep(0.7, 1.7, sd)) * hh * (1.0 - smoothstep(0.35, 0.55, sp)) * smoothstep(0.0, 0.05, sp);
      col += vec3(0.85, 0.95, 1.0) * shoot;
    
      vec2 hq = uv * 2.6;
      float m1 = fbm(hq + vec2(t * 0.045, -t * 0.03));
      float m2 = fbm(hq * 1.8 + vec2(-t * 0.03, t * 0.05) + 7.3);
      float mist = smoothstep(0.22, 0.75, m1 * 0.65 + m2 * 0.55);
      float falloff = 1.0 - smoothstep(0.05, 0.95, rS);
      col += mix(uColA, uColB, 0.35) * mist * falloff * (0.55 + lvl * 0.40 + uLow * 0.08);
      col += uColA * (1.0 - smoothstep(0.0, 0.42, rS)) * 0.22;
    
      vec3 cIn  = uColB;
      vec3 cMid = uColB;
      vec3 cOut = uColA * 0.6 + uColB * 0.7;
    
      col += cOut * ring(r, a, 0.560 * k, px * 3.5 * th, 0.0, 1.0, 0.0, px) * 1.00;
      col += cOut * ring(r, a, 0.538 * k, px * 1.2,      0.0, 1.0, 0.0, px) * 0.55;
    
      col += cOut * ring(r, a, 0.470 * k, px * 6.0 * th, 2.0,  0.30,  t * 0.45, px) * 0.95;
      col += cOut * ring(r, a, 0.405 * k, px * 1.2,      0.0, 1.0, 0.0, px)        * 0.50;
      col += cOut * ring(r, a, 0.385 * k, px * 4.5,      120.0, 0.50, -t * 0.55, px) * 0.80;
      col += cMid * ring(r, a, 0.305 * k, px * 5.5 * th, 5.0,  0.38,  t * 0.80, px) * 1.00;
      col += cIn  * ring(r, a, 0.235 * k, px * 3.5,      48.0, 0.50, -t * 1.00, px) * 0.85;
      col += cMid * ring(r, a, 0.175 * k, px * 4.5 * th, 3.0,  0.62,  t * 1.60, px) * 1.00;
      col += cIn  * ring(r, a, 0.115 * k, px * 2.5,      0.0, 1.0, 0.0, px)        * 1.00;
      col += cIn  * ring(r, a, 0.075 * k, px * 1.2,      0.0, 1.0, 0.0, px)        * 0.70;
    
      float rd = (0.030 + lvl * 0.012) * k;
      col += cIn * (1.0 - smoothstep(rd - px, rd + px, r)) * 0.95;
      col += uColC * (1.0 - smoothstep(rd * 0.45 - px, rd * 0.45 + px, r));
    
      float N = 24.0;
      float f = abs(fract((a + t * 0.25) / (2.0 * PI) * N + 0.5) - 0.5);
      float perp = f * (2.0 * PI / N) * r;
      float spoke = (1.0 - smoothstep(px, px * 2.0, perp))
                  * smoothstep(0.12, 0.125, r) * (1.0 - smoothstep(0.300, 0.305, r));
      col += cMid * spoke * (0.30 + uHigh * 0.7);
    
      for (int i = 0; i < 3; i++){
        float ph = fract(t * 0.24 + float(i) / 3.0);
        float wr = 0.08 + ph * 0.50;
        float line = 1.0 - smoothstep(px, px * 2.2, abs(r - wr));
        col += mix(cMid, cOut, ph) * line * (1.0 - ph) * (0.35 + lvl * 1.1);
      }
    
      for (int i = 0; i < 24; i++){
        float fi = float(i);
        float spd = 0.30 + hash(fi * 3.1) * 0.9;
        float dir = mod(fi, 2.0) < 1.0 ? 1.0 : -1.0;
        float ang = t * spd * dir + fi * 2.3999;
        float rad = (0.13 + 0.45 * hash(fi * 7.7)) * k;
        vec2 pos = vec2(cos(ang), sin(ang)) * rad;
        float d = length(u - pos);
        float dotR = px * (2.0 + 1.5 * hash(fi * 1.3));
        col += uColC * (1.0 - smoothstep(dotR - px, dotR + px, d)) * 0.9;
      }
    
      col *= uGain;
      col *= 1.0 - 0.45 * smoothstep(0.62, 1.35, rS);
      col = min(col, vec3(1.0));
      col += (hash2(gl_FragCoord.xy + uTime) - 0.5) * 0.008;
    
      gl_FragColor = vec4(col, 1.0);
    }
    `;
    
    function compile(type, src){
      const s = gl.createShader(type);
      gl.shaderSource(s, src);
      gl.compileShader(s);
      if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
      return s;
    }
    
    const prog = gl.createProgram();
    gl.attachShader(prog, compile(gl.VERTEX_SHADER, VERT));
    gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, FRAG));
    gl.linkProgram(prog);
    if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog));
    gl.useProgram(prog);
    
    const buf = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, buf);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 3,-1, -1,3]), gl.STATIC_DRAW);
    const aPos = gl.getAttribLocation(prog, 'aPos');
    gl.enableVertexAttribArray(aPos);
    gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
    
    const U = {};
    ['uRes','uTime','uLevel','uLow','uHigh','uSpeed','uGain','uColA','uColB','uColC']
      .forEach(n => U[n] = gl.getUniformLocation(prog, n));
    
    function resizeGL(){
      const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
      const w = Math.floor(innerWidth * dpr), h = Math.floor(innerHeight * dpr);
      if (canvas.width !== w || canvas.height !== h){
        canvas.width = w; canvas.height = h;
        gl.viewport(0, 0, w, h);
      }
    }
    addEventListener('resize', resizeGL);
    resizeGL();
    
    const STATES = {
      idle:   { label:'STANDBY',  jp:'待機中',    speed:0.55, gain:0.95,
                a:[0.02,0.12,0.90], b:[0.10,0.45,1.00], c:[0.72,0.86,1.00], hud:'#7fe3ff' },
      listen: { label:'LISTENING', jp:'聞き取り中', speed:0.95, gain:1.12,
                a:[0.00,0.30,0.95], b:[0.20,0.75,1.00], c:[0.75,0.95,1.00], hud:'#7cf3ff' },
      think:  { label:'PROCESSING', jp:'思考中',   speed:1.55, gain:1.08,
                a:[0.34,0.06,0.95], b:[0.85,0.25,0.90], c:[0.92,0.78,1.00], hud:'#e39cff' },
      done:   { label:'COMPLETE', jp:'完了',       speed:0.70, gain:1.16,
                a:[0.02,0.45,0.32], b:[0.20,0.90,0.60], c:[0.78,1.00,0.90], hud:'#7dffc4' },
      error:  { label:'FAULT',    jp:'エラー',     speed:1.25, gain:1.10,
                a:[1.00,0.18,0.22], b:[1.00,0.62,0.40], c:[1.00,0.82,0.78], hud:'#ff9a9a' },
    };
    
    let stateKey = 'idle';
    const cur = { speed:STATES.idle.speed, gain:STATES.idle.gain,
                  a:STATES.idle.a.slice(), b:STATES.idle.b.slice(), c:STATES.idle.c.slice() };
    
    const elStateName = document.getElementById('stateName');
    const elMode = document.getElementById('cMode');
    
    function setState(key){
      if (!STATES[key] || key === stateKey) return;
      stateKey = key;
      const s = STATES[key];
      elStateName.textContent = s.label;
      elStateName.style.color = s.hud;
      elMode.textContent = s.label;
      document.documentElement.style.setProperty('--key', s.hud);
      addLog('state', s.label + ' / ' + s.jp);
    }
    
    const elLog = document.getElementById('log');
    const LOG_MAX = 40;
    
    function stamp(){
      const d = new Date();
      const p = n => String(n).padStart(2, '0');
      return p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds());
    }
    
    function addLog(kind, msg){
      const div = document.createElement('div');
      div.innerHTML = '<span class="t">[' + stamp() + ']</span> <span class="k">' + kind + '</span> ' + msg;
      elLog.appendChild(div);
      while (elLog.children.length > LOG_MAX) elLog.removeChild(elLog.firstChild);
      elLog.scrollTop = elLog.scrollHeight;
    }
    
    let analyser = null, timeBuf = null, freqBuf = null, audioCtx = null;
    let capNode = null, micStream = null, sampleRate = 16000;
    const elMicChip = document.getElementById('cMic');
    
    async function startMic(){
      try{
        micStream = await navigator.mediaDevices.getUserMedia({
          audio:{ echoCancellation:true, noiseSuppression:true, autoGainControl:false }
        });
        const AC = window.AudioContext || window.webkitAudioContext;
        try { audioCtx = new AC({ sampleRate: 16000 }); }
        catch(e){ audioCtx = new AC(); }
        sampleRate = audioCtx.sampleRate;
    
        const src = audioCtx.createMediaStreamSource(micStream);
        analyser = audioCtx.createAnalyser();
        analyser.fftSize = 2048;
        analyser.smoothingTimeConstant = 0.55;
        src.connect(analyser);
        timeBuf = new Uint8Array(analyser.fftSize);
        freqBuf = new Uint8Array(analyser.frequencyBinCount);
    
        capNode = audioCtx.createScriptProcessor(4096, 1, 1);
        const mute = audioCtx.createGain();
        mute.gain.value = 0;
        src.connect(capNode);
        capNode.connect(mute);
        mute.connect(audioCtx.destination);
        capNode.onaudioprocess = onAudio;
    
        elMicChip.textContent = 'LIVE';
        addLog('audio', 'microphone connected · ' + sampleRate + 'Hz');
        return true;
      }catch(e){
        analyser = null; capNode = null;
        elMicChip.textContent = 'SIM';
        addLog('audio', 'microphone unavailable — running simulation');
        return false;
      }
    }
    
    function stopMic(){
      if (capNode){ capNode.onaudioprocess = null; capNode.disconnect(); capNode = null; }
      if (micStream){ micStream.getTracks().forEach(t => t.stop()); micStream = null; }
      if (audioCtx){ audioCtx.close(); audioCtx = null; }
      analyser = null;
      capturing = false;
      elMicChip.textContent = 'SIM';
      addLog('audio', 'microphone released');
    }
    
    const elVoiceChip = document.getElementById('cVoice');
    const elCvYou = document.getElementById('cvYou');
    const elCvAi  = document.getElementById('cvAi');
    
    let voiceOn   = true;
    let capturing = false;
    let busy      = false;
    let speaking  = false;
    let pttDown   = false;
    let silenceMs = 0;
    let capBuf = [];
    let ring = [];
    const RING_MAX = 6;
    let threshold = 0.10;
    
    function onAudio(e){
      const copy = new Float32Array(e.inputBuffer.getChannelData(0));
      if (capturing) capBuf.push(copy);
      else { ring.push(copy); if (ring.length > RING_MAX) ring.shift(); }
    }
    
    function capSeconds(){
      let n = 0;
      for (const c of capBuf) n += c.length;
      return n / sampleRate;
    }
    
    function encodeWAV(chunks, sr){
      let n = 0;
      for (const c of chunks) n += c.length;
      const buf = new ArrayBuffer(44 + n * 2);
      const v = new DataView(buf);
      const ws = (o, s) => { for (let i = 0; i < s.length; i++) v.setUint8(o + i, s.charCodeAt(i)); };
      ws(0,'RIFF');  v.setUint32(4, 36 + n * 2, true);
      ws(8,'WAVE');  ws(12,'fmt ');
      v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true);
      v.setUint32(24, sr, true); v.setUint32(28, sr * 2, true);
      v.setUint16(32, 2, true);  v.setUint16(34, 16, true);
      ws(36,'data'); v.setUint32(40, n * 2, true);
      let o = 44;
      for (const c of chunks){
        for (let i = 0; i < c.length; i++, o += 2){
          const s = Math.max(-1, Math.min(1, c[i]));
          v.setInt16(o, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
        }
      }
      return new Blob([buf], { type:'audio/wav' });
    }
    
    let pttCapture = false;
    
    function startCapture(){
      capBuf = ring.slice();
      ring = [];
      capturing = true;
      pttCapture = pttDown;
      silenceMs = 0;
      setState('listen');
    }
    
    function endCapture(send){
      capturing = false;
      pttCapture = false;
      const chunks = capBuf;
      capBuf = [];
      let n = 0;
      for (const c of chunks) n += c.length;
      if (send && n / sampleRate >= 0.3) handleUtterance(encodeWAV(chunks, sampleRate));
      else if (!busy) setState('idle');
    }
    
    async function handleUtterance(blob){
      busy = true;
      setState('think');
      try{
        const r1 = await fetch('/stt', { method:'POST', headers:{'Content-Type':'audio/wav'}, body: blob });
        const d1 = await r1.json();
        if (d1.error) throw new Error(d1.error);
        const text = (d1.text || '').trim();
        if (!text){
          addLog('stt', '聞き取れませんでした');
          setState('idle');
          return;
        }
        elCvYou.textContent = '「' + text + '」';
        elCvAi.textContent = '';
        addLog('stt', text);
    
        const r2 = await fetch('/ask', {
          method:'POST', headers:{'Content-Type':'application/json'},
          body: JSON.stringify({ text })
        });
        if (!r2.ok || !r2.body) throw new Error('応答を受け取れませんでした');
    
        const reader = r2.body.getReader();
        const dec = new TextDecoder();
        let buf = '', full = '', spoken = 0, started = false;
    
        for (;;){
          const { value, done } = await reader.read();
          if (done) break;
          buf += dec.decode(value, { stream:true });
    
          let nl;
          while ((nl = buf.indexOf('\n')) >= 0){
            const line = buf.slice(0, nl).trim();
            buf = buf.slice(nl + 1);
            if (!line) continue;
    
            let ev;
            try { ev = JSON.parse(line); } catch(e){ continue; }
            if (ev.error) throw new Error(ev.error);
    
            if (ev.delta){
              full = (full + ev.delta).replace(/<[^>]*>?/g, '');
              if (spoken > full.length) spoken = full.length;
              elCvAi.textContent = full;
              const cut = speakableCut(full, spoken);
              if (cut > spoken){
                if (!started){ started = true; setState('done'); }
                speak(full.slice(spoken, cut));
                spoken = cut;
              }
            }
    
            if (ev.done){
              if (spoken < full.length) speak(full.slice(spoken));
              addLog('claude', full);
              if (pendingSpeech <= 0) setState('idle');
            }
          }
        }
      }catch(err){
        addLog('error', String(err && err.message || err));
        setState('error');
        setTimeout(() => { if (!busy) setState('idle'); }, 2500);
      }finally{
        busy = false;
      }
    }
    
    const VOICE = {
      name:  'Kyoko',
      pitch: 1.5,
      rate:  1.08,
    };
    
    let jaVoice = null;
    function pickVoice(){
      if (!('speechSynthesis' in window)) return null;
      const vs = speechSynthesis.getVoices();
      const ja = vs.filter(v => /ja[-_]JP/i.test(v.lang));
      jaVoice = (VOICE.name ? ja.find(v => v.name.toLowerCase().indexOf(VOICE.name.toLowerCase()) >= 0) : null)
             || ja[0] || null;
      return jaVoice;
    }
    
    function listVoices(){
      if (!('speechSynthesis' in window)) return;
      const ja = speechSynthesis.getVoices().filter(v => /ja[-_]JP/i.test(v.lang));
      if (ja.length) addLog('voice', ja.map(v => v.name).join(' / '));
    }
    
    if ('speechSynthesis' in window){
      pickVoice();
      speechSynthesis.onvoiceschanged = () => { pickVoice(); };
    }
    
    let pendingSpeech = 0;
    let useVoicevox = false;
    let vvPitch = 0.0;
    
    function finishSpeech(){
      pendingSpeech--;
      if (pendingSpeech <= 0){ pendingSpeech = 0; speaking = false; setState('idle'); }
    }
    
    let vvList = [];
    let vvChar = 0;
    let vvStyle = 0;
    
    function checkVoicevox(){
      return fetch('/voices').then(r => r.json()).then(d => {
        if (d.ok){
          useVoicevox = true;
          vvList = d.speakers || [];
          vvChar = Math.max(0, vvList.findIndex(s => s.name === d.speaker));
          const st = (vvList[vvChar] && vvList[vvChar].styles) || [];
          vvStyle = Math.max(0, st.indexOf(d.style));
          addLog('voice', 'VOICEVOX ' + d.speaker + '(' + d.style + ') · ' + vvList.length + '人');
        }else{
          useVoicevox = false;
          addLog('voice', 'VOICEVOX なし — Mac標準の声を使います');
          listVoices();
        }
      }).catch(() => { useVoicevox = false; });
    }
    
    function applySpeaker(){
      const s = vvList[vvChar];
      if (!s) return;
      const style = s.styles[vvStyle] || s.styles[0];
      fetch('/tts-config', {
        method:'POST', headers:{'Content-Type':'application/json'},
        body: JSON.stringify({ speaker: s.name, style: style })
      }).then(r => r.json()).then(d => {
        addLog('voice', (vvChar + 1) + '/' + vvList.length + '  ' + d.speaker + '(' + d.style + ')');
        speak('こんにちは。この声でどうですか。');
      }).catch(() => addLog('error', '声の変更に失敗しました'));
    }
    
    const ttsQueue = [];
    let ttsRunning = false;
    
    let playCtx = null;
    
    function ensurePlayCtx(){
      if (!playCtx){
        const AC = window.AudioContext || window.webkitAudioContext;
        if (!AC) return null;
        try { playCtx = new AC(); } catch(e){ return null; }
      }
      if (playCtx.state === 'suspended') playCtx.resume().catch(() => {});
      return playCtx;
    }
    
    function playViaTag(blob){
      return new Promise(resolve => {
        const url = URL.createObjectURL(blob);
        const a = new Audio(url);
        let settled = false;
        const done = (why) => {
          if (settled) return;
          settled = true;
          clearTimeout(timer);
          URL.revokeObjectURL(url);
          resolve(why);
        };
        const timer = setTimeout(() => done('timeout'), 60000);
        a.onended = () => done(null);
        a.onerror = () => done('audio error');
        a.play().catch(e => {
          done(e && e.name === 'NotAllowedError' ? 'blocked' : 'play error');
        });
      });
    }
    
    async function playBlob(blob){
      const ctx = ensurePlayCtx();
      if (ctx && ctx.state === 'running'){
        try{
          const buf = await ctx.decodeAudioData(await blob.arrayBuffer());
          return await new Promise(resolve => {
            let settled = false;
            const done = (why) => { if (!settled){ settled = true; clearTimeout(timer); resolve(why); } };
            const timer = setTimeout(() => done('timeout'), (buf.duration + 5) * 1000);
            const src = ctx.createBufferSource();
            src.buffer = buf;
            src.connect(ctx.destination);
            src.onended = () => done(null);
            src.start();
          });
        }catch(e){
        }
      }
      return playViaTag(blob);
    }
    
    async function pumpTts(){
      if (ttsRunning) return;
      ttsRunning = true;
      try{
        while (ttsQueue.length){
          const job = ttsQueue.shift();
          try{
            const blob = await job;
            const why = blob ? await playBlob(blob) : 'no audio';
            if (why === 'blocked'){
              addLog('error', 'ブラウザが音を止めています。画面を一度クリックしてください');
            }else if (why){
              addLog('error', '読み上げ再生: ' + why);
            }
          }catch(e){
            addLog('error', '読み上げの作成に失敗しました');
          }
          finishSpeech();
        }
      }finally{
        ttsRunning = false;
      }
    }
    
    function speak(text){
      if (!text.trim()) return;
    
      pendingSpeech++;
      speaking = true;
    
      if (useVoicevox){
        const ctl = new AbortController();
        const bail = setTimeout(() => ctl.abort(), 30000);
        const job = fetch('/tts', {
          method:'POST', headers:{'Content-Type':'application/json'},
          body: JSON.stringify({ text }), signal: ctl.signal
        }).then(r => {
          clearTimeout(bail);
          return r.ok ? r.blob() : Promise.reject(new Error('tts'));
        });
        ttsQueue.push(job);
        pumpTts();
        return;
      }
    
      if (!('speechSynthesis' in window)){ finishSpeech(); return; }
      const u = new SpeechSynthesisUtterance(text);
      const v = jaVoice || pickVoice();
      if (v) u.voice = v;
      u.lang  = 'ja-JP';
      u.rate  = VOICE.rate;
      u.pitch = VOICE.pitch;
      u.onend = finishSpeech;
      u.onerror = finishSpeech;
      speechSynthesis.speak(u);
    }
    
    function speakableCut(s, from){
      let end = -1;
      for (let i = from; i < s.length; i++){
        const c = s[i];
        if (c === '。' || c === '!' || c === '?' || c === '!' || c === '?') end = i;
      }
      if (end >= from) return end + 1;
    
      let comma = -1;
      for (let i = from; i < s.length; i++){
        if (s[i] === '、') comma = i;
      }
      if (comma >= from && comma - from >= 16) return comma + 1;
    
      return from;
    }
    
    function voiceTick(dt, level){
      if (speaking && pendingSpeech <= 0 && !ttsRunning && ttsQueue.length === 0){
        speaking = false;
      }
    
      if (!voiceOn || !analyser || busy || speaking){
        if (capturing) endCapture(false);
        return;
      }
      const active = pttDown || level > threshold;
    
      if (!capturing){
        if (active) startCapture();
        return;
      }
    
      if (active) silenceMs = 0; else silenceMs += dt;
    
      const finished = pttCapture ? !pttDown : (silenceMs > 800);
      if (finished){
        endCapture(true);
      }else if (capSeconds() > 20){
        addLog('stt', '20秒で打ち切りました');
        endCapture(true);
      }
    }
    
    function simulate(t){
      const phrase = Math.pow(Math.max(0, Math.sin(t * 0.5)), 2.0);
      const syll   = 0.5 + 0.5 * Math.sin(t * 11.0 + Math.sin(t * 3.1) * 2.0);
      const breath = 0.10 + 0.06 * Math.sin(t * 0.7);
      const level  = Math.min(1, breath + phrase * syll * 0.85);
      const bins = new Array(64);
      for (let i = 0; i < 64; i++){
        const fall = Math.pow(1 - i / 64, 1.4);
        bins[i] = Math.min(1, level * fall * (0.55 + 0.75 * Math.abs(Math.sin(t * (2 + i * 0.35) + i))));
      }
      return { level, low: Math.min(1, breath * 2 + phrase * 0.7),
               high: Math.min(1, level * 0.55), bins };
    }
    
    function readMic(){
      analyser.getByteTimeDomainData(timeBuf);
      let sum = 0;
      for (let i = 0; i < timeBuf.length; i++){
        const v = (timeBuf[i] - 128) / 128;
        sum += v * v;
      }
      const rms = Math.sqrt(sum / timeBuf.length);
    
      analyser.getByteFrequencyData(freqBuf);
      const band = (from, to) => {
        let s = 0;
        for (let i = from; i < to; i++) s += freqBuf[i];
        return s / ((to - from) * 255);
      };
      const bins = new Array(64);
      for (let i = 0; i < 64; i++){
        let s = 0;
        const a = Math.floor(Math.pow(i / 64, 1.7) * 400) + 2;
        const b = Math.floor(Math.pow((i + 1) / 64, 1.7) * 400) + 3;
        for (let j = a; j < b; j++) s += freqBuf[j] || 0;
        bins[i] = Math.min(1, (s / ((b - a) * 255)) * 1.7);
      }
      return { level: Math.min(1, rms * 6.0), low: Math.min(1, band(2,14) * 1.6),
               high: Math.min(1, band(60,220) * 2.4), bins };
    }
    
    const METERS = [
      { key:'level', name:'AUDIO IN' },
      { key:'low',   name:'LOW BAND' },
      { key:'high',  name:'HIGH BAND' },
      { key:'fps',   name:'RENDER FPS' },
      { key:'ms',    name:'FRAME MS' },
    ];
    const elMeters = document.getElementById('meters');
    const bars = {}, vals = {};
    METERS.forEach(m => {
      const d = document.createElement('div');
      d.className = 'm';
      d.innerHTML = '<div class="row"><span>' + m.name + '</span><i>0</i></div>'
                  + '<div class="bar"><span></span></div>';
      elMeters.appendChild(d);
      vals[m.key] = d.querySelector('i');
      bars[m.key] = d.querySelector('.bar span');
    });
    
    const elGArc = document.getElementById('gArc');
    const elGNum = document.getElementById('gNum');
    const elClock = document.getElementById('cClock');
    
    const wave = document.getElementById('wave');
    const wctx = wave.getContext('2d');
    function resizeWave(){
      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      const r = wave.getBoundingClientRect();
      wave.width = Math.max(1, Math.floor(r.width * dpr));
      wave.height = Math.max(1, Math.floor(r.height * dpr));
    }
    addEventListener('resize', resizeWave);
    
    function drawWave(bins, color){
      const w = wave.width, h = wave.height;
      wctx.clearRect(0, 0, w, h);
      const n = bins.length * 2;
      const bw = w / n;
      wctx.fillStyle = color;
      for (let i = 0; i < n; i++){
        const idx = i < n / 2 ? (n / 2 - 1 - i) : (i - n / 2);
        const v = bins[idx] || 0;
        const bh = Math.max(1, v * h * 0.92);
        wctx.globalAlpha = 0.25 + v * 0.75;
        wctx.fillRect(i * bw, (h - bh) / 2, Math.max(1, bw - 1), bh);
      }
      wctx.globalAlpha = 1;
    }
    
    const sm = { level:0, low:0, high:0 };
    const smBins = new Array(64).fill(0);
    function follow(prev, next){ return prev + (next - prev) * (next > prev ? 0.35 : 0.06); }
    function lerp(a,b,k){ return a + (b - a) * k; }
    function lerp3(d,s,k){ d[0]=lerp(d[0],s[0],k); d[1]=lerp(d[1],s[1],k); d[2]=lerp(d[2],s[2],k); }
    
    const t0 = performance.now();
    let lastT = t0, fps = 60, msAvg = 16;
    let peakCooldown = 0, heartbeat = 0;
    
    function frame(now){
      const t = (now - t0) / 1000;
      const dt = now - lastT; lastT = now;
      fps = lerp(fps, 1000 / Math.max(dt, 1), 0.06);
      msAvg = 1000 / Math.max(fps, 1);
    
      resizeGL();
      if (wave.width === 0) resizeWave();
    
      const raw = analyser ? readMic() : simulate(t);
      sm.level = follow(sm.level, raw.level);
      sm.low   = follow(sm.low,   raw.low);
      sm.high  = follow(sm.high,  raw.high);
      for (let i = 0; i < 64; i++) smBins[i] = follow(smBins[i], raw.bins[i] || 0);
    
      const s = STATES[stateKey];
      cur.speed = lerp(cur.speed, s.speed, 0.05);
      cur.gain  = lerp(cur.gain,  s.gain,  0.05);
      lerp3(cur.a, s.a, 0.05); lerp3(cur.b, s.b, 0.05); lerp3(cur.c, s.c, 0.05);
    
      voiceTick(dt, sm.level);
    
      const pulse = stateKey === 'think' ? 0.20 + 0.16 * Math.sin(t * 3.6) : 0;
      const level = Math.min(1, sm.level + pulse);
    
      gl.uniform2f(U.uRes, canvas.width, canvas.height);
      gl.uniform1f(U.uTime, t);
      gl.uniform1f(U.uLevel, level);
      gl.uniform1f(U.uLow, sm.low);
      gl.uniform1f(U.uHigh, sm.high);
      gl.uniform1f(U.uSpeed, cur.speed);
      gl.uniform1f(U.uGain, cur.gain);
      gl.uniform3fv(U.uColA, cur.a);
      gl.uniform3fv(U.uColB, cur.b);
      gl.uniform3fv(U.uColC, cur.c);
      gl.drawArrays(gl.TRIANGLES, 0, 3);
    
      const set = (k, v, txt) => {
        bars[k].style.width = (Math.min(1, v) * 100).toFixed(1) + '%';
        vals[k].textContent = txt;
      };
      set('level', sm.level, (sm.level * 100).toFixed(0));
      set('low',   sm.low,   (sm.low   * 100).toFixed(0));
      set('high',  sm.high,  (sm.high  * 100).toFixed(0));
      set('fps',   fps / 60, fps.toFixed(0));
      set('ms',    msAvg / 33, msAvg.toFixed(1));
    
      const pct = level * 100;
      elGArc.style.strokeDashoffset = (264 - 264 * Math.min(1, level)).toFixed(1);
      elGNum.textContent = pct.toFixed(1);
    
      drawWave(smBins, getComputedStyle(document.documentElement).getPropertyValue('--key').trim() || '#7fe3ff');
    
      elClock.textContent = stamp();
    
      peakCooldown -= dt; heartbeat -= dt;
      if (sm.level > 0.45 && peakCooldown <= 0){
        addLog('audio', 'peak ' + sm.level.toFixed(2) + ' — capturing');
        peakCooldown = 1400;
      }
      if (heartbeat <= 0){
        addLog('sys', 'idle · ' + fps.toFixed(0) + 'fps · listening');
        heartbeat = 6000;
      }
    
      requestAnimationFrame(frame);
    }
    requestAnimationFrame(frame);
    
    const elChrome = document.getElementById('chrome');
    const KEYMAP = { '1':'idle', '2':'listen', '3':'think', '4':'done', '5':'error' };
    
    addEventListener('keydown', (e) => {
      const k = e.key.toLowerCase();
      ensurePlayCtx();
    
      if (e.code === 'Space'){ e.preventDefault(); pttDown = true; return; }
    
      if (k === 'c'){
        const el = document.getElementById('convo');
        el.classList.toggle('hidden');
        addLog('sys', '字幕 ' + (el.classList.contains('hidden') ? 'OFF' : 'ON'));
        return;
      }
    
      if (KEYMAP[e.key]) { setState(KEYMAP[e.key]); return; }
      if (k === 'h'){ elChrome.classList.toggle('hidden'); document.body.classList.toggle('showcursor'); return; }
      if (k === 'f'){
        if (document.fullscreenElement) document.exitFullscreen();
        else document.documentElement.requestFullscreen();
        return;
      }
      if (k === 'm'){ analyser ? stopMic() : startMic(); return; }
      if (k === 'v'){
        voiceOn = !voiceOn;
        elVoiceChip.textContent = voiceOn ? 'ON' : 'OFF';
        addLog('sys', 'voice loop ' + (voiceOn ? 'enabled' : 'disabled'));
        return;
      }
      if (e.key === '[' || e.key === ']'){
        threshold = Math.min(0.6, Math.max(0.02, threshold + (e.key === ']' ? 0.02 : -0.02)));
        addLog('sys', 'threshold ' + threshold.toFixed(2));
        return;
      }
      if (k === 'd'){
        fetch('/demo', { method:'POST', headers:{'Content-Type':'application/json'}, body:'{}' })
          .then(r => r.json())
          .then(d => {
            document.getElementById('cDemo').textContent = d.on ? 'ON' : 'OFF';
            addLog('sys', '撮影モード ' + (d.on ? 'ON' : 'OFF') + ' · 台詞' + d.count + '件');
          })
          .catch(() => addLog('error', '撮影モードの切替に失敗しました'));
        return;
      }
      if (k === 't'){
        const ctx = ensurePlayCtx();
        addLog('voice', useVoicevox ? 'test · VOICEVOX' : 'test · Mac標準の声');
        addLog('voice', '再生装置 ' + (ctx ? ctx.state : 'なし')
                      + ' · pending=' + pendingSpeech + ' queue=' + ttsQueue.length);
        speak('テストです。この声が聞こえていますか。');
        return;
      }
      if (k === 'n' || k === 'b'){
        if (!useVoicevox || !vvList.length){
          addLog('voice', 'VOICEVOX が起動していません');
          return;
        }
        vvChar = (vvChar + (k === 'n' ? 1 : -1) + vvList.length) % vvList.length;
        vvStyle = 0;
        applySpeaker();
        return;
      }
      if (k === 's'){
        if (!useVoicevox || !vvList.length){
          addLog('voice', 'VOICEVOX が起動していません');
          return;
        }
        const st = vvList[vvChar].styles;
        vvStyle = (vvStyle + 1) % st.length;
        applySpeaker();
        return;
      }
      if (k === 'r'){
        addLog('sys', 'resetting session');
        fetch('/reset', { method:'POST' })
          .then(() => addLog('sys', 'session reset'))
          .catch(() => addLog('error', 'reset に失敗しました'));
        return;
      }
      if (e.key === ',' || e.key === '.'){
        if (useVoicevox){
          vvPitch = Math.min(0.15, Math.max(-0.15, vvPitch + (e.key === '.' ? 0.03 : -0.03)));
          fetch('/tts-config', {
            method:'POST', headers:{'Content-Type':'application/json'},
            body: JSON.stringify({ pitch: vvPitch })
          }).then(() => {
            addLog('voice', 'pitch ' + vvPitch.toFixed(2));
            speak('こんにちは、この高さでどうですか。');
          }).catch(() => addLog('error', 'pitch の変更に失敗しました'));
          return;
        }
        VOICE.pitch = Math.min(2.0, Math.max(0.0, VOICE.pitch + (e.key === '.' ? 0.1 : -0.1)));
        addLog('voice', 'pitch ' + VOICE.pitch.toFixed(1));
        speak('こんにちは、この高さでどうですか。');
        return;
      }
    });
    
    addEventListener('keyup', (e) => {
      if (e.code === 'Space') pttDown = false;
    });
    
    addEventListener('click', () => {
      ensurePlayCtx();
      if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
      if (!analyser) startMic();
    });
    
    if (new URLSearchParams(location.search).get('hud') === '0') elChrome.classList.add('hidden');
    
    resizeWave();
    addLog('sys', 'boot sequence complete');
    addLog('sys', 'renderer webgl · shader ok');
    startMic();
    checkVoicevox();
    
    fetch('/demo')
      .then(r => r.json())
      .then(d => {
        document.getElementById('cDemo').textContent = d.on ? 'ON' : 'OFF';
        if (d.on) addLog('sys', '撮影モード ON · 台詞' + d.count + '件');
      })
      .catch(() => {});
    
    fetch('/warmup', { method:'POST' })
      .then(() => addLog('sys', 'whisper warming up'))
      .catch(() => addLog('sys', 'server offline — 音声応答は使えません'));
    </script>
    </body>
    </html>

    server.py(配信・文字起こし・応答・読み上げ)

    #!/usr/bin/env python3
    """JARVIS Visualizer のローカルサーバー。
    
    役割は3つ。
    
      1. index.html を配信する
      2. POST /stt  … ブラウザが録った音声(WAV)を Whisper で文字にする(完全オフライン)
      3. POST /ask  … その文字を Claude Code に渡して、短い返事をもらう
    
    マイクは https か localhost でしか使えないため、ファイルを直接開くのではなく
    このサーバー経由(http://localhost:8123)で開く必要がある。
    
    `python3 -m http.server` は起動時に os.getcwd() を呼ぶ実装になっていて、
    環境によっては PermissionError で落ちるため、自前で用意している。
    """
    
    import io
    import json
    import os
    import shutil
    import subprocess
    import sys
    import threading
    import urllib.error
    import urllib.parse
    import urllib.request
    import wave
    from functools import partial
    from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
    
    ROOT = os.path.dirname(os.path.abspath(os.path.realpath(__file__)))
    PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8123
    
    # 文字起こしに使うモデル。turbo は精度と速さのつり合いが良い。
    WHISPER_MODEL = "mlx-community/whisper-large-v3-turbo"
    
    # Claude を動かす作業フォルダ。
    # プロジェクト全体のルールを読ませたいときは、ここをプロジェクトのルートに変える。
    CLAUDE_CWD = ROOT
    
    # 読み上げられる前提の返事にするための指示。
    VOICE_SYSTEM_PROMPT = (
        "あなたは音声アシスタントです。返事はそのまま読み上げられます。"
        "日本語で、2文以内・120文字以内で答えてください。"
        "箇条書き、記号、絵文字、コード、URL、タグは使わないでください。"
        "聞き取れなかった場合は、聞き返してください。"
        "あなたは道具を一切持っていません。ファイルの読み書き、コマンドの実行、"
        "検索、外部サービスの操作はできません。"
        "それらを頼まれたときは、実行しようとせず、"
        "声からはできないと短く伝えてください。"
    )
    
    MAX_BODY = 12 * 1024 * 1024  # 12MB
    ASK_TIMEOUT = 120            # 秒
    
    # ---- VOICEVOX(読み上げ)----
    # VOICEVOX アプリを起動しておくと、この窓口が開く。
    # 起動していなければ自動で Mac 標準の声に切り替わる。
    VOICEVOX_URL = "http://127.0.0.1:50021"
    VOICEVOX_SPEAKER = "春日部つむぎ"
    VOICEVOX_STYLE = "ノーマル"
    VOICEVOX_TIMEOUT = 25
    
    # 速さと高さは画面の「,」「.」からも変えられるので、書き換え可能にしておく
    VOICEVOX_CFG = {
        "speed": 1.10,   # 速さ
        "pitch": 0.0,    # 高さ -0.15〜0.15 くらい
    }
    
    _mlx_lock = threading.Lock()   # MLX は同時実行させない
    _whisper = None
    
    
    def _load_whisper():
        global _whisper
        if _whisper is None:
            import mlx_whisper
            _whisper = mlx_whisper
        return _whisper
    
    
    def _claude_bin():
        return shutil.which("claude") or os.path.expanduser("~/.local/bin/claude")
    
    
    def wav_to_mono16k(raw):
        """WAV のバイト列を 16kHz モノラルの float32 配列にする。"""
        import numpy as np
    
        with wave.open(io.BytesIO(raw), "rb") as w:
            ch = w.getnchannels()
            sr = w.getframerate()
            width = w.getsampwidth()
            frames = w.readframes(w.getnframes())
    
        if width != 2:
            raise ValueError("16bit の WAV だけ対応しています")
    
        pcm = np.frombuffer(frames, dtype=np.int16).astype(np.float32) / 32768.0
        if ch > 1:
            pcm = pcm.reshape(-1, ch).mean(axis=1)
    
        if sr != 16000 and len(pcm) > 1:
            n = int(round(len(pcm) * 16000 / sr))
            pcm = np.interp(
                np.linspace(0, len(pcm) - 1, n, dtype=np.float64),
                np.arange(len(pcm), dtype=np.float64),
                pcm,
            ).astype(np.float32)
    
        return pcm
    
    
    def transcribe(raw):
        pcm = wav_to_mono16k(raw)
        seconds = len(pcm) / 16000.0
        if seconds < 0.35:
            return "", seconds
    
        mlx_whisper = _load_whisper()
        with _mlx_lock:
            r = mlx_whisper.transcribe(
                pcm,
                path_or_hf_repo=WHISPER_MODEL,
                language="ja",
                fp16=True,
                condition_on_previous_text=False,
            )
        return (r.get("text") or "").strip(), seconds
    
    
    MAX_TURNS = 20   # これを超えたら会話をたたみ直す(文脈が伸びすぎるのを防ぐ)
    
    # 声だけでファイル操作やコマンド実行が走らないよう、道具を全部外す。
    #
    # 注意:`--allowed-tools ""` では止まらない。あれは「確認なしで使ってよい道具」の
    # 指定であって、道具そのものを外すものではない。実際に空にして試したところ、
    # 「ファイルを作って」と言うだけでファイルが作られてしまった。
    # 外すには --disallowed-tools に名前を並べる必要がある(外れたことは実測で確認済み)。
    BLOCKED_TOOLS = [
        "Task", "Artifact", "Bash", "CronCreate", "CronDelete", "CronList",
        "DesignSync", "Edit", "EnterWorktree", "ExitWorktree", "Glob", "Grep",
        "ListAgents", "Monitor", "NotebookEdit", "PushNotification", "Read",
        "RemoteTrigger", "ReportFindings", "ScheduleWakeup", "SendMessage",
        "Skill", "TaskCreate", "TaskGet", "TaskList", "TaskOutput", "TaskStop",
        "TaskUpdate", "ToolSearch", "WebFetch", "WebSearch", "Workflow", "Write",
    ]
    
    
    # ---- 撮影モード ----
    # 決めておいた問いかけに、決めておいた台詞を即座に返す。
    # 撮影では「毎回同じ内容」「待ち時間ゼロ」が要るため。
    LINES_PATH = os.path.join(ROOT, "lines.json")
    _lines = {
        "mtime": 0.0,
        "data": {"enabled": False, "lines": []},
    }
    DEMO_ON = False
    
    
    def load_lines():
        """台詞ファイルを読む。保存されていれば読み直す(再起動不要)。"""
        try:
            mtime = os.stat(LINES_PATH).st_mtime
        except OSError:
            return {"enabled": False, "lines": []}
    
        if mtime != _lines["mtime"]:
            try:
                with open(LINES_PATH, encoding="utf-8") as f:
                    _lines["data"] = json.load(f)
                _lines["mtime"] = mtime
                n = len(_lines["data"].get("lines") or [])
                sys.stderr.write("撮影用の台詞を読み込みました(%d件)\n" % n)
            except Exception as e:
                sys.stderr.write("lines.json が読めません: %r\n" % (e,))
        return _lines["data"]
    
    
    def match_line(text):
        """問いかけに合う決め台詞があれば返す。無ければ None。"""
        if not DEMO_ON:
            return None
        t = text.replace(" ", "").replace(" ", "")
        for item in load_lines().get("lines") or []:
            kws = item.get("keywords") or []
            if kws and all(k in t for k in kws):
                return item.get("reply") or ""
        return None
    
    
    _speaker_cache = {"id": None, "listed": None}
    
    
    def _vv(path, data=None, timeout=VOICEVOX_TIMEOUT):
        """VOICEVOX の窓口を叩く。data があれば POST。"""
        req = urllib.request.Request(
            VOICEVOX_URL + path,
            data=data,
            headers={"Content-Type": "application/json"} if data else {},
            method="POST" if data is not None else "GET",
        )
        return urllib.request.urlopen(req, timeout=timeout).read()
    
    
    def voicevox_speakers():
        """使える声の一覧。VOICEVOX が起動していなければ例外。"""
        raw = json.loads(_vv("/speakers", timeout=4).decode("utf-8"))
        return [{"name": s["name"], "styles": [st["name"] for st in s["styles"]]}
                for s in raw]
    
    
    def _speaker_id():
        """名前とスタイルから話者番号を引く。
        番号は版によって変わるので、直書きせず毎回名前で引き直す。"""
        global VOICEVOX_STYLE
    
        if _speaker_cache["id"] is not None:
            return _speaker_cache["id"]
    
        raw = json.loads(_vv("/speakers", timeout=6).decode("utf-8"))
        found = None
        for s in raw:
            if s["name"] != VOICEVOX_SPEAKER:
                continue
            for st in s["styles"]:
                if st["name"] == VOICEVOX_STYLE:
                    found = st["id"]
                    break
            if found is None and s["styles"]:
                # スタイル名が合わなければ最初のものにし、実際に選んだ名前へ揃える
                found = s["styles"][0]["id"]
                VOICEVOX_STYLE = s["styles"][0]["name"]
            break
    
        if found is None:
            names = " / ".join(s["name"] for s in raw)
            raise RuntimeError("「%s」が見つかりません。使えるのは %s" % (VOICEVOX_SPEAKER, names))
    
        _speaker_cache["id"] = found
        return found
    
    
    def set_speaker(name, style):
        """声を切り替える。実在しなければ元に戻して知らせる。"""
        global VOICEVOX_SPEAKER, VOICEVOX_STYLE
    
        before = (VOICEVOX_SPEAKER, VOICEVOX_STYLE)
        VOICEVOX_SPEAKER = name or VOICEVOX_SPEAKER
        VOICEVOX_STYLE = style or VOICEVOX_STYLE
        _speaker_cache["id"] = None
        try:
            _speaker_id()
        except Exception:
            VOICEVOX_SPEAKER, VOICEVOX_STYLE = before
            _speaker_cache["id"] = None
            raise
        return VOICEVOX_SPEAKER, VOICEVOX_STYLE
    
    
    def voicevox_wav(text):
        """文章を読み上げた WAV を作って返す。"""
        sid = _speaker_id()
    
        q = _vv("/audio_query?" + urllib.parse.urlencode({"text": text, "speaker": sid}),
                data=b"")
        query = json.loads(q.decode("utf-8"))
        query["speedScale"] = VOICEVOX_CFG["speed"]
        query["pitchScale"] = VOICEVOX_CFG["pitch"]
    
        return _vv("/synthesis?" + urllib.parse.urlencode({"speaker": sid}),
                   data=json.dumps(query).encode("utf-8"))
    
    
    class ClaudeSession:
        """Claude Code を起動しっぱなしにして、質問のたびに使い回す。
    
        質問ごとに `claude -p` を起動し直すと、起動だけで毎回およそ6秒かかる。
        起動したまま標準入力で質問を流し込むと、その6秒がまるごと消える
        (実測:最初の文字が届くまで 6.1秒 → 1〜2.7秒)。
    
        ついでに会話の流れも保たれるので、「さっきの件だけど」が通じる。
        """
    
        def __init__(self):
            self.p = None
            self.lock = threading.Lock()
            self.turns = 0
    
        def _alive(self):
            return self.p is not None and self.p.poll() is None
    
        def start(self):
            self.stop()
            cmd = [
                _claude_bin(), "-p",
                # ユーザー設定(~/.claude/settings.json)を読ませない。
                # そこに登録されたフックが、応答が終わるたびに通知音を鳴らすため。
                # ターミナルで使う Claude Code 側の設定はそのまま。ここだけ外している。
                "--setting-sources", "project",
                "--disallowed-tools"] + BLOCKED_TOOLS + [
                # MCP は読み込ませない。ツールを使わせないので不要なうえ、
                # 読み込むだけで約5秒かかる(実測 11.3秒 → 6.5秒)
                "--strict-mcp-config",
                "--mcp-config", '{"mcpServers": {} }',
                # 質問を流し込み続け、返事も書けた端から受け取る
                "--input-format", "stream-json",
                "--output-format", "stream-json",
                "--include-partial-messages",
                "--verbose",
                "--append-system-prompt", VOICE_SYSTEM_PROMPT,
            ]
            self.p = subprocess.Popen(
                cmd, cwd=CLAUDE_CWD,
                stdin=subprocess.PIPE, stdout=subprocess.PIPE,
                stderr=subprocess.DEVNULL,   # 警告は混ぜない
            )
            self.turns = 0
            sys.stderr.write("claude セッション開始\n")
    
        def stop(self):
            if self.p is not None:
                try:
                    self.p.stdin.close()
                except Exception:
                    pass
                try:
                    self.p.terminate()
                    self.p.wait(timeout=3)
                except Exception:
                    try:
                        self.p.kill()
                    except Exception:
                        pass
            self.p = None
    
        def _turn(self, text):
            msg = {
                "type": "user",
                "message": {
                    "role": "user",
                    "content": [{"type": "text", "text": text}],
                },
            }
            self.p.stdin.write((json.dumps(msg, ensure_ascii=False) + "\n").encode("utf-8"))
            self.p.stdin.flush()
    
            got_any = False
            for raw in self.p.stdout:
                line = raw.decode("utf-8", "replace").strip()
                if not line:
                    continue
                try:
                    ev = json.loads(line)
                except ValueError:
                    continue
    
                if ev.get("type") == "stream_event":
                    e = ev.get("event") or {}
                    if e.get("type") == "content_block_delta":
                        d = e.get("delta") or {}
                        if d.get("type") == "text_delta" and d.get("text"):
                            got_any = True
                            yield d["text"]
                elif ev.get("type") == "result":
                    if ev.get("is_error") and not got_any:
                        raise RuntimeError(str(ev.get("result"))[:300] or "claude エラー")
                    return
            # ここに来たのはプロセスが落ちたということ
            raise RuntimeError("claude セッションが終了しました")
    
        def ask(self, text):
            with self.lock:
                if not self._alive() or self.turns >= MAX_TURNS:
                    self.start()
                self.turns += 1
                try:
                    for chunk in self._turn(text):
                        yield chunk
                    return
                except Exception as first_err:
                    sys.stderr.write("claude 再起動して再試行: %r\n" % (first_err,))
    
                # 一度だけ立ち上げ直して再試行する
                self.start()
                self.turns = 1
                for chunk in self._turn(text):
                    yield chunk
    
    
    claude_session = ClaudeSession()
    
    
    def stream_claude(text):
        return claude_session.ask(text)
    
    
    class Handler(SimpleHTTPRequestHandler):
        protocol_version = "HTTP/1.1"
    
        # ---- 共通 ----
    
        def end_headers(self):
            self.send_header("Cache-Control", "no-store, must-revalidate")
            super().end_headers()
    
        def log_message(self, fmt, *args):
            if self.path in ("/stt", "/ask", "/warmup"):
                sys.stderr.write("%s\n" % (fmt % args))
    
        def _json(self, code, obj):
            body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
            self.send_response(code)
            self.send_header("Content-Type", "application/json; charset=utf-8")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)
    
        def _body(self):
            n = int(self.headers.get("Content-Length") or 0)
            if n <= 0:
                return b""
            if n > MAX_BODY:
                raise ValueError("送信されたデータが大きすぎます")
            return self.rfile.read(n)
    
        def _line(self, obj):
            """1行1件のJSONを、その場で送り出す。"""
            self.wfile.write((json.dumps(obj, ensure_ascii=False) + "\n").encode("utf-8"))
            self.wfile.flush()
    
        def _do_ask(self):
            """返事を少しずつ流す。ここでは例外を外に投げない。
            すでにヘッダを送ってしまっているため、後からエラー応答に切り替えられない。"""
            try:
                data = json.loads(self._body().decode("utf-8") or "{}")
                text = (data.get("text") or "").strip()
            except Exception as e:
                self._json(400, {"error": str(e)[:200]})
                return
    
            if not text:
                self._json(400, {"error": "text がありません"})
                return
    
            self.send_response(200)
            self.send_header("Content-Type", "application/x-ndjson; charset=utf-8")
            self.send_header("Connection", "close")
            self.end_headers()
            self.close_connection = True
    
            # 撮影モードで当てはまる台詞があれば、Claude を呼ばずに即返す
            canned = match_line(text)
            if canned:
                sys.stderr.write("決め台詞: %s\n" % canned[:40])
                try:
                    self._line({"delta": canned})
                    self._line({"done": True, "reply": canned, "canned": True})
                except Exception:
                    pass
                return
    
            parts = []
            try:
                for chunk in stream_claude(text):
                    parts.append(chunk)
                    self._line({"delta": chunk})
                self._line({"done": True, "reply": "".join(parts)})
            except Exception as e:
                sys.stderr.write("ask error: %r\n" % (e,))
                try:
                    self._line({"error": str(e)[:300]})
                except Exception:
                    pass
    
        # ---- ルーティング ----
    
        def do_GET(self):
            if self.path == "/demo":
                # 画面側が起動時の状態を知るための窓口(切り替えはしない)
                d = load_lines()
                self._json(200, {"on": DEMO_ON, "count": len(d.get("lines") or [])})
                return
    
            if self.path == "/voices":
                # VOICEVOX が使えるかどうかを、ブラウザ側が判断するための窓口。
                # 起動していない場合も 200 で ok:false を返し、Mac 標準の声に切り替えさせる
                try:
                    self._json(200, {"ok": True,
                                     "speaker": VOICEVOX_SPEAKER,
                                     "style": VOICEVOX_STYLE,
                                     "speakers": voicevox_speakers()})
                except Exception as e:
                    self._json(200, {"ok": False, "error": str(e)[:200]})
                return
            super().do_GET()
    
        def do_POST(self):
            try:
                if self.path == "/stt":
                    text, sec = transcribe(self._body())
                    self._json(200, {"text": text, "seconds": round(sec, 2)})
    
                elif self.path == "/ask":
                    self._do_ask()
    
                elif self.path == "/warmup":
                    threading.Thread(target=self._warm, daemon=True).start()
                    self._json(200, {"ok": True})
    
                elif self.path == "/reset":
                    claude_session.start()
                    self._json(200, {"ok": True})
    
                elif self.path == "/demo":
                    global DEMO_ON
                    data = json.loads(self._body().decode("utf-8") or "{}")
                    DEMO_ON = bool(data.get("on")) if "on" in data else not DEMO_ON
                    d = load_lines()
                    self._json(200, {"on": DEMO_ON, "count": len(d.get("lines") or [])})
    
                elif self.path == "/tts-config":
                    data = json.loads(self._body().decode("utf-8") or "{}")
                    if "pitch" in data:
                        VOICEVOX_CFG["pitch"] = max(-0.15, min(0.15, float(data["pitch"])))
                    if "speed" in data:
                        VOICEVOX_CFG["speed"] = max(0.5, min(2.0, float(data["speed"])))
                    if data.get("speaker") or data.get("style"):
                        set_speaker(data.get("speaker"), data.get("style"))
                    out = dict(VOICEVOX_CFG)
                    out["speaker"] = VOICEVOX_SPEAKER
                    out["style"] = VOICEVOX_STYLE
                    self._json(200, out)
    
                elif self.path == "/tts":
                    data = json.loads(self._body().decode("utf-8") or "{}")
                    text = (data.get("text") or "").strip()
                    if not text:
                        self._json(400, {"error": "text がありません"})
                        return
                    wav = voicevox_wav(text)
                    self.send_response(200)
                    self.send_header("Content-Type", "audio/wav")
                    self.send_header("Content-Length", str(len(wav)))
                    self.end_headers()
                    self.wfile.write(wav)
    
                else:
                    self._json(404, {"error": "not found"})
    
            except subprocess.TimeoutExpired:
                self._json(504, {"error": "Claude の応答が %d 秒を超えました" % ASK_TIMEOUT})
            except Exception as e:
                sys.stderr.write("error %s: %r\n" % (self.path, e))
                self._json(500, {"error": str(e)[:300]})
    
        def _warm(self):
            """初回を速くするための下準備。
            文字起こしのモデル読み込みと、Claude の立ち上げを先に済ませておく。"""
            try:
                claude_session.start()
            except Exception as e:
                sys.stderr.write("claude 準備に失敗: %r\n" % (e,))
            try:
                import numpy as np
                mlx_whisper = _load_whisper()
                with _mlx_lock:
                    mlx_whisper.transcribe(
                        np.zeros(16000, dtype=np.float32),
                        path_or_hf_repo=WHISPER_MODEL, language="ja", fp16=True,
                    )
                sys.stderr.write("whisper 準備完了\n")
            except Exception as e:
                sys.stderr.write("whisper 準備に失敗: %r\n" % (e,))
    
    
    def main():
        global DEMO_ON
        os.chdir(ROOT)
        DEMO_ON = bool(load_lines().get("enabled"))
        handler = partial(Handler, directory=ROOT)
        with ThreadingHTTPServer(("127.0.0.1", PORT), handler) as httpd:
            print("JARVIS Visualizer: http://localhost:%d" % PORT, flush=True)
            print("文字起こし: %s(オフライン)" % WHISPER_MODEL, flush=True)
            print("応答: %s" % _claude_bin(), flush=True)
            try:
                voicevox_speakers()
                print("読み上げ: VOICEVOX %s(%s)" % (VOICEVOX_SPEAKER, VOICEVOX_STYLE), flush=True)
            except Exception:
                print("読み上げ: Mac標準の声(VOICEVOXが起動していません)", flush=True)
            print("撮影モード: %s(台詞 %d件・画面の D で切替)"
                  % ("入" if DEMO_ON else "切", len(load_lines().get("lines") or [])),
                  flush=True)
            httpd.serve_forever()
    
    
    if __name__ == "__main__":
        main()

    起動.command(ダブルクリックで起動する用)

    #!/bin/bash
    # JARVIS Visualizer を起動して、ブラウザで開く。
    # 終了するときは、このウィンドウで Control + C を押す。
    
    cd "$(dirname "$0")" || exit 1
    
    echo "JARVIS Visualizer を起動します..."
    python3 server.py 8123 &
    SERVER_PID=$!
    
    sleep 1
    open "http://localhost:8123/"
    
    echo ""
    echo "起動しました。ブラウザで開いています。"
    echo "マイクの許可を聞かれたら「許可」を押してください。"
    echo ""
    echo "  Space を押しながら話す … 確実に聞き取らせる"
    echo "  そのまま話しかけても反応します(V で自動聞き取りを切り替え)"
    echo "  F … 全画面    H … 計器を消す    M … マイク接続/切断"
    echo "  [ ] … 聞き取りの感度    1〜5 … 状態を手で切り替え"
    echo "  N B … 読み上げるキャラクターを変える    S … スタイル    , . … 声の高さ"
    echo ""
    echo "終了するには、このウィンドウで Control + C を押してください。"
    
    # Control+C だけでなく、ウィンドウを閉じた場合(HUP)も止める。
    # これが無いとサーバーが残り、次の起動でポートが埋まって失敗する
    trap 'kill $SERVER_PID 2>/dev/null; exit 0' INT TERM HUP
    wait $SERVER_PID
     
     

    shige

     
     
    モットーは「広く、深く、どっぷりと、マニアックに」。好きな言葉は“Power to make your dream come true”。Mono2qry(モノつくり)は、ゼロから1を創造する楽しさを込めた語呂合わせ。ゲーム機をコンパクトにまとめ、すぐ遊べる機器を作っています。

    あなたへのおすすめ