# -*- coding: utf-8 -*-
# dzw_tts_server.py — SHIN 음성 사이드카 서버 (포트 8095) [CEO 2026-07-23 시연대비]
#
#   왜 별도 서버인가:
#     8093 지식서버는 찬 상태 부팅에 10분 이상 걸려 시연 중 재시작이 불가능하다.
#     그래서 "더 자연스러운 목소리"(속도·톤 조절)를 8093 을 건드리지 않고 얹기 위해
#     이 작은 서버를 따로 띄운다. 이 서버가 죽어도 화면은 자동으로 8093 기존 음성으로 돌아간다.
#
#   되돌리는 법: 이 프로세스를 끄기만 하면 된다. 화면(dzw_process.js)이 8095 응답이 없으면
#              곧바로 기존 8093 /api/tts 로 폴백한다. 코드 되돌릴 필요 없음.
#
#   /api/tts?text=...&voice=...&rate=-8%&pitch=-3Hz  → MP3
#   /api/ping                                        → {"ok":true}
#
#   [포트 주의 2026-07-23] 처음 8095 로 잡았다가 사고를 냈다. 8095 는 이미 뷰어서버
#   (dzw_server.py 8095)가 쓰는 포트다. 이 서버가 127.0.0.1:8095 를 먼저 물자 감시장치가
#   뷰어서버를 죽은 걸로 오판해 계속 새로 띄웠다(프로세스 3개 누적). 그래서 8092 로 옮겼다.
#   ★ 새 포트를 잡을 땐 반드시 netstat 로 먼저 비어 있는지 확인할 것.
#   현재 사용중: 8090 뷰어 / 8091 스샷 / 8093 지식 / 8095 뷰어 / 8096 / 8099 감시장치
#
#   텍스트 정리(마크다운 제거·단위/약어 풀어읽기)는 화면(dzw_process.js speakText)에서 이미 한다.
#   여기서 또 하면 이중 변환이 되므로 이 서버는 받은 글을 그대로 읽는다.

import http.server, socketserver, os, tempfile, urllib.parse, json, uuid, asyncio, time

PORT = 8092   # ← 8095 는 뷰어서버가 이미 쓴다. 위 [포트 주의] 참조.
# [CEO 2026-07-23 지시변경] '묵직한 남성 자비스' 작업은 시연 후로 보류. 목소리·톤은 건드리지 않고
#   '읽는 방식'만 다듬는다. 그래서 기본값을 원래의 여성 선희 + 가벼운 완화값으로 되돌렸다.
#   (남성 묵직 설정은 샘플 20~27번으로 남아 있고, 나중에 여기 3줄만 바꾸면 즉시 적용된다.)
DEFAULT_VOICE = 'ko-KR-SunHiNeural'
DEFAULT_RATE = '-8%'
DEFAULT_PITCH = '-3Hz'

# edge-tts 가 실제로 제공하는 한국어 목소리는 이 3개뿐이다(2026-07-23 실측).
# 없는 이름을 허용하면 합성이 실패해 헛기다림이 생기므로 목록을 실제 3개로 고정한다.
OK_VOICES = ('ko-KR-SunHiNeural', 'ko-KR-InJoonNeural', 'ko-KR-HyunsuMultilingualNeural')


def _tmp():
    return os.path.join(tempfile.gettempdir(), 'dzw_tts5_%s.mp3' % uuid.uuid4().hex)


def synth(text, voice, rate, pitch):
    """edge-tts 뉴럴 음성 + 속도/톤 조절 → MP3 바이트."""
    import edge_tts
    out = _tmp()

    async def go():
        await edge_tts.Communicate((text or '')[:1200], voice, rate=rate, pitch=pitch).save(out)
    try:
        asyncio.run(go())
        with open(out, 'rb') as f:
            return f.read()
    finally:
        try:
            os.remove(out)
        except Exception:
            pass


class H(http.server.BaseHTTPRequestHandler):
    def log_message(self, *a):
        pass

    def _json(self, code, obj):
        b = 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('Access-Control-Allow-Origin', '*')
        self.send_header('Content-Length', str(len(b)))
        self.end_headers()
        self.wfile.write(b)

    def do_GET(self):
        u = urllib.parse.urlparse(self.path)
        qs = urllib.parse.parse_qs(u.query)
        if u.path == '/api/ping':
            self._json(200, {'ok': True, 'voice': DEFAULT_VOICE,
                             'rate': DEFAULT_RATE, 'pitch': DEFAULT_PITCH})
            return
        if u.path != '/api/tts':
            self._json(404, {'error': 'unknown'})
            return
        try:
            v = qs.get('voice', [''])[0]
            voice = v if v in OK_VOICES else DEFAULT_VOICE
            rate = qs.get('rate', [DEFAULT_RATE])[0] or DEFAULT_RATE
            pitch = qs.get('pitch', [DEFAULT_PITCH])[0] or DEFAULT_PITCH
            # 이상한 값이 오면 기본값으로 (형식이 틀리면 edge-tts 가 예외를 낸다)
            import re as _re
            if not _re.fullmatch(r'[+-]\d{1,3}%', rate):
                rate = DEFAULT_RATE
            if not _re.fullmatch(r'[+-]\d{1,3}Hz', pitch):
                pitch = DEFAULT_PITCH
            t0 = time.time()
            data = synth(qs.get('text', [''])[0], voice, rate, pitch)
            ms = int((time.time() - t0) * 1000)
            if not data or len(data) < 800:
                raise RuntimeError('empty audio')
            self.send_response(200)
            self.send_header('Content-Type', 'audio/mpeg')
            self.send_header('Access-Control-Allow-Origin', '*')
            self.send_header('X-TTS-Ms', str(ms))
            self.send_header('Content-Length', str(len(data)))
            self.end_headers()
            self.wfile.write(data)
        except Exception as e:
            # 실패는 조용히 넘기지 않는다 — 화면은 이 오류를 보고 즉시 8093 기존 음성으로 폴백한다.
            self._json(501, {'error': str(e)})


class TS(socketserver.ThreadingTCPServer):
    allow_reuse_address = True
    daemon_threads = True


if __name__ == '__main__':
    with TS(('127.0.0.1', PORT), H) as srv:
        print('[음성 사이드카] http://127.0.0.1:%d  기본 %s rate=%s pitch=%s'
              % (PORT, DEFAULT_VOICE, DEFAULT_RATE, DEFAULT_PITCH), flush=True)
        srv.serve_forever()
