diff --git a/notebooks/luke-scribe-colab.ipynb b/notebooks/luke-scribe-colab.ipynb index 5b82682..ea116f1 100644 --- a/notebooks/luke-scribe-colab.ipynb +++ b/notebooks/luke-scribe-colab.ipynb @@ -109,7 +109,7 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": "# 7) 실전 전사 — GPU 자동 감지 + 모델 다운로드 (large-v3-turbo)\n# 첫 실행 시 Hugging Face에서 모델을 다운로드합니다 (turbo ≈ 1.6GB, 1~2분)\n# CUDA 13 환경에서 CTranslate2가 GPU를 못 열면 자동으로 CPU로 폴백합니다.\nimport subprocess, json\nr = subprocess.run(\n ['luke-scribe', 'transcribe', 'samples/colab-ko-en.mp3', '--language', 'ko', '--device', 'auto'],\n capture_output=True, text=True,\n)\nprint(r.stdout[-2500:] if r.stdout else '')\nprint(r.stderr[-800:] if r.stderr else '')\n" + "source": "# 7) 실전 전사 — GPU 자동 감지 + 모델 다운로드 (large-v3-turbo)\n# 첫 실행 시 Hugging Face에서 모델을 다운로드합니다 (turbo ≈ 1.6GB, 1~2분)\n# v0.1: 'cuda:N' device를 분리해 CTranslate2 계약에 맞게 전달하도록 수정됨\n# (기존: unsupported device cuda:0). 그래도 실패하면 CPU 폴백 안내가 출력됩니다.\nimport subprocess, json\nr = subprocess.run(\n ['luke-scribe', 'transcribe', 'samples/colab-ko-en.mp3', '--language', 'ko', '--device', 'auto'],\n capture_output=True, text=True,\n)\nprint(r.stdout[-2500:] if r.stdout else '')\nprint(r.stderr[-800:] if r.stderr else '')\n" }, { "cell_type": "markdown", @@ -140,7 +140,7 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": "# 10) 서버 기동 (in-proc 큐, 백그라운드 — Colab에서는 %%bash --bg 또는 nohup 사용)\n!mkdir -p /content/logs\n!nohup luke-scribe serve --port 8000 > /content/logs/server.log 2>&1 &\nimport time, urllib.request\nok = False\nfor _ in range(40):\n try:\n urllib.request.urlopen('http://localhost:8000/health', timeout=1)\n ok = True\n break\n except Exception:\n time.sleep(1)\nprint('서버 기동 OK' if ok else '서버 기동 실패 — 로그:')\nif not ok:\n print(open('/content/logs/server.log').read()[-1500:])\n" + "source": "# 10) 서버 기동 (in-proc 큐, 백그라운드 — nohup)\n# 이전 실행에서 남은 서버가 포트 8000을 점유하면 새 키를 모른 채 401이 난다.\n# → 먼저 기존 서버를 모두 종료하고, 키 파일 경로를 명시해 재시작한다.\nimport subprocess, os, time, urllib.request, urllib.error\n\nassert 'RAW_KEY' in globals(), '셀 9(키 생성)를 먼저 실행하세요'\n\nsubprocess.run(['pkill', '-f', 'luke-scribe serve'], capture_output=True)\ntime.sleep(2)\n\n# 서버가 읽을 키 파일/큐를 명시 (cwd 의존 제거)\nos.environ['LUKESCRIBE_API_KEY_FILE'] = '/content/api_keys.json'\nos.environ['LUKESCRIBE_QUEUE_BACKEND'] = 'inproc'\n\n!mkdir -p /content/logs\n!nohup luke-scribe serve --port 8000 > /content/logs/server.log 2>&1 &\n\nok = False\nfor _ in range(40):\n try:\n urllib.request.urlopen('http://localhost:8000/health', timeout=1)\n ok = True\n break\n except Exception:\n time.sleep(1)\nif not ok:\n print('서버 기동 실패 — 로그:')\n print(open('/content/logs/server.log').read()[-1500:])\n raise SystemExit('서버 기동 실패')\n\n# 키 인증 검증 — RAW_KEY로 /v1/jobs 호출 → 200이어야 함 (401이면 종료)\nreq = urllib.request.Request('http://localhost:8000/v1/jobs', headers={'X-API-Key': RAW_KEY})\ntry:\n resp = urllib.request.urlopen(req, timeout=5)\n print('서버 기동 OK + 키 인증 OK (HTTP', resp.status, ')')\nexcept urllib.error.HTTPError as e:\n print('키 인증 실패 (HTTP', e.code, ') — 서버 로그:')\n print(open('/content/logs/server.log').read()[-1500:])\n raise SystemExit('키 인증 실패 — RAW_KEY 캡처/키 파일 확인')\n" }, { "cell_type": "code", @@ -159,7 +159,7 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": "# 12) 브로커 + 워커 포함 전체 흐름 (in-proc)\n# 서버의 in-proc 브로커에 enqueue된 job을 같은 프로세스의 워커가 소비하는 구조는\n# 프로세스 분리 필요 → 여기서는 클라이언트에서 직접 Worker.drain() 호출로 검증\nfrom luke_scribe.config import Settings\nfrom luke_scribe.jobqueue.broker import InProcBroker\nfrom luke_scribe.jobqueue.worker import Worker\nfrom luke_scribe.jobqueue.jobs import Job\nfrom luke_scribe.results.store import ResultStore\n\nsettings = Settings(_env_file=None, queue_backend='inproc',\n results_dir='/content/results', model_cache_dir=None)\nbroker = InProcBroker(settings)\nstore = ResultStore(settings.results_dir)\n\njob = Job(type='file', lane='batch', source_path='samples/colab-ko-en.mp3',\n options={'model': 'large-v3-turbo', 'language': 'ko', 'device': 'auto'})\nbroker.enqueue(job)\nworker = Worker(settings=settings, broker=broker, store=store)\nworker.drain()\n\nresult = store.read_result(job.id)\nprint('status:', result.status)\nprint('text:', result.text[:120])\nprint('device:', result.execution.device, '| ct:', result.execution.compute_type, '| rtf:', result.timings.rtf)\n" + "source": "# 12) 브로커 + 워커 포함 전체 흐름 (in-proc)\n# 서버의 in-proc 브로커에 enqueue된 job을 같은 프로세스의 워커가 소비하는 구조는\n# 프로세스 분리 필요 → 여기서는 클라이언트에서 직접 Worker.drain() 호출로 검증\nfrom luke_scribe.config import Settings\nfrom luke_scribe.jobqueue.broker import InProcBroker\nfrom luke_scribe.jobqueue.worker import Worker\nfrom luke_scribe.jobqueue.jobs import Job\nfrom luke_scribe.results.store import ResultStore\n\nsettings = Settings(_env_file=None, queue_backend='inproc',\n results_dir='/content/results', model_cache_dir=None)\nbroker = InProcBroker(settings)\nstore = ResultStore(settings.results_dir)\n\njob = Job(type='file', lane='batch', source_path='samples/colab-ko-en.mp3',\n options={'model': 'large-v3-turbo', 'language': 'ko', 'device': 'auto'})\nbroker.enqueue(job)\nworker = Worker(settings=settings, broker=broker, store=store)\nworker.drain()\n\nresult = store.read_result(job.id)\nif result is None:\n # 전사 실패(예: GPU 초기화 실패) 시 결과가 저장되지 않음 — 상태/오류 출력\n state = broker.get(job.id)\n print('결과 없음 — job 상태:', getattr(state, 'status', 'unknown'))\n if state is not None:\n print('job 오류:', getattr(state, 'error', None) or '없음')\nelse:\n print('status:', result.status)\n print('text:', result.text[:120])\n print('device:', result.execution.device, '| ct:', result.execution.compute_type, '| rtf:', result.timings.rtf)\n" }, { "cell_type": "markdown", @@ -176,7 +176,7 @@ { "cell_type": "markdown", "metadata": {}, - "source": "## 8) 문제 해결\n\n### CUDA 13 환경에서 `unsupported device cuda:0` (faster-whisper/CTranslate2)\n\nColab이 CUDA 13.0(드라이버 580)으로 올라가면서, CUDA 12용으로 빌드된\nCTranslate2 wheel이 런타임 라이브러리(cuBLAS/cuDNN)를 찾지 못하는 이슈가 발생합니다.\n설치 셀 3에서 CUDA 12 런타임을 pip로 설치하고 `LD_LIBRARY_PATH`를 잡아 해결합니다.\n그래도 안 되면 CPU로 폴백: `--device cpu --compute-type int8` (느리지만 확실).\n\n### 모델 다운로드 실패\n\n- Hugging Face 연결 필요. 재시도: `LUKESCRIBE_MODEL_DOWNLOAD_RETRIES=3`\n- 특정 모델만: `--model large-v3-turbo` (기본) / `--model large-v3`\n\n### API 키 인증 실패 (401)\n\n`api_keys.json`에는 **다이제스트만** 저장되고 raw 키는 생성 시 1회만 출력됩니다\n(보안 설계). 셀 9에서 `RAW_KEY`를 캡처했는지 확인하세요 — 파일에서 키를 읽으면 401이 납니다.\n\n### OOM (16GB VRAM)\n\n- EngineOwner가 자동으로 정밀도를 강등합니다 (float16 → int8_float16 → int8 → CPU)\n- 수동 지정: `--compute-type int8_float16`\n\n### 저장소가 private인데 클론 안 됨\n\n1번 셀의 `GITEA_TOKEN`에 토큰을 입력하고 런타임 → **Restart session** 후 다시 실행하세요.\n" + "source": "## 8) 문제 해결\n\n### 전사 실패: `unsupported device cuda:0`\n\n**근본 원인**: v0.1 엔진이 `device=\"cuda:0\"`(인덱스 포함)를 그대로 CTranslate2에 전달했는데,\nCTranslate2는 `device=\"cuda\"`만 허용하고 인덱스는 별도 `device_index` 인자로 받습니다.\n→ 엔진에서 `device`/`device_index`를 분리하도록 **수정 완료** (이 브랜치 최신 커밋 포함).\n\n참고: Colab의 CUDA 13(드라이버 580)에서는 CUDA 12용 CTranslate2 wheel이\n런타임 라이브러리(cuBLAS/cuDNN)를 못 찾을 수 있어, 설치 셀 3에서\n`nvidia-cublas-cu12`/`nvidia-cudnn-cu12` + `LD_LIBRARY_PATH`를 설정합니다.\n그래도 실패하면 CPU 폴백: `--device cpu --compute-type int8` (느리지만 확실).\n\n### API 키 인증 실패 (401)\n\n1. `api_keys.json`에는 **다이제스트만** 저장되고 raw 키는 생성 시 1회만 출력됩니다\n (보안 설계). 셀 9에서 `RAW_KEY`를 캡처했는지 확인하세요 — 파일에서 키를 읽으면 401.\n2. **이전 실행에서 남은 서버**가 포트 8000을 점유하면 새 키를 모른 채 401이 납니다.\n 셀 10이 시작 시 `pkill`로 기존 서버를 종료하고 키 인증(HTTP 200)까지 검증합니다.\n\n### 모델 다운로드 실패\n\n- Hugging Face 연결 필요. 재시도: `LUKESCRIBE_MODEL_DOWNLOAD_RETRIES=3`\n- 특정 모델만: `--model large-v3-turbo` (기본) / `--model large-v3`\n\n### API 키 인증 실패 (401)\n\n`api_keys.json`에는 **다이제스트만** 저장되고 raw 키는 생성 시 1회만 출력됩니다\n(보안 설계). 셀 9에서 `RAW_KEY`를 캡처했는지 확인하세요 — 파일에서 키를 읽으면 401이 납니다.\n\n### OOM (16GB VRAM)\n\n- EngineOwner가 자동으로 정밀도를 강등합니다 (float16 → int8_float16 → int8 → CPU)\n- 수동 지정: `--compute-type int8_float16`\n\n### 저장소가 private인데 클론 안 됨\n\n1번 셀의 `GITEA_TOKEN`에 토큰을 입력하고 런타임 → **Restart session** 후 다시 실행하세요.\n" } ] } \ No newline at end of file diff --git a/scripts/build_colab_notebook.py b/scripts/build_colab_notebook.py index a279209..6ab8289 100644 --- a/scripts/build_colab_notebook.py +++ b/scripts/build_colab_notebook.py @@ -28,7 +28,13 @@ def md(source: str) -> dict: def code(source: str) -> dict: - return {"cell_type": "code", "execution_count": None, "metadata": {}, "outputs": [], "source": source} + return { + "cell_type": "code", + "execution_count": None, + "metadata": {}, + "outputs": [], + "source": source, + } def cells() -> list[dict]: @@ -94,7 +100,9 @@ def cells() -> list[dict]: "print('작업 디렉터리 →', os.getcwd())\n" "!git log --oneline -1\n" ), - md("## 2) 시스템 의존성 + 설치\n\nffmpeg(오디오 정규화) 설치 후 venv에 luke-scribe를 설치합니다."), + md( + "## 2) 시스템 의존성 + 설치\n\nffmpeg(오디오 정규화) 설치 후 venv에 luke-scribe를 설치합니다." + ), code( "# 2) 시스템 패키지 (ffmpeg — ffprobe/정규화에 필수)\n" "!apt-get update -qq && apt-get install -y -qq ffmpeg >/dev/null\n" @@ -143,15 +151,11 @@ def cells() -> list[dict]: "CPU-only 환경에서는 `T0 / cpu / int8`이 나왔지만,\n" "Colab GPU(A100 80GB / T4 16GB)에서는 GPU가 감지되어야 합니다." ), - code( - "# 4) detect — GPU 감지 / 능력 등급 / 정밀도 / 워커수\n" - "!luke-scribe detect\n" - ), - md("## 4) 단위/통합 테스트 (127개)\n\nmock 기반 테스트가 GPU 환경에서도 전부 통과하는지 확인합니다."), - code( - "# 5) 테스트 스위트\n" - "!python -m pytest tests/ -q 2>&1 | tail -5\n" + code("# 4) detect — GPU 감지 / 능력 등급 / 정밀도 / 워커수\n!luke-scribe detect\n"), + md( + "## 4) 단위/통합 테스트 (127개)\n\nmock 기반 테스트가 GPU 환경에서도 전부 통과하는지 확인합니다." ), + code("# 5) 테스트 스위트\n!python -m pytest tests/ -q 2>&1 | tail -5\n"), md( "## 5) 실전 전사 (GPU)\n\n" "### 5-1) 샘플 오디오 생성\n\n" @@ -167,7 +171,8 @@ def cells() -> list[dict]: code( "# 7) 실전 전사 — GPU 자동 감지 + 모델 다운로드 (large-v3-turbo)\n" "# 첫 실행 시 Hugging Face에서 모델을 다운로드합니다 (turbo ≈ 1.6GB, 1~2분)\n" - "# CUDA 13 환경에서 CTranslate2가 GPU를 못 열면 자동으로 CPU로 폴백합니다.\n" + "# v0.1: 'cuda:N' device를 분리해 CTranslate2 계약에 맞게 전달하도록 수정됨\n" + "# (기존: unsupported device cuda:0). 그래도 실패하면 CPU 폴백 안내가 출력됩니다.\n" "import subprocess, json\n" "r = subprocess.run(\n" " ['luke-scribe', 'transcribe', 'samples/colab-ko-en.mp3', '--language', 'ko', '--device', 'auto'],\n" @@ -202,10 +207,23 @@ def cells() -> list[dict]: "print('raw 키 캡처 완료 (표시 안 함)')\n" ), code( - "# 10) 서버 기동 (in-proc 큐, 백그라운드 — Colab에서는 %%bash --bg 또는 nohup 사용)\n" + "# 10) 서버 기동 (in-proc 큐, 백그라운드 — nohup)\n" + "# 이전 실행에서 남은 서버가 포트 8000을 점유하면 새 키를 모른 채 401이 난다.\n" + "# → 먼저 기존 서버를 모두 종료하고, 키 파일 경로를 명시해 재시작한다.\n" + "import subprocess, os, time, urllib.request, urllib.error\n" + "\n" + "assert 'RAW_KEY' in globals(), '셀 9(키 생성)를 먼저 실행하세요'\n" + "\n" + "subprocess.run(['pkill', '-f', 'luke-scribe serve'], capture_output=True)\n" + "time.sleep(2)\n" + "\n" + "# 서버가 읽을 키 파일/큐를 명시 (cwd 의존 제거)\n" + "os.environ['LUKESCRIBE_API_KEY_FILE'] = '/content/api_keys.json'\n" + "os.environ['LUKESCRIBE_QUEUE_BACKEND'] = 'inproc'\n" + "\n" "!mkdir -p /content/logs\n" "!nohup luke-scribe serve --port 8000 > /content/logs/server.log 2>&1 &\n" - "import time, urllib.request\n" + "\n" "ok = False\n" "for _ in range(40):\n" " try:\n" @@ -214,9 +232,20 @@ def cells() -> list[dict]: " break\n" " except Exception:\n" " time.sleep(1)\n" - "print('서버 기동 OK' if ok else '서버 기동 실패 — 로그:')\n" "if not ok:\n" + " print('서버 기동 실패 — 로그:')\n" " print(open('/content/logs/server.log').read()[-1500:])\n" + " raise SystemExit('서버 기동 실패')\n" + "\n" + "# 키 인증 검증 — RAW_KEY로 /v1/jobs 호출 → 200이어야 함 (401이면 종료)\n" + "req = urllib.request.Request('http://localhost:8000/v1/jobs', headers={'X-API-Key': RAW_KEY})\n" + "try:\n" + " resp = urllib.request.urlopen(req, timeout=5)\n" + " print('서버 기동 OK + 키 인증 OK (HTTP', resp.status, ')')\n" + "except urllib.error.HTTPError as e:\n" + " print('키 인증 실패 (HTTP', e.code, ') — 서버 로그:')\n" + " print(open('/content/logs/server.log').read()[-1500:])\n" + " raise SystemExit('키 인증 실패 — RAW_KEY 캡처/키 파일 확인')\n" ), code( "# 11) 업로드 → poll → 결과 (RAW_KEY는 셀 9에서 캡처된 값)\n" @@ -229,7 +258,7 @@ def cells() -> list[dict]: " ['curl', '-s', '-X', 'POST', 'http://localhost:8000/v1/jobs',\n" " '-H', f'X-API-Key: {RAW_KEY}',\n" " '-F', 'file=@samples/colab-ko-en.mp3',\n" - " '-F', 'options={\"language\":\"ko\",\"formats\":[\"json\",\"srt\"]}'],\n" + ' \'-F\', \'options={"language":"ko","formats":["json","srt"]}\'],\n' " capture_output=True, text=True,\n" ")\n" "job = json.loads(r.stdout)\n" @@ -281,9 +310,16 @@ def cells() -> list[dict]: "worker.drain()\n" "\n" "result = store.read_result(job.id)\n" - "print('status:', result.status)\n" - "print('text:', result.text[:120])\n" - "print('device:', result.execution.device, '| ct:', result.execution.compute_type, '| rtf:', result.timings.rtf)\n" + "if result is None:\n" + " # 전사 실패(예: GPU 초기화 실패) 시 결과가 저장되지 않음 — 상태/오류 출력\n" + " state = broker.get(job.id)\n" + " print('결과 없음 — job 상태:', getattr(state, 'status', 'unknown'))\n" + " if state is not None:\n" + " print('job 오류:', getattr(state, 'error', None) or '없음')\n" + "else:\n" + " print('status:', result.status)\n" + " print('text:', result.text[:120])\n" + " print('device:', result.execution.device, '| ct:', result.execution.compute_type, '| rtf:', result.timings.rtf)\n" ), md( "## 7) 벤치마크 (turbo vs large-v3)\n\n" @@ -310,11 +346,19 @@ def cells() -> list[dict]: ), md( "## 8) 문제 해결\n\n" - "### CUDA 13 환경에서 `unsupported device cuda:0` (faster-whisper/CTranslate2)\n\n" - "Colab이 CUDA 13.0(드라이버 580)으로 올라가면서, CUDA 12용으로 빌드된\n" - "CTranslate2 wheel이 런타임 라이브러리(cuBLAS/cuDNN)를 찾지 못하는 이슈가 발생합니다.\n" - "설치 셀 3에서 CUDA 12 런타임을 pip로 설치하고 `LD_LIBRARY_PATH`를 잡아 해결합니다.\n" - "그래도 안 되면 CPU로 폴백: `--device cpu --compute-type int8` (느리지만 확실).\n\n" + "### 전사 실패: `unsupported device cuda:0`\n\n" + '**근본 원인**: v0.1 엔진이 `device="cuda:0"`(인덱스 포함)를 그대로 CTranslate2에 전달했는데,\n' + 'CTranslate2는 `device="cuda"`만 허용하고 인덱스는 별도 `device_index` 인자로 받습니다.\n' + "→ 엔진에서 `device`/`device_index`를 분리하도록 **수정 완료** (이 브랜치 최신 커밋 포함).\n\n" + "참고: Colab의 CUDA 13(드라이버 580)에서는 CUDA 12용 CTranslate2 wheel이\n" + "런타임 라이브러리(cuBLAS/cuDNN)를 못 찾을 수 있어, 설치 셀 3에서\n" + "`nvidia-cublas-cu12`/`nvidia-cudnn-cu12` + `LD_LIBRARY_PATH`를 설정합니다.\n" + "그래도 실패하면 CPU 폴백: `--device cpu --compute-type int8` (느리지만 확실).\n\n" + "### API 키 인증 실패 (401)\n\n" + "1. `api_keys.json`에는 **다이제스트만** 저장되고 raw 키는 생성 시 1회만 출력됩니다\n" + " (보안 설계). 셀 9에서 `RAW_KEY`를 캡처했는지 확인하세요 — 파일에서 키를 읽으면 401.\n" + "2. **이전 실행에서 남은 서버**가 포트 8000을 점유하면 새 키를 모른 채 401이 납니다.\n" + " 셀 10이 시작 시 `pkill`로 기존 서버를 종료하고 키 인증(HTTP 200)까지 검증합니다.\n\n" "### 모델 다운로드 실패\n\n" "- Hugging Face 연결 필요. 재시도: `LUKESCRIBE_MODEL_DOWNLOAD_RETRIES=3`\n" "- 특정 모델만: `--model large-v3-turbo` (기본) / `--model large-v3`\n" diff --git a/src/luke_scribe/engine/faster_whisper_engine.py b/src/luke_scribe/engine/faster_whisper_engine.py index a9585cf..48de03a 100644 --- a/src/luke_scribe/engine/faster_whisper_engine.py +++ b/src/luke_scribe/engine/faster_whisper_engine.py @@ -86,10 +86,15 @@ class FasterWhisperEngine(TranscriptionEngine): with self._lock: model = self._models.get(model_key) if model is None: + # CTranslate2는 device 문자열로 "cuda"만 허용한다 ("cuda:0" 형태는 + # "unsupported device cuda:0" 오류). DeviceManager가 내려주는 + # "cuda:N"을 device + device_index로 분리해 전달한다. + device_name, device_index = self._split_device(options.device) try: model = WhisperModel( options.model, - device=options.device, + device=device_name, + device_index=device_index, compute_type=options.compute_type, download_root=self._download_root(), ) @@ -128,6 +133,20 @@ class FasterWhisperEngine(TranscriptionEngine): wrapped = _CancellableSegmentIterator(segments_iter, should_cancel or (lambda: False)) return TranscriptionOutcome(wrapped, info=info) + @staticmethod + def _split_device(device: str) -> tuple[str, int]: + """'cuda:N' → ('cuda', N). CTranslate2는 device='cuda'만 허용하므로 분리한다. + + 'cpu'/'cuda' 같은 인덱스 없는 값은 그대로 (device, 0)을 반환한다. + """ + if device.startswith("cuda") and ":" in device: + name, _, idx = device.partition(":") + try: + return name, int(idx) + except ValueError: + pass + return device, 0 + def _download_root(self) -> str | None: from ..config import get_settings diff --git a/tests/unit/test_engine_faster_whisper.py b/tests/unit/test_engine_faster_whisper.py new file mode 100644 index 0000000..d27feeb --- /dev/null +++ b/tests/unit/test_engine_faster_whisper.py @@ -0,0 +1,80 @@ +"""FasterWhisperEngine 단위 테스트 — CTranslate2 호출 계약 (device 분리). + +faster-whisper/CTranslate2는 ``device`` 문자열로 "cuda"만 허용하며 인덱스는 +별도 ``device_index`` 인자로 받는다. DeviceManager가 내려주는 "cuda:N"을 +제대로 분리해 전달하는지 mock으로 검증한다 (GPU 환경에서만 재현되는 버그 +— Colab 실전 테스트에서 ``unsupported device cuda:0``으로 확인됨). +""" + +from __future__ import annotations + +import sys +import types + +from luke_scribe.engine.base import TranscriptionOptions +from luke_scribe.engine.faster_whisper_engine import FasterWhisperEngine + + +class FakeWhisperModel: + """faster_whisper.WhisperModel 대체 — 생성 인자만 기록한다.""" + + calls: list[dict] = [] + + def __init__(self, *args, **kwargs) -> None: + self.kwargs = kwargs + self.__class__.calls.append(kwargs) + + def transcribe(self, audio_path, **kwargs): + return iter([]), {} + + +def _install_fake(monkeypatch) -> None: + mod = types.ModuleType("faster_whisper") + mod.WhisperModel = FakeWhisperModel + monkeypatch.setitem(sys.modules, "faster_whisper", mod) + FakeWhisperModel.calls.clear() + + +def _opts(**kw) -> TranscriptionOptions: + kw.setdefault("model", "large-v3-turbo") + kw.setdefault("device", "cuda:0") + kw.setdefault("compute_type", "float16") + return TranscriptionOptions(**kw) + + +def test_cuda_device_index_split(monkeypatch): + """'cuda:0' → device='cuda', device_index=0 (Colab A100 첫 GPU).""" + _install_fake(monkeypatch) + FasterWhisperEngine().transcribe("/tmp/x.wav", _opts()) + kwargs = FakeWhisperModel.calls[-1] + assert kwargs["device"] == "cuda" + assert kwargs["device_index"] == 0 + assert kwargs["compute_type"] == "float16" + + +def test_cuda_second_device_index(monkeypatch): + """'cuda:1' → device='cuda', device_index=1.""" + _install_fake(monkeypatch) + FasterWhisperEngine().transcribe("/tmp/x.wav", _opts(device="cuda:1")) + kwargs = FakeWhisperModel.calls[-1] + assert kwargs["device"] == "cuda" + assert kwargs["device_index"] == 1 + + +def test_cpu_passthrough(monkeypatch): + """'cpu' → 그대로 device='cpu', device_index=0.""" + _install_fake(monkeypatch) + FasterWhisperEngine().transcribe("/tmp/x.wav", _opts(device="cpu", compute_type="int8")) + kwargs = FakeWhisperModel.calls[-1] + assert kwargs["device"] == "cpu" + assert kwargs["device_index"] == 0 + assert kwargs["compute_type"] == "int8" + + +def test_split_device_helper(): + assert FasterWhisperEngine._split_device("cuda:0") == ("cuda", 0) + assert FasterWhisperEngine._split_device("cuda:3") == ("cuda", 3) + assert FasterWhisperEngine._split_device("cpu") == ("cpu", 0) + assert FasterWhisperEngine._split_device("cuda") == ("cuda", 0) + # 파싱 불가 인덱스는 그대로 전달 (모델 로드 시 명시적 오류로 fail-explicit) + assert FasterWhisperEngine._split_device("cuda:xx") == ("cuda:xx", 0)