From 22e9b805a6c5524b08b9175549e39c0a55ac7a7e Mon Sep 17 00:00:00 2001 From: du5t Date: Fri, 19 Jun 2026 00:30:15 +0900 Subject: [PATCH] Persist realtime transcription sessions to history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The websocket handler now accumulates the full session's PCM audio (not just the per-chunk buffer that gets discarded after each worker call) and, on stop, writes it as a wav to UPLOAD_DIR plus a result JSON to RESULT_DIR via the new _save_realtime_history() helper — same shape /transcribe already writes, tagged gateway_backend="realtime" so the history table visually distinguishes it from file uploads. This reuses /asr/history and /asr/uploads/{filename} as-is; no new endpoints needed. Co-Authored-By: Claude Sonnet 4.6 --- app/asr/router.py | 49 ++++++++++++++++++++++++++++++++++++++++- app/ui/assets/js/asr.js | 2 +- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/app/asr/router.py b/app/asr/router.py index bf8b689..0e68848 100644 --- a/app/asr/router.py +++ b/app/asr/router.py @@ -268,6 +268,41 @@ async def _send_chunk(wav_bytes: bytes, *, model: str, language: str, beam_size: return resp.json() +def _save_realtime_history( + *, + all_audio: bytearray, + text: str, + segments: List[Dict[str, Any]], + model: str, + language: str, + duration: float, +) -> Optional[str]: + if not all_audio: + return None + + ensure_runtime_dirs() + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + saved_upload = UPLOAD_DIR / f"{timestamp}.wav" + saved_upload.write_bytes(_pcm_float32_to_wav(bytes(all_audio))) + + payload = { + "backend": "faster-whisper", + "gateway_backend": "realtime", + "model": model, + "language": language, + "duration": round(duration, 3), + "text": text, + "segments": segments, + "diarized": False, + "_filename": "실시간 녹음", + "_timestamp": timestamp, + "upload_file": str(saved_upload), + } + result_path = RESULT_DIR / f"{timestamp}.json" + result_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + return timestamp + + @ws_router.websocket("/ws/realtime") async def realtime_ws(websocket: WebSocket) -> None: if not websocket.session.get("user"): @@ -276,6 +311,7 @@ async def realtime_ws(websocket: WebSocket) -> None: await websocket.accept() audio_buf = bytearray() + all_audio = bytearray() partial_texts: List[str] = [] all_segments: List[Dict[str, Any]] = [] time_offset = 0.0 @@ -333,6 +369,7 @@ async def realtime_ws(websocket: WebSocket) -> None: break if "bytes" in data: audio_buf.extend(data["bytes"]) + all_audio.extend(data["bytes"]) while len(audio_buf) >= chunk_bytes: await process_buffer(bytearray(audio_buf[:chunk_bytes])) audio_buf = audio_buf[chunk_bytes:] @@ -341,11 +378,21 @@ async def realtime_ws(websocket: WebSocket) -> None: if msg.get("cmd") == "stop": if audio_buf: await process_buffer(audio_buf) + full_text = " ".join(x for x in partial_texts if x) + result_id = _save_realtime_history( + all_audio=all_audio, + text=full_text, + segments=all_segments, + model=model, + language=language, + duration=time_offset, + ) await websocket.send_json({ "type": "final", - "text": " ".join(x for x in partial_texts if x), + "text": full_text, "segments": all_segments, "diarized": False, + "id": result_id, }) break diff --git a/app/ui/assets/js/asr.js b/app/ui/assets/js/asr.js index d50c296..725e499 100644 --- a/app/ui/assets/js/asr.js +++ b/app/ui/assets/js/asr.js @@ -200,7 +200,7 @@ async function startRecording() { appendLive(''); } else if (msg.type === 'final') { rtFinalText.value = msg.text || ''; - setIndicator('idle', '완료'); + setIndicator('idle', msg.id ? '완료 (처리 내역에 저장됨)' : '완료'); cleanupAudio(); } else if (msg.type === 'error') { setIndicator('idle', `오류: ${msg.detail}`);