본문 바로가기

AI 바이브 코딩

'자비스'라고 부르면 초록 링이 빛난다 — Canvas 2D와 Web Speech만으로 만드는 AI 음성비서

"자비스"라고 부르면 초록 링이 빛난다 — Canvas 2D와 Web Speech만으로 만드는 AI 음성비서

인스타 릴스에서 초록색 발광 오브가 빙글빙글 돌면서 AI 답변을 스트리밍하는 영상 보셨나요?
그걸 브라우저 HTML 파일 하나로 직접 만들어봤습니다.
음성으로 AI 비서에게 물으면 — 토러스가 파동치고, 글자가 흘러나오고, Yuna 목소리로 답합니다.

완성된 모습

  • 초록 발광 토러스가 내 목소리에 맞춰 맥동·회전
  • 내가 말하는 중 실시간 자막(interim) 즉시 표시
  • AI 비서 답변이 글자 단위로 흘러나오며 동시에 음성 출력
  • IDLE / LISTENING / THINKING / SPEAKING 상태 실시간 표시
  • 스페이스바 한 번 = 푸시투토크, 🎙버튼 = 상시 대기 모드

필요한 것

  • macOS (Yuna 보이스 사용 — 다른 OS는 시스템 TTS로 대체 가능)
  • Python 3 (로컬 서버용, 별도 패키지 불필요)
  • Chrome 브라우저
  • AI 비서 백엔드 (OpenClaw + Claude 세션 — 또는 직접 API 호출로 대체 가능)

파일 구조

jarvis-dashboard/
├── orchestrator.html   ← 오케스트레이션 HUD 본체
├── server.py           ← 로컬 백엔드 (/ask 엔드포인트)
└── launch.sh           ← 실행 스크립트

1단계: 로컬 서버 만들기 (server.py)

브라우저에서 마이크를 쓰려면 localhost HTTP 서버가 필요합니다.
file://로 열면 마이크 권한이 막힙니다.

from http.server import HTTPServer, SimpleHTTPRequestHandler
import json

class Handler(SimpleHTTPRequestHandler):
    def do_POST(self):
        if self.path != '/ask':
            self.send_error(404)
            return
        length = int(self.headers['Content-Length'])
        body = json.loads(self.rfile.read(length))
        text = body.get('text', '').strip()
        if not text:
            self._json({'reply': '(빈 질문)'})
            return
        reply = call_ai(text)
        self._json({'reply': reply})

    def _json(self, obj):
        data = json.dumps(obj, ensure_ascii=False).encode()
        self.send_response(200)
        self.send_header('Content-Type', 'application/json; charset=utf-8')
        self.send_header('Access-Control-Allow-Origin', '*')
        self.end_headers()
        self.wfile.write(data)

    def log_message(self, *_): pass

def call_ai(text):
    # 여기를 본인 AI 호출 코드로 교체하세요
    # 예: return openai_chat(text)
    return f"'{text}' 에 대한 AI 답변입니다."

HTTPServer(('127.0.0.1', 8777), Handler).serve_forever()

2단계: 오케스트레이션 HUD 기본 구조

<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <title>JARVIS ORCHESTRATION</title>
  <style>
    body { background: #000; color: #00ff88; font-family: monospace; }
  </style>
</head>
<body>
  <div id="status">IDLE</div>
  <canvas id="torus"></canvas>
  <div id="say">말씀하세요...</div>
  <div id="log"></div>
  <canvas id="wave"></canvas>
</body>
</html>

3단계: 초록 토러스 — Canvas 2D로 구현

three.js 없이 Canvas 2D만으로 토러스(링 파티클)를 구현합니다.
마이크 진폭에 따라 반경·밝기·회전이 바뀝니다.

const canvas = document.getElementById('torus');
const ctx = canvas.getContext('2d');
const PARTICLES = 220;
let orbAmp = 0;

function drawTorus(t) {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  const cx = canvas.width / 2;
  const cy = canvas.height / 2;
  const baseR = 120;

  for (let i = 0; i < PARTICLES; i++) {
    const angle = (i / PARTICLES) * Math.PI * 2 + t * 0.5;
    const r = baseR + orbAmp * 40 * Math.sin(i * 0.3 + t * 2);
    const x = cx + r * Math.cos(angle);
    const y = cy + r * Math.sin(angle) * 0.4;
    const alpha = 0.4 + orbAmp * 0.6;
    const size = 2 + orbAmp * 3;

    ctx.beginPath();
    ctx.arc(x, y, size, 0, Math.PI * 2);
    ctx.fillStyle = `rgba(0, 255, 136, ${alpha})`;
    ctx.fill();
  }
}

const audioCtx = new AudioContext();
navigator.mediaDevices.getUserMedia({ audio: true }).then(stream => {
  const source = audioCtx.createMediaStreamSource(stream);
  const analyser = audioCtx.createAnalyser();
  source.connect(analyser);
  const data = new Uint8Array(analyser.frequencyBinCount);

  let t = 0;
  function loop() {
    analyser.getByteFrequencyData(data);
    const avg = data.reduce((a, b) => a + b, 0) / data.length;
    orbAmp += ((avg / 255) - orbAmp) * 0.15;
    drawTorus(t);
    t += 0.02;
    requestAnimationFrame(loop);
  }
  loop();
});

4단계: 음성 인식 — Web Speech API

const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
const sr = new SR();
sr.lang = 'ko-KR';
sr.interimResults = true;
sr.continuous = false;

let busy = false;

sr.onresult = (e) => {
  const result = e.results[e.results.length - 1];
  const text = result[0].transcript;
  document.getElementById('say').textContent = text;
  if (result.isFinal && !busy) ask(text);
};

sr.onerror = (e) => {
  if (e.error === 'no-speech' || e.error === 'aborted') return;
  setTimeout(() => sr.start(), 500);
};

sr.onend = () => {
  if (!busy) setTimeout(() => sr.start(), 350);
};

5단계: AI 비서 호출 + 음성 출력

async function ask(text) {
  busy = true;
  setStatus('THINKING');

  const res = await fetch('/ask', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ text })
  });
  const { reply } = await res.json();

  document.getElementById('log').textContent = reply;
  setStatus('SPEAKING');

  const utter = new SpeechSynthesisUtterance(reply);
  utter.lang = 'ko-KR';
  const voices = speechSynthesis.getVoices();
  utter.voice = voices.find(v => v.name === 'Yuna') || voices.find(v => v.lang === 'ko-KR');

  utter.onend = () => {
    busy = false;
    setTimeout(() => {
      setStatus('LISTENING');
      sr.start();
    }, 700);
  };

  speechSynthesis.speak(utter);
}

실행하기

# 1. 서버 시작
python3 server.py &

# 2. 브라우저에서 열기
open -a "Google Chrome" http://127.0.0.1:8777/orchestrator.html

처음 열면 마이크 권한 팝업이 뜹니다. 허용하면 됩니다.
이후엔 🎙 버튼 누르고 말하면 바로 작동합니다.


흔한 문제들

음성인식이 자비스 목소리를 따라 말함
busy = true로 AI가 말하는 동안 인식을 막고, onend 후 0.7초 딜레이 필수.

file://로 열었더니 마이크가 안 됨
→ 반드시 http://127.0.0.1:8777/으로 접속해야 합니다.

Yuna 목소리가 안 나옴
→ 시스템 설정 > 손쉬운 사용 > 말하기에서 Yuna 보이스 다운로드 후 재시도.

말이 끝나도 AI가 답 안 함
→ isFinal 체크 확인. Chrome에서는 continuous: true 대신 onend 재시작 방식이 더 안정적.


마치며

three.js나 복잡한 라이브러리 없이, 순수 Canvas 2D와 Web Speech API만으로
인스타 릴스에서 보던 그 자비스 느낌을 낼 수 있습니다.
백엔드 /ask만 본인 AI에 연결하면 완전히 나만의 음성비서 HUD가 됩니다.

2026-06-10 작성