Compare commits
11 Commits
0defc829dd
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a2efb3b9b | ||
|
|
9da6c91dbe | ||
|
|
e68c5ebd9f | ||
|
|
fd203ef771 | ||
|
|
771655e03e | ||
|
|
7d3fc72bd2 | ||
|
|
e7a06e2b8f | ||
|
|
c36f4167f2 | ||
|
|
6112799c79 | ||
|
|
9a6ca5730b | ||
|
|
c05fdad8e4 |
@@ -1,9 +1,32 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from .database import init_db_pool, init_tables
|
from .database import init_db_pool, init_tables
|
||||||
from .routers import auth, meetings, ai, export
|
from .routers import auth, meetings, ai, export, sidecar
|
||||||
|
from .sidecar_manager import get_sidecar_manager
|
||||||
|
|
||||||
|
# Determine client directory path
|
||||||
|
# In development: backend/../client/src
|
||||||
|
# In packaged mode: backend/backend/_internal/../../client (relative to backend executable)
|
||||||
|
BACKEND_DIR = Path(__file__).parent.parent
|
||||||
|
PROJECT_DIR = BACKEND_DIR.parent
|
||||||
|
CLIENT_DIR = PROJECT_DIR / "client" / "src"
|
||||||
|
|
||||||
|
# Check for packaged mode (PyInstaller sets _MEIPASS)
|
||||||
|
import sys
|
||||||
|
if getattr(sys, 'frozen', False):
|
||||||
|
# Packaged mode: look for client folder relative to executable
|
||||||
|
# Backend runs from resources/backend/, client files at resources/backend/client/
|
||||||
|
EXEC_DIR = Path(sys.executable).parent.parent # up from backend/backend.exe
|
||||||
|
CLIENT_DIR = EXEC_DIR / "client"
|
||||||
|
print(f"[Backend] Packaged mode: CLIENT_DIR={CLIENT_DIR}")
|
||||||
|
else:
|
||||||
|
print(f"[Backend] Development mode: CLIENT_DIR={CLIENT_DIR}")
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -11,8 +34,25 @@ async def lifespan(app: FastAPI):
|
|||||||
# Startup
|
# Startup
|
||||||
init_db_pool()
|
init_db_pool()
|
||||||
init_tables()
|
init_tables()
|
||||||
|
|
||||||
|
# Only start sidecar in browser mode (not when Electron manages it)
|
||||||
|
# Set BROWSER_MODE=true in start-browser.sh to enable
|
||||||
|
browser_mode = os.environ.get("BROWSER_MODE", "").lower() == "true"
|
||||||
|
sidecar_mgr = get_sidecar_manager()
|
||||||
|
|
||||||
|
if browser_mode and sidecar_mgr.is_available():
|
||||||
|
print("[Backend] Browser mode: Starting sidecar...")
|
||||||
|
await sidecar_mgr.start()
|
||||||
|
elif browser_mode:
|
||||||
|
print("[Backend] Browser mode: Sidecar not available (transcription disabled)")
|
||||||
|
else:
|
||||||
|
print("[Backend] Electron mode: Sidecar managed by Electron")
|
||||||
|
|
||||||
yield
|
yield
|
||||||
# Shutdown (cleanup if needed)
|
|
||||||
|
# Shutdown - only stop if we started it
|
||||||
|
if browser_mode:
|
||||||
|
sidecar_mgr.stop()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
@@ -36,9 +76,43 @@ app.include_router(auth.router, prefix="/api", tags=["Authentication"])
|
|||||||
app.include_router(meetings.router, prefix="/api", tags=["Meetings"])
|
app.include_router(meetings.router, prefix="/api", tags=["Meetings"])
|
||||||
app.include_router(ai.router, prefix="/api", tags=["AI"])
|
app.include_router(ai.router, prefix="/api", tags=["AI"])
|
||||||
app.include_router(export.router, prefix="/api", tags=["Export"])
|
app.include_router(export.router, prefix="/api", tags=["Export"])
|
||||||
|
app.include_router(sidecar.router, prefix="/api", tags=["Sidecar"])
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
async def health_check():
|
async def health_check():
|
||||||
"""Health check endpoint."""
|
"""Health check endpoint."""
|
||||||
return {"status": "healthy", "service": "meeting-assistant"}
|
return {"status": "healthy", "service": "meeting-assistant"}
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# Browser Mode: Serve static files
|
||||||
|
# ========================================
|
||||||
|
|
||||||
|
# Check if client directory exists for browser mode
|
||||||
|
print(f"[Backend] CLIENT_DIR exists: {CLIENT_DIR.exists()}")
|
||||||
|
if CLIENT_DIR.exists():
|
||||||
|
# Serve static assets (CSS, JS, etc.)
|
||||||
|
app.mount("/styles", StaticFiles(directory=CLIENT_DIR / "styles"), name="styles")
|
||||||
|
app.mount("/services", StaticFiles(directory=CLIENT_DIR / "services"), name="services")
|
||||||
|
app.mount("/config", StaticFiles(directory=CLIENT_DIR / "config"), name="config")
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def serve_login():
|
||||||
|
"""Serve login page."""
|
||||||
|
return FileResponse(CLIENT_DIR / "pages" / "login.html")
|
||||||
|
|
||||||
|
@app.get("/login")
|
||||||
|
async def serve_login_page():
|
||||||
|
"""Serve login page."""
|
||||||
|
return FileResponse(CLIENT_DIR / "pages" / "login.html")
|
||||||
|
|
||||||
|
@app.get("/meetings")
|
||||||
|
async def serve_meetings_page():
|
||||||
|
"""Serve meetings list page."""
|
||||||
|
return FileResponse(CLIENT_DIR / "pages" / "meetings.html")
|
||||||
|
|
||||||
|
@app.get("/meeting-detail")
|
||||||
|
async def serve_meeting_detail_page():
|
||||||
|
"""Serve meeting detail page."""
|
||||||
|
return FileResponse(CLIENT_DIR / "pages" / "meeting-detail.html")
|
||||||
|
|||||||
346
backend/app/routers/sidecar.py
Normal file
346
backend/app/routers/sidecar.py
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
"""
|
||||||
|
Sidecar API Router
|
||||||
|
|
||||||
|
Provides HTTP endpoints for browser-based clients to access
|
||||||
|
the Whisper transcription sidecar functionality.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import base64
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import APIRouter, HTTPException, UploadFile, File, WebSocket, WebSocketDisconnect
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from ..sidecar_manager import get_sidecar_manager
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/sidecar", tags=["Sidecar"])
|
||||||
|
|
||||||
|
|
||||||
|
class TranscribeRequest(BaseModel):
|
||||||
|
"""Request for transcribing audio from base64 data."""
|
||||||
|
audio_data: str # Base64 encoded audio (webm/opus)
|
||||||
|
|
||||||
|
|
||||||
|
class AudioChunkRequest(BaseModel):
|
||||||
|
"""Request for sending an audio chunk in streaming mode."""
|
||||||
|
data: str # Base64 encoded PCM audio
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
async def get_sidecar_status():
|
||||||
|
"""
|
||||||
|
Get the current status of the sidecar transcription engine.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Status object with ready state, whisper model info, etc.
|
||||||
|
"""
|
||||||
|
manager = get_sidecar_manager()
|
||||||
|
return manager.get_status()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/start")
|
||||||
|
async def start_sidecar():
|
||||||
|
"""
|
||||||
|
Start the sidecar transcription engine.
|
||||||
|
|
||||||
|
This is typically called automatically on backend startup,
|
||||||
|
but can be used to restart the sidecar if needed.
|
||||||
|
"""
|
||||||
|
manager = get_sidecar_manager()
|
||||||
|
|
||||||
|
if not manager.is_available():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Sidecar not available. Check if sidecar/transcriber.py and sidecar/venv exist."
|
||||||
|
)
|
||||||
|
|
||||||
|
success = await manager.start()
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Failed to start sidecar. Check backend logs for details."
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"status": "started", "ready": manager.ready}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/stop")
|
||||||
|
async def stop_sidecar():
|
||||||
|
"""Stop the sidecar transcription engine."""
|
||||||
|
manager = get_sidecar_manager()
|
||||||
|
manager.stop()
|
||||||
|
return {"status": "stopped"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/transcribe")
|
||||||
|
async def transcribe_audio(request: TranscribeRequest):
|
||||||
|
"""
|
||||||
|
Transcribe base64-encoded audio data.
|
||||||
|
|
||||||
|
The audio should be in webm/opus format (as recorded by MediaRecorder).
|
||||||
|
"""
|
||||||
|
manager = get_sidecar_manager()
|
||||||
|
|
||||||
|
if not manager.ready:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Sidecar not ready. Please wait for model to load."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Decode base64 audio
|
||||||
|
audio_data = base64.b64decode(request.audio_data)
|
||||||
|
|
||||||
|
# Save to temp file
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".webm", delete=False) as f:
|
||||||
|
f.write(audio_data)
|
||||||
|
temp_path = f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Transcribe
|
||||||
|
result = await manager.transcribe_file(temp_path)
|
||||||
|
|
||||||
|
if result.get("error"):
|
||||||
|
raise HTTPException(status_code=500, detail=result["error"])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"result": result.get("result", ""),
|
||||||
|
"file": result.get("file", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Clean up temp file
|
||||||
|
os.unlink(temp_path)
|
||||||
|
|
||||||
|
except base64.binascii.Error:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid base64 audio data")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/transcribe-file")
|
||||||
|
async def transcribe_audio_file(file: UploadFile = File(...)):
|
||||||
|
"""
|
||||||
|
Transcribe an uploaded audio file.
|
||||||
|
|
||||||
|
Accepts common audio formats: mp3, wav, m4a, webm, ogg, flac, aac
|
||||||
|
"""
|
||||||
|
manager = get_sidecar_manager()
|
||||||
|
|
||||||
|
if not manager.ready:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Sidecar not ready. Please wait for model to load."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate file extension
|
||||||
|
allowed_extensions = {".mp3", ".wav", ".m4a", ".webm", ".ogg", ".flac", ".aac"}
|
||||||
|
ext = os.path.splitext(file.filename or "")[1].lower()
|
||||||
|
if ext not in allowed_extensions:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Unsupported audio format. Allowed: {', '.join(allowed_extensions)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Save uploaded file
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as f:
|
||||||
|
content = await file.read()
|
||||||
|
f.write(content)
|
||||||
|
temp_path = f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await manager.transcribe_file(temp_path)
|
||||||
|
|
||||||
|
if result.get("error"):
|
||||||
|
raise HTTPException(status_code=500, detail=result["error"])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"result": result.get("result", ""),
|
||||||
|
"filename": file.filename
|
||||||
|
}
|
||||||
|
|
||||||
|
finally:
|
||||||
|
os.unlink(temp_path)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/stream/start")
|
||||||
|
async def start_streaming():
|
||||||
|
"""
|
||||||
|
Start a streaming transcription session.
|
||||||
|
|
||||||
|
Returns a session ID that should be used for subsequent audio chunks.
|
||||||
|
"""
|
||||||
|
manager = get_sidecar_manager()
|
||||||
|
|
||||||
|
if not manager.ready:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Sidecar not ready. Please wait for model to load."
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await manager.start_stream()
|
||||||
|
|
||||||
|
if result.get("error"):
|
||||||
|
raise HTTPException(status_code=500, detail=result["error"])
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/stream/chunk")
|
||||||
|
async def send_audio_chunk(request: AudioChunkRequest):
|
||||||
|
"""
|
||||||
|
Send an audio chunk for streaming transcription.
|
||||||
|
|
||||||
|
The audio should be base64-encoded PCM data (16-bit, 16kHz, mono).
|
||||||
|
|
||||||
|
Returns a transcription segment if speech end was detected,
|
||||||
|
or null if more audio is needed.
|
||||||
|
"""
|
||||||
|
manager = get_sidecar_manager()
|
||||||
|
|
||||||
|
if not manager.ready:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Sidecar not ready"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await manager.send_audio_chunk(request.data)
|
||||||
|
|
||||||
|
# Result may be None if no segment ready yet
|
||||||
|
if result is None:
|
||||||
|
return {"segment": None}
|
||||||
|
|
||||||
|
if result.get("error"):
|
||||||
|
raise HTTPException(status_code=500, detail=result["error"])
|
||||||
|
|
||||||
|
return {"segment": result}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/stream/stop")
|
||||||
|
async def stop_streaming():
|
||||||
|
"""
|
||||||
|
Stop the streaming transcription session.
|
||||||
|
|
||||||
|
Returns any final transcription segments and session statistics.
|
||||||
|
"""
|
||||||
|
manager = get_sidecar_manager()
|
||||||
|
|
||||||
|
result = await manager.stop_stream()
|
||||||
|
|
||||||
|
if result.get("error"):
|
||||||
|
raise HTTPException(status_code=500, detail=result["error"])
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/segment-audio")
|
||||||
|
async def segment_audio_file(file: UploadFile = File(...), max_chunk_seconds: int = 300):
|
||||||
|
"""
|
||||||
|
Segment an audio file using VAD for natural speech boundaries.
|
||||||
|
|
||||||
|
This is used for processing large audio files before cloud transcription.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file: The audio file to segment
|
||||||
|
max_chunk_seconds: Maximum duration per chunk (default 300s / 5 minutes)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of segment metadata with file paths
|
||||||
|
"""
|
||||||
|
manager = get_sidecar_manager()
|
||||||
|
|
||||||
|
if not manager.ready:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Sidecar not ready. Please wait for model to load."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Save uploaded file
|
||||||
|
ext = os.path.splitext(file.filename or "")[1].lower() or ".wav"
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as f:
|
||||||
|
content = await file.read()
|
||||||
|
f.write(content)
|
||||||
|
temp_path = f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await manager.segment_audio(temp_path, max_chunk_seconds)
|
||||||
|
|
||||||
|
if result.get("error"):
|
||||||
|
raise HTTPException(status_code=500, detail=result["error"])
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Keep temp file for now - segments reference it
|
||||||
|
# Will be cleaned up by the transcription process
|
||||||
|
pass
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/ws")
|
||||||
|
async def websocket_endpoint(websocket: WebSocket):
|
||||||
|
"""
|
||||||
|
WebSocket endpoint for real-time streaming transcription.
|
||||||
|
|
||||||
|
Protocol:
|
||||||
|
1. Client connects
|
||||||
|
2. Client sends: {"action": "start_stream"}
|
||||||
|
3. Server responds: {"status": "streaming", "session_id": "..."}
|
||||||
|
4. Client sends: {"action": "audio_chunk", "data": "<base64_pcm>"}
|
||||||
|
5. Server responds: {"segment": {...}} when speech detected, or {"segment": null}
|
||||||
|
6. Client sends: {"action": "stop_stream"}
|
||||||
|
7. Server responds: {"status": "stream_stopped", ...}
|
||||||
|
"""
|
||||||
|
await websocket.accept()
|
||||||
|
|
||||||
|
manager = get_sidecar_manager()
|
||||||
|
|
||||||
|
if not manager.ready:
|
||||||
|
await websocket.send_json({"error": "Sidecar not ready"})
|
||||||
|
await websocket.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = await websocket.receive_json()
|
||||||
|
action = data.get("action")
|
||||||
|
|
||||||
|
if action == "start_stream":
|
||||||
|
result = await manager.start_stream()
|
||||||
|
await websocket.send_json(result)
|
||||||
|
|
||||||
|
elif action == "audio_chunk":
|
||||||
|
audio_data = data.get("data")
|
||||||
|
if audio_data:
|
||||||
|
result = await manager.send_audio_chunk(audio_data)
|
||||||
|
await websocket.send_json({"segment": result})
|
||||||
|
else:
|
||||||
|
await websocket.send_json({"error": "No audio data"})
|
||||||
|
|
||||||
|
elif action == "stop_stream":
|
||||||
|
result = await manager.stop_stream()
|
||||||
|
await websocket.send_json(result)
|
||||||
|
break
|
||||||
|
|
||||||
|
elif action == "ping":
|
||||||
|
await websocket.send_json({"status": "pong"})
|
||||||
|
|
||||||
|
else:
|
||||||
|
await websocket.send_json({"error": f"Unknown action: {action}"})
|
||||||
|
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
# Clean up streaming session if active
|
||||||
|
if manager._is_streaming():
|
||||||
|
await manager.stop_stream()
|
||||||
|
except Exception as e:
|
||||||
|
await websocket.send_json({"error": str(e)})
|
||||||
|
await websocket.close()
|
||||||
343
backend/app/sidecar_manager.py
Normal file
343
backend/app/sidecar_manager.py
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
"""
|
||||||
|
Sidecar Process Manager
|
||||||
|
|
||||||
|
Manages the Python sidecar process for speech-to-text transcription.
|
||||||
|
Provides an interface for the backend to communicate with the sidecar
|
||||||
|
via subprocess stdin/stdout.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import base64
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Dict, Any, Callable
|
||||||
|
from threading import Thread, Lock
|
||||||
|
import queue
|
||||||
|
|
||||||
|
|
||||||
|
class SidecarManager:
|
||||||
|
"""
|
||||||
|
Manages the Whisper transcription sidecar process.
|
||||||
|
|
||||||
|
The sidecar is a Python process running transcriber.py that handles
|
||||||
|
speech-to-text conversion using faster-whisper.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.process: Optional[subprocess.Popen] = None
|
||||||
|
self.ready = False
|
||||||
|
self.whisper_info: Optional[Dict] = None
|
||||||
|
self._lock = Lock()
|
||||||
|
self._response_queue = queue.Queue()
|
||||||
|
self._reader_thread: Optional[Thread] = None
|
||||||
|
self._progress_callbacks: list[Callable] = []
|
||||||
|
self._last_status: Dict[str, Any] = {}
|
||||||
|
self._is_packaged = getattr(sys, 'frozen', False)
|
||||||
|
|
||||||
|
# Paths - detect packaged vs development mode
|
||||||
|
if self._is_packaged:
|
||||||
|
# Packaged mode: executable at resources/backend/backend/backend.exe
|
||||||
|
# Sidecar at resources/sidecar/transcriber/transcriber.exe
|
||||||
|
exec_dir = Path(sys.executable).parent.parent # up from backend/backend.exe
|
||||||
|
resources_dir = exec_dir.parent # up from backend/ to resources/
|
||||||
|
self.sidecar_dir = resources_dir / "sidecar" / "transcriber"
|
||||||
|
self.transcriber_path = self.sidecar_dir / ("transcriber.exe" if sys.platform == "win32" else "transcriber")
|
||||||
|
self.venv_python = None # Not used in packaged mode
|
||||||
|
print(f"[Sidecar] Packaged mode: transcriber={self.transcriber_path}")
|
||||||
|
else:
|
||||||
|
# Development mode
|
||||||
|
self.project_dir = Path(__file__).parent.parent.parent
|
||||||
|
self.sidecar_dir = self.project_dir / "sidecar"
|
||||||
|
self.transcriber_path = self.sidecar_dir / "transcriber.py"
|
||||||
|
if sys.platform == "win32":
|
||||||
|
self.venv_python = self.sidecar_dir / "venv" / "Scripts" / "python.exe"
|
||||||
|
else:
|
||||||
|
self.venv_python = self.sidecar_dir / "venv" / "bin" / "python"
|
||||||
|
print(f"[Sidecar] Development mode: transcriber={self.transcriber_path}")
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
"""Check if sidecar is available (files exist)."""
|
||||||
|
if self._is_packaged:
|
||||||
|
# In packaged mode, just check the executable
|
||||||
|
available = self.transcriber_path.exists()
|
||||||
|
print(f"[Sidecar] is_available (packaged): {available}, path={self.transcriber_path}")
|
||||||
|
return available
|
||||||
|
else:
|
||||||
|
# Development mode - need both script and venv
|
||||||
|
available = self.transcriber_path.exists() and self.venv_python.exists()
|
||||||
|
print(f"[Sidecar] is_available (dev): {available}, script={self.transcriber_path.exists()}, venv={self.venv_python.exists()}")
|
||||||
|
return available
|
||||||
|
|
||||||
|
def get_status(self) -> Dict[str, Any]:
|
||||||
|
"""Get current sidecar status."""
|
||||||
|
return {
|
||||||
|
"ready": self.ready,
|
||||||
|
"streaming": self._is_streaming(),
|
||||||
|
"whisper": self.whisper_info,
|
||||||
|
"available": self.is_available(),
|
||||||
|
"browserMode": False,
|
||||||
|
**self._last_status
|
||||||
|
}
|
||||||
|
|
||||||
|
def _is_streaming(self) -> bool:
|
||||||
|
"""Check if currently in streaming mode."""
|
||||||
|
return self._last_status.get("streaming", False)
|
||||||
|
|
||||||
|
async def start(self) -> bool:
|
||||||
|
"""Start the sidecar process."""
|
||||||
|
if self.process and self.process.poll() is None:
|
||||||
|
return True # Already running
|
||||||
|
|
||||||
|
if not self.is_available():
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get Whisper configuration from environment
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["WHISPER_MODEL"] = os.getenv("WHISPER_MODEL", "medium")
|
||||||
|
env["WHISPER_DEVICE"] = os.getenv("WHISPER_DEVICE", "cpu")
|
||||||
|
env["WHISPER_COMPUTE"] = os.getenv("WHISPER_COMPUTE", "int8")
|
||||||
|
|
||||||
|
print(f"[Sidecar] Starting with model={env['WHISPER_MODEL']}, device={env['WHISPER_DEVICE']}, compute={env['WHISPER_COMPUTE']}")
|
||||||
|
|
||||||
|
# Build command based on mode
|
||||||
|
if self._is_packaged:
|
||||||
|
# Packaged mode: run the executable directly
|
||||||
|
cmd = [str(self.transcriber_path)]
|
||||||
|
cwd = str(self.sidecar_dir)
|
||||||
|
else:
|
||||||
|
# Development mode: use venv python
|
||||||
|
cmd = [str(self.venv_python), str(self.transcriber_path), "--server"]
|
||||||
|
cwd = str(self.sidecar_dir.parent) if self._is_packaged else str(self.sidecar_dir)
|
||||||
|
|
||||||
|
print(f"[Sidecar] Command: {cmd}, cwd={cwd}")
|
||||||
|
|
||||||
|
self.process = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
env=env,
|
||||||
|
cwd=cwd,
|
||||||
|
bufsize=1, # Line buffered
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start reader threads
|
||||||
|
self._reader_thread = Thread(target=self._read_stdout, daemon=True)
|
||||||
|
self._reader_thread.start()
|
||||||
|
|
||||||
|
stderr_thread = Thread(target=self._read_stderr, daemon=True)
|
||||||
|
stderr_thread.start()
|
||||||
|
|
||||||
|
# Wait for ready signal
|
||||||
|
try:
|
||||||
|
response = await asyncio.wait_for(
|
||||||
|
asyncio.get_event_loop().run_in_executor(
|
||||||
|
None, self._wait_for_ready
|
||||||
|
),
|
||||||
|
timeout=120.0 # 2 minutes for model download
|
||||||
|
)
|
||||||
|
if response and response.get("status") == "ready":
|
||||||
|
self.ready = True
|
||||||
|
print("[Sidecar] Ready")
|
||||||
|
return True
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
print("[Sidecar] Timeout waiting for ready")
|
||||||
|
self.stop()
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[Sidecar] Start error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _wait_for_ready(self) -> Optional[Dict]:
|
||||||
|
"""Wait for the ready signal from sidecar."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
response = self._response_queue.get(timeout=1.0)
|
||||||
|
status = response.get("status", "")
|
||||||
|
|
||||||
|
# Track progress events
|
||||||
|
if status in ["downloading_model", "model_downloaded", "model_cached",
|
||||||
|
"loading_model", "model_loaded", "model_error"]:
|
||||||
|
self._last_status = response
|
||||||
|
self._notify_progress(response)
|
||||||
|
|
||||||
|
if status == "model_loaded":
|
||||||
|
# Extract whisper info
|
||||||
|
self.whisper_info = {
|
||||||
|
"model": os.getenv("WHISPER_MODEL", "medium"),
|
||||||
|
"device": os.getenv("WHISPER_DEVICE", "cpu"),
|
||||||
|
"compute": os.getenv("WHISPER_COMPUTE", "int8"),
|
||||||
|
"configSource": "environment"
|
||||||
|
}
|
||||||
|
elif status == "model_error":
|
||||||
|
self.whisper_info = {"error": response.get("error", "Unknown error")}
|
||||||
|
|
||||||
|
if status == "ready":
|
||||||
|
return response
|
||||||
|
|
||||||
|
except queue.Empty:
|
||||||
|
if self.process and self.process.poll() is not None:
|
||||||
|
return None # Process died
|
||||||
|
continue
|
||||||
|
|
||||||
|
def _read_stdout(self):
|
||||||
|
"""Read stdout from sidecar process."""
|
||||||
|
if not self.process or not self.process.stdout:
|
||||||
|
return
|
||||||
|
|
||||||
|
for line in self.process.stdout:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
data = json.loads(line)
|
||||||
|
self._response_queue.put(data)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
print(f"[Sidecar] Invalid JSON: {line[:100]}")
|
||||||
|
|
||||||
|
def _read_stderr(self):
|
||||||
|
"""Read stderr from sidecar process."""
|
||||||
|
if not self.process or not self.process.stderr:
|
||||||
|
return
|
||||||
|
|
||||||
|
for line in self.process.stderr:
|
||||||
|
line = line.strip()
|
||||||
|
if line:
|
||||||
|
# Try to parse as JSON (some status messages go to stderr)
|
||||||
|
try:
|
||||||
|
data = json.loads(line)
|
||||||
|
if "status" in data or "warning" in data:
|
||||||
|
self._notify_progress(data)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
print(f"[Sidecar stderr] {line}")
|
||||||
|
|
||||||
|
def _notify_progress(self, data: Dict):
|
||||||
|
"""Notify all progress callbacks."""
|
||||||
|
for callback in self._progress_callbacks:
|
||||||
|
try:
|
||||||
|
callback(data)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[Sidecar] Progress callback error: {e}")
|
||||||
|
|
||||||
|
def add_progress_callback(self, callback: Callable):
|
||||||
|
"""Add a callback for progress updates."""
|
||||||
|
self._progress_callbacks.append(callback)
|
||||||
|
|
||||||
|
def remove_progress_callback(self, callback: Callable):
|
||||||
|
"""Remove a progress callback."""
|
||||||
|
if callback in self._progress_callbacks:
|
||||||
|
self._progress_callbacks.remove(callback)
|
||||||
|
|
||||||
|
async def send_command(self, command: Dict) -> Optional[Dict]:
|
||||||
|
"""Send a command to the sidecar and wait for response."""
|
||||||
|
if not self.process or self.process.poll() is not None:
|
||||||
|
return {"error": "Sidecar not running"}
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
try:
|
||||||
|
# Clear queue before sending
|
||||||
|
while not self._response_queue.empty():
|
||||||
|
try:
|
||||||
|
self._response_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Send command
|
||||||
|
cmd_json = json.dumps(command) + "\n"
|
||||||
|
self.process.stdin.write(cmd_json)
|
||||||
|
self.process.stdin.flush()
|
||||||
|
|
||||||
|
# Wait for response
|
||||||
|
try:
|
||||||
|
response = await asyncio.wait_for(
|
||||||
|
asyncio.get_event_loop().run_in_executor(
|
||||||
|
None, lambda: self._response_queue.get(timeout=60.0)
|
||||||
|
),
|
||||||
|
timeout=65.0
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
except (asyncio.TimeoutError, queue.Empty):
|
||||||
|
return {"error": "Command timeout"}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"Command error: {e}"}
|
||||||
|
|
||||||
|
async def transcribe_file(self, audio_path: str) -> Dict:
|
||||||
|
"""Transcribe an audio file."""
|
||||||
|
return await self.send_command({
|
||||||
|
"action": "transcribe",
|
||||||
|
"file": audio_path
|
||||||
|
}) or {"error": "No response"}
|
||||||
|
|
||||||
|
async def start_stream(self) -> Dict:
|
||||||
|
"""Start a streaming transcription session."""
|
||||||
|
result = await self.send_command({"action": "start_stream"})
|
||||||
|
if result and result.get("status") == "streaming":
|
||||||
|
self._last_status["streaming"] = True
|
||||||
|
return result or {"error": "No response"}
|
||||||
|
|
||||||
|
async def send_audio_chunk(self, base64_audio: str) -> Optional[Dict]:
|
||||||
|
"""Send an audio chunk for streaming transcription."""
|
||||||
|
return await self.send_command({
|
||||||
|
"action": "audio_chunk",
|
||||||
|
"data": base64_audio
|
||||||
|
})
|
||||||
|
|
||||||
|
async def stop_stream(self) -> Dict:
|
||||||
|
"""Stop the streaming session."""
|
||||||
|
result = await self.send_command({"action": "stop_stream"})
|
||||||
|
self._last_status["streaming"] = False
|
||||||
|
return result or {"error": "No response"}
|
||||||
|
|
||||||
|
async def segment_audio(self, file_path: str, max_chunk_seconds: int = 300) -> Dict:
|
||||||
|
"""Segment an audio file using VAD."""
|
||||||
|
return await self.send_command({
|
||||||
|
"action": "segment_audio",
|
||||||
|
"file_path": file_path,
|
||||||
|
"max_chunk_seconds": max_chunk_seconds
|
||||||
|
}) or {"error": "No response"}
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""Stop the sidecar process."""
|
||||||
|
self.ready = False
|
||||||
|
self._last_status = {}
|
||||||
|
|
||||||
|
if self.process:
|
||||||
|
try:
|
||||||
|
# Try graceful shutdown
|
||||||
|
self.process.stdin.write('{"action": "quit"}\n')
|
||||||
|
self.process.stdin.flush()
|
||||||
|
self.process.wait(timeout=5.0)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
if self.process.poll() is None:
|
||||||
|
self.process.terminate()
|
||||||
|
try:
|
||||||
|
self.process.wait(timeout=2.0)
|
||||||
|
except:
|
||||||
|
self.process.kill()
|
||||||
|
self.process = None
|
||||||
|
|
||||||
|
print("[Sidecar] Stopped")
|
||||||
|
|
||||||
|
|
||||||
|
# Global instance
|
||||||
|
_sidecar_manager: Optional[SidecarManager] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_sidecar_manager() -> SidecarManager:
|
||||||
|
"""Get or create the global sidecar manager instance."""
|
||||||
|
global _sidecar_manager
|
||||||
|
if _sidecar_manager is None:
|
||||||
|
_sidecar_manager = SidecarManager()
|
||||||
|
return _sidecar_manager
|
||||||
@@ -96,6 +96,8 @@ def build():
|
|||||||
"--hidden-import", "app.routers.meetings",
|
"--hidden-import", "app.routers.meetings",
|
||||||
"--hidden-import", "app.routers.ai",
|
"--hidden-import", "app.routers.ai",
|
||||||
"--hidden-import", "app.routers.export",
|
"--hidden-import", "app.routers.export",
|
||||||
|
"--hidden-import", "app.routers.sidecar",
|
||||||
|
"--hidden-import", "app.sidecar_manager",
|
||||||
"--hidden-import", "app.models",
|
"--hidden-import", "app.models",
|
||||||
"--hidden-import", "app.models.schemas",
|
"--hidden-import", "app.models.schemas",
|
||||||
# Collect package data
|
# Collect package data
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
"apiBaseUrl": "http://localhost:8000/api",
|
"apiBaseUrl": "http://localhost:8000/api",
|
||||||
"uploadTimeout": 600000,
|
"uploadTimeout": 600000,
|
||||||
"appTitle": "Meeting Assistant",
|
"appTitle": "Meeting Assistant",
|
||||||
|
"ui": {
|
||||||
|
"launchBrowser": true
|
||||||
|
},
|
||||||
"whisper": {
|
"whisper": {
|
||||||
"model": "medium",
|
"model": "medium",
|
||||||
"device": "cpu",
|
"device": "cpu",
|
||||||
|
|||||||
@@ -41,6 +41,26 @@
|
|||||||
{
|
{
|
||||||
"from": "config.json",
|
"from": "config.json",
|
||||||
"to": "config.json"
|
"to": "config.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "src/pages",
|
||||||
|
"to": "backend/client/pages",
|
||||||
|
"filter": ["**/*"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "src/styles",
|
||||||
|
"to": "backend/client/styles",
|
||||||
|
"filter": ["**/*"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "src/services",
|
||||||
|
"to": "backend/client/services",
|
||||||
|
"filter": ["**/*"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "src/config",
|
||||||
|
"to": "backend/client/config",
|
||||||
|
"filter": ["**/*"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"win": {
|
"win": {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const { app, BrowserWindow, ipcMain, session } = require("electron");
|
const { app, BrowserWindow, ipcMain, session, shell } = require("electron");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const { spawn } = require("child_process");
|
const { spawn } = require("child_process");
|
||||||
@@ -288,9 +288,12 @@ function createWindow() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function startSidecar() {
|
function startSidecar() {
|
||||||
|
console.log("=== startSidecar() called ===");
|
||||||
const sidecarDir = app.isPackaged
|
const sidecarDir = app.isPackaged
|
||||||
? path.join(process.resourcesPath, "sidecar")
|
? path.join(process.resourcesPath, "sidecar")
|
||||||
: path.join(__dirname, "..", "..", "sidecar");
|
: path.join(__dirname, "..", "..", "sidecar");
|
||||||
|
console.log("Sidecar directory:", sidecarDir);
|
||||||
|
console.log("App is packaged:", app.isPackaged);
|
||||||
|
|
||||||
// Determine the sidecar executable path based on packaging and platform
|
// Determine the sidecar executable path based on packaging and platform
|
||||||
let sidecarExecutable;
|
let sidecarExecutable;
|
||||||
@@ -327,11 +330,13 @@ function startSidecar() {
|
|||||||
sidecarArgs = [sidecarScript];
|
sidecarArgs = [sidecarScript];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log("Checking sidecar executable at:", sidecarExecutable);
|
||||||
if (!fs.existsSync(sidecarExecutable)) {
|
if (!fs.existsSync(sidecarExecutable)) {
|
||||||
console.log("Sidecar executable not found at:", sidecarExecutable);
|
console.log("ERROR: Sidecar executable not found at:", sidecarExecutable);
|
||||||
console.log("Transcription will not be available.");
|
console.log("Transcription will not be available.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
console.log("Sidecar executable found:", sidecarExecutable);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get Whisper configuration from config.json or environment variables
|
// Get Whisper configuration from config.json or environment variables
|
||||||
@@ -434,10 +439,16 @@ function startSidecar() {
|
|||||||
mainWindow.webContents.send("model-download-progress", msg);
|
mainWindow.webContents.send("model-download-progress", msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Forward model error status
|
// Forward model error status and mark sidecar as not ready
|
||||||
if (msg.status === "model_error" && mainWindow) {
|
if (msg.status === "model_error") {
|
||||||
|
sidecarReady = false;
|
||||||
|
if (activeWhisperConfig) {
|
||||||
|
activeWhisperConfig.error = msg.error || "Model load failed";
|
||||||
|
}
|
||||||
|
if (mainWindow) {
|
||||||
mainWindow.webContents.send("model-download-progress", msg);
|
mainWindow.webContents.send("model-download-progress", msg);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log("Sidecar output:", line);
|
console.log("Sidecar output:", line);
|
||||||
}
|
}
|
||||||
@@ -466,6 +477,57 @@ app.whenReady().then(async () => {
|
|||||||
// Load configuration first
|
// Load configuration first
|
||||||
loadConfig();
|
loadConfig();
|
||||||
|
|
||||||
|
const backendConfig = appConfig?.backend || {};
|
||||||
|
const uiConfig = appConfig?.ui || {};
|
||||||
|
const launchBrowser = uiConfig.launchBrowser === true;
|
||||||
|
|
||||||
|
console.log("=== Startup Mode Check ===");
|
||||||
|
console.log("uiConfig:", JSON.stringify(uiConfig));
|
||||||
|
console.log("launchBrowser:", launchBrowser);
|
||||||
|
console.log("backendConfig.embedded:", backendConfig.embedded);
|
||||||
|
console.log("Will use browser mode:", launchBrowser && backendConfig.embedded);
|
||||||
|
|
||||||
|
// Browser-only mode: start backend and open browser, no Electron UI
|
||||||
|
if (launchBrowser && backendConfig.embedded) {
|
||||||
|
console.log("=== Browser-Only Mode ===");
|
||||||
|
|
||||||
|
// Set BROWSER_MODE so backend manages sidecar
|
||||||
|
process.env.BROWSER_MODE = "true";
|
||||||
|
|
||||||
|
// Start backend sidecar
|
||||||
|
startBackendSidecar();
|
||||||
|
|
||||||
|
// Wait for backend to be ready
|
||||||
|
const ready = await waitForBackendReady();
|
||||||
|
if (!ready) {
|
||||||
|
const { dialog } = require("electron");
|
||||||
|
dialog.showErrorBox(
|
||||||
|
"Backend Startup Failed",
|
||||||
|
"後端服務啟動失敗。請檢查日誌以獲取詳細信息。"
|
||||||
|
);
|
||||||
|
app.quit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open browser to login page
|
||||||
|
const host = backendConfig.host || "127.0.0.1";
|
||||||
|
const port = backendConfig.port || 8000;
|
||||||
|
const loginUrl = `http://${host}:${port}/login`;
|
||||||
|
|
||||||
|
console.log(`Opening browser: ${loginUrl}`);
|
||||||
|
await shell.openExternal(loginUrl);
|
||||||
|
|
||||||
|
// Keep app running in background
|
||||||
|
// On macOS, we need to handle dock visibility
|
||||||
|
if (process.platform === "darwin") {
|
||||||
|
app.dock.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Backend running. Close this window or press Ctrl+C to stop.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard Electron mode
|
||||||
// Grant microphone permission automatically
|
// Grant microphone permission automatically
|
||||||
session.defaultSession.setPermissionRequestHandler((webContents, permission, callback, details) => {
|
session.defaultSession.setPermissionRequestHandler((webContents, permission, callback, details) => {
|
||||||
console.log(`Permission request: ${permission}`, details);
|
console.log(`Permission request: ${permission}`, details);
|
||||||
@@ -501,7 +563,6 @@ app.whenReady().then(async () => {
|
|||||||
startBackendSidecar();
|
startBackendSidecar();
|
||||||
|
|
||||||
// Wait for backend to be ready before creating window
|
// Wait for backend to be ready before creating window
|
||||||
const backendConfig = appConfig?.backend || {};
|
|
||||||
if (backendConfig.embedded) {
|
if (backendConfig.embedded) {
|
||||||
const ready = await waitForBackendReady();
|
const ready = await waitForBackendReady();
|
||||||
if (!ready) {
|
if (!ready) {
|
||||||
@@ -713,3 +774,42 @@ ipcMain.handle("transcribe-audio", async (event, audioFilePath) => {
|
|||||||
}, 60000);
|
}, 60000);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// === Browser Mode Handler ===
|
||||||
|
// Opens the current page in the system's default browser
|
||||||
|
// This is useful when Electron's audio access is blocked by security software
|
||||||
|
|
||||||
|
ipcMain.handle("open-in-browser", async () => {
|
||||||
|
const backendConfig = appConfig?.backend || {};
|
||||||
|
const host = backendConfig.host || "127.0.0.1";
|
||||||
|
const port = backendConfig.port || 8000;
|
||||||
|
|
||||||
|
// Determine the current page URL and preserve query parameters
|
||||||
|
let currentPage = "login";
|
||||||
|
let queryString = "";
|
||||||
|
|
||||||
|
if (mainWindow) {
|
||||||
|
const currentUrl = mainWindow.webContents.getURL();
|
||||||
|
|
||||||
|
// Parse query string from current URL (e.g., ?id=123)
|
||||||
|
const urlMatch = currentUrl.match(/\?(.+)$/);
|
||||||
|
if (urlMatch) {
|
||||||
|
queryString = "?" + urlMatch[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentUrl.includes("meetings.html")) {
|
||||||
|
currentPage = "meetings";
|
||||||
|
} else if (currentUrl.includes("meeting-detail.html")) {
|
||||||
|
currentPage = "meeting-detail";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const browserUrl = `http://${host}:${port}/${currentPage}${queryString}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await shell.openExternal(browserUrl);
|
||||||
|
return { success: true, url: browserUrl };
|
||||||
|
} catch (error) {
|
||||||
|
return { error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -26,6 +26,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="module">
|
<script type="module">
|
||||||
|
// Browser mode polyfill (must be first)
|
||||||
|
import '../services/browser-api.js';
|
||||||
import { initApp } from '../services/init.js';
|
import { initApp } from '../services/init.js';
|
||||||
import { login } from '../services/api.js';
|
import { login } from '../services/api.js';
|
||||||
|
|
||||||
|
|||||||
@@ -139,6 +139,201 @@
|
|||||||
border-color: #2196F3;
|
border-color: #2196F3;
|
||||||
box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.2);
|
box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.2);
|
||||||
}
|
}
|
||||||
|
/* Audio Device Settings Panel */
|
||||||
|
.audio-device-panel {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.audio-device-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 15px;
|
||||||
|
background: #e9ecef;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.audio-device-header:hover {
|
||||||
|
background: #dee2e6;
|
||||||
|
}
|
||||||
|
.audio-device-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
.audio-device-toggle {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6c757d;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
.audio-device-panel.collapsed .audio-device-toggle {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
.audio-device-panel.collapsed .audio-device-body {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.audio-device-body {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
.audio-device-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.audio-device-row:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.audio-device-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #495057;
|
||||||
|
min-width: 70px;
|
||||||
|
}
|
||||||
|
.audio-device-select {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #ced4da;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
.audio-device-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #2196F3;
|
||||||
|
box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.2);
|
||||||
|
}
|
||||||
|
.audio-refresh-btn {
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #ced4da;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.audio-refresh-btn:hover {
|
||||||
|
background: #e9ecef;
|
||||||
|
}
|
||||||
|
/* Volume Meter */
|
||||||
|
.volume-meter-container {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.volume-meter {
|
||||||
|
flex: 1;
|
||||||
|
height: 20px;
|
||||||
|
background: #e9ecef;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.volume-meter-fill {
|
||||||
|
height: 100%;
|
||||||
|
width: 0%;
|
||||||
|
background: linear-gradient(to right, #28a745, #ffc107, #dc3545);
|
||||||
|
transition: width 0.05s ease-out;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.volume-meter-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6c757d;
|
||||||
|
min-width: 40px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
/* Test Controls */
|
||||||
|
.audio-test-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.audio-test-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.audio-test-btn.record {
|
||||||
|
background: #dc3545;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.audio-test-btn.record:hover:not(:disabled) {
|
||||||
|
background: #c82333;
|
||||||
|
}
|
||||||
|
.audio-test-btn.record.recording {
|
||||||
|
background: #6c757d;
|
||||||
|
}
|
||||||
|
.audio-test-btn.play {
|
||||||
|
background: #28a745;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.audio-test-btn.play:hover:not(:disabled) {
|
||||||
|
background: #218838;
|
||||||
|
}
|
||||||
|
.audio-test-btn.play.playing {
|
||||||
|
background: #6c757d;
|
||||||
|
}
|
||||||
|
.audio-test-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.audio-status {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6c757d;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.audio-status.success {
|
||||||
|
color: #28a745;
|
||||||
|
}
|
||||||
|
.audio-status.error {
|
||||||
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
.audio-status.recording {
|
||||||
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
.no-input-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #dc3545;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
/* Browser Mode Hint */
|
||||||
|
.browser-mode-hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 15px;
|
||||||
|
background: #fff3cd;
|
||||||
|
border: 1px solid #ffc107;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
.browser-mode-hint.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.browser-mode-btn {
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.browser-mode-btn:hover {
|
||||||
|
background: #0056b3;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -164,6 +359,52 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Audio Device Settings Panel -->
|
||||||
|
<div id="audio-device-panel" class="audio-device-panel">
|
||||||
|
<div class="audio-device-header" id="audio-device-header">
|
||||||
|
<h3>音訊設備設定</h3>
|
||||||
|
<span class="audio-device-toggle">▼</span>
|
||||||
|
</div>
|
||||||
|
<div class="audio-device-body">
|
||||||
|
<!-- Device Selection Row -->
|
||||||
|
<div class="audio-device-row">
|
||||||
|
<span class="audio-device-label">麥克風:</span>
|
||||||
|
<select id="audio-device-select" class="audio-device-select">
|
||||||
|
<option value="">載入中...</option>
|
||||||
|
</select>
|
||||||
|
<button id="audio-refresh-btn" class="audio-refresh-btn" title="重新整理設備清單">🔄</button>
|
||||||
|
</div>
|
||||||
|
<!-- Volume Meter Row -->
|
||||||
|
<div class="audio-device-row">
|
||||||
|
<span class="audio-device-label">輸入音量:</span>
|
||||||
|
<div class="volume-meter-container">
|
||||||
|
<div class="volume-meter">
|
||||||
|
<div id="volume-meter-fill" class="volume-meter-fill"></div>
|
||||||
|
</div>
|
||||||
|
<span id="volume-meter-text" class="volume-meter-text">0%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Test Controls Row -->
|
||||||
|
<div class="audio-device-row">
|
||||||
|
<span class="audio-device-label">收音測試:</span>
|
||||||
|
<div class="audio-test-controls">
|
||||||
|
<button id="test-record-btn" class="audio-test-btn record" title="錄製 5 秒測試音訊">
|
||||||
|
🎤 測試錄音
|
||||||
|
</button>
|
||||||
|
<button id="test-play-btn" class="audio-test-btn play" disabled title="播放測試錄音">
|
||||||
|
▶️ 播放測試
|
||||||
|
</button>
|
||||||
|
<span id="audio-status" class="audio-status">準備就緒</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Browser Mode Hint (shown when audio access fails) -->
|
||||||
|
<div id="browser-mode-hint" class="browser-mode-hint hidden">
|
||||||
|
<span>無法存取麥克風?安全軟體可能阻擋了 Electron。請嘗試在瀏覽器中開啟。</span>
|
||||||
|
<button id="open-browser-btn" class="browser-mode-btn">在瀏覽器中開啟</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Dual Panel Layout -->
|
<!-- Dual Panel Layout -->
|
||||||
<div class="dual-panel">
|
<div class="dual-panel">
|
||||||
<!-- Left Panel: Transcript -->
|
<!-- Left Panel: Transcript -->
|
||||||
@@ -236,6 +477,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="module">
|
<script type="module">
|
||||||
|
// Browser mode polyfill (must be first)
|
||||||
|
import '../services/browser-api.js';
|
||||||
import { initApp } from '../services/init.js';
|
import { initApp } from '../services/init.js';
|
||||||
import {
|
import {
|
||||||
getMeeting,
|
getMeeting,
|
||||||
@@ -284,15 +527,526 @@
|
|||||||
const uploadProgressFill = document.getElementById('upload-progress-fill');
|
const uploadProgressFill = document.getElementById('upload-progress-fill');
|
||||||
const whisperStatusEl = document.getElementById('whisper-status');
|
const whisperStatusEl = document.getElementById('whisper-status');
|
||||||
|
|
||||||
|
// Audio Device Settings Elements
|
||||||
|
const audioDevicePanel = document.getElementById('audio-device-panel');
|
||||||
|
const audioDeviceHeader = document.getElementById('audio-device-header');
|
||||||
|
const audioDeviceSelect = document.getElementById('audio-device-select');
|
||||||
|
const audioRefreshBtn = document.getElementById('audio-refresh-btn');
|
||||||
|
const volumeMeterFill = document.getElementById('volume-meter-fill');
|
||||||
|
const volumeMeterText = document.getElementById('volume-meter-text');
|
||||||
|
const testRecordBtn = document.getElementById('test-record-btn');
|
||||||
|
const testPlayBtn = document.getElementById('test-play-btn');
|
||||||
|
const audioStatusEl = document.getElementById('audio-status');
|
||||||
|
const browserModeHint = document.getElementById('browser-mode-hint');
|
||||||
|
const openBrowserBtn = document.getElementById('open-browser-btn');
|
||||||
|
|
||||||
|
// Audio Device State
|
||||||
|
const audioDeviceState = {
|
||||||
|
availableDevices: [],
|
||||||
|
selectedDeviceId: null,
|
||||||
|
isMonitoring: false,
|
||||||
|
monitoringStream: null,
|
||||||
|
monitoringContext: null,
|
||||||
|
monitoringAnalyser: null,
|
||||||
|
animationFrameId: null,
|
||||||
|
testRecordingBlob: null,
|
||||||
|
testState: 'idle', // 'idle' | 'recording' | 'playing'
|
||||||
|
testMediaRecorder: null,
|
||||||
|
testAudioElement: null,
|
||||||
|
testCountdown: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Audio Device Management Functions
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// Check if deviceId is an alias (not a real device ID)
|
||||||
|
function isAliasDeviceId(id) {
|
||||||
|
return id === 'default' || id === 'communications' || !id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enumerate audio devices and populate dropdown
|
||||||
|
async function enumerateAudioDevices() {
|
||||||
|
try {
|
||||||
|
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||||
|
const audioInputs = devices.filter(d => d.kind === 'audioinput');
|
||||||
|
|
||||||
|
// Filter out virtual devices like Stereo Mix
|
||||||
|
const realDevices = audioInputs.filter(d =>
|
||||||
|
!d.label.includes('立體聲混音') &&
|
||||||
|
!d.label.toLowerCase().includes('stereo mix')
|
||||||
|
);
|
||||||
|
|
||||||
|
audioDeviceState.availableDevices = realDevices;
|
||||||
|
|
||||||
|
// Populate dropdown
|
||||||
|
audioDeviceSelect.innerHTML = '';
|
||||||
|
|
||||||
|
if (realDevices.length === 0) {
|
||||||
|
audioDeviceSelect.innerHTML = '<option value="">未偵測到麥克風</option>';
|
||||||
|
setAudioStatus('未偵測到麥克風', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
realDevices.forEach((device, index) => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = device.deviceId;
|
||||||
|
|
||||||
|
// Create friendly label
|
||||||
|
let label = device.label || `麥克風 ${index + 1}`;
|
||||||
|
if (device.deviceId === 'default') {
|
||||||
|
label = `🔹 ${label} (系統預設)`;
|
||||||
|
} else if (device.deviceId === 'communications') {
|
||||||
|
label = `📞 ${label} (通訊裝置)`;
|
||||||
|
}
|
||||||
|
option.textContent = label;
|
||||||
|
audioDeviceSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Try to restore saved preference
|
||||||
|
const savedDeviceId = localStorage.getItem('audioDevice.selectedId');
|
||||||
|
const savedDevice = realDevices.find(d => d.deviceId === savedDeviceId);
|
||||||
|
|
||||||
|
if (savedDevice) {
|
||||||
|
audioDeviceSelect.value = savedDeviceId;
|
||||||
|
audioDeviceState.selectedDeviceId = savedDeviceId;
|
||||||
|
} else {
|
||||||
|
// Prefer non-alias device
|
||||||
|
const preferredDevice = realDevices.find(d => !isAliasDeviceId(d.deviceId)) || realDevices[0];
|
||||||
|
if (preferredDevice) {
|
||||||
|
audioDeviceSelect.value = preferredDevice.deviceId;
|
||||||
|
audioDeviceState.selectedDeviceId = preferredDevice.deviceId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Audio devices enumerated:', realDevices.length, realDevices);
|
||||||
|
setAudioStatus('準備就緒', 'success');
|
||||||
|
|
||||||
|
// Start volume monitoring with selected device
|
||||||
|
await startVolumeMonitoring();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to enumerate audio devices:', error);
|
||||||
|
audioDeviceSelect.innerHTML = '<option value="">無法存取麥克風</option>';
|
||||||
|
setAudioStatus('無法存取麥克風: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select audio device
|
||||||
|
async function selectAudioDevice(deviceId) {
|
||||||
|
audioDeviceState.selectedDeviceId = deviceId;
|
||||||
|
|
||||||
|
// Save preference
|
||||||
|
if (deviceId) {
|
||||||
|
localStorage.setItem('audioDevice.selectedId', deviceId);
|
||||||
|
const device = audioDeviceState.availableDevices.find(d => d.deviceId === deviceId);
|
||||||
|
if (device) {
|
||||||
|
localStorage.setItem('audioDevice.lastUsedLabel', device.label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restart volume monitoring with new device
|
||||||
|
await startVolumeMonitoring();
|
||||||
|
|
||||||
|
console.log('Selected audio device:', deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start volume monitoring
|
||||||
|
async function startVolumeMonitoring() {
|
||||||
|
// Stop existing monitoring
|
||||||
|
stopVolumeMonitoring();
|
||||||
|
|
||||||
|
const deviceId = audioDeviceState.selectedDeviceId;
|
||||||
|
if (!deviceId && audioDeviceState.availableDevices.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get audio stream
|
||||||
|
let constraints;
|
||||||
|
if (isAliasDeviceId(deviceId)) {
|
||||||
|
constraints = { audio: true };
|
||||||
|
} else {
|
||||||
|
constraints = { audio: { deviceId: { exact: deviceId } } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||||
|
audioDeviceState.monitoringStream = stream;
|
||||||
|
|
||||||
|
// Create audio context and analyser
|
||||||
|
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
const analyser = audioContext.createAnalyser();
|
||||||
|
analyser.fftSize = 256;
|
||||||
|
analyser.smoothingTimeConstant = 0.3;
|
||||||
|
|
||||||
|
const source = audioContext.createMediaStreamSource(stream);
|
||||||
|
source.connect(analyser);
|
||||||
|
|
||||||
|
audioDeviceState.monitoringContext = audioContext;
|
||||||
|
audioDeviceState.monitoringAnalyser = analyser;
|
||||||
|
audioDeviceState.isMonitoring = true;
|
||||||
|
|
||||||
|
// Start animation loop for volume meter
|
||||||
|
updateVolumeMeter();
|
||||||
|
|
||||||
|
setAudioStatus('正在監聽...', 'success');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to start volume monitoring:', error);
|
||||||
|
|
||||||
|
if (error.name === 'NotAllowedError') {
|
||||||
|
setAudioStatus('麥克風權限被拒絕', 'error');
|
||||||
|
} else if (error.name === 'NotReadableError') {
|
||||||
|
setAudioStatus('麥克風被其他應用程式佔用', 'error');
|
||||||
|
} else {
|
||||||
|
setAudioStatus('無法存取麥克風', 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show browser mode hint when audio access fails (only in Electron)
|
||||||
|
if (window.electronAPI && window.electronAPI.openInBrowser) {
|
||||||
|
browserModeHint.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop volume monitoring
|
||||||
|
function stopVolumeMonitoring() {
|
||||||
|
if (audioDeviceState.animationFrameId) {
|
||||||
|
cancelAnimationFrame(audioDeviceState.animationFrameId);
|
||||||
|
audioDeviceState.animationFrameId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audioDeviceState.monitoringStream) {
|
||||||
|
audioDeviceState.monitoringStream.getTracks().forEach(track => track.stop());
|
||||||
|
audioDeviceState.monitoringStream = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audioDeviceState.monitoringContext) {
|
||||||
|
audioDeviceState.monitoringContext.close();
|
||||||
|
audioDeviceState.monitoringContext = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
audioDeviceState.monitoringAnalyser = null;
|
||||||
|
audioDeviceState.isMonitoring = false;
|
||||||
|
|
||||||
|
volumeMeterFill.style.width = '0%';
|
||||||
|
volumeMeterText.textContent = '0%';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update volume meter (animation loop)
|
||||||
|
function updateVolumeMeter() {
|
||||||
|
if (!audioDeviceState.isMonitoring || !audioDeviceState.monitoringAnalyser) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const analyser = audioDeviceState.monitoringAnalyser;
|
||||||
|
const dataArray = new Uint8Array(analyser.frequencyBinCount);
|
||||||
|
analyser.getByteFrequencyData(dataArray);
|
||||||
|
|
||||||
|
// Calculate RMS (root mean square) for more accurate volume
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < dataArray.length; i++) {
|
||||||
|
sum += dataArray[i] * dataArray[i];
|
||||||
|
}
|
||||||
|
const rms = Math.sqrt(sum / dataArray.length);
|
||||||
|
|
||||||
|
// Normalize to 0-100 range
|
||||||
|
const level = Math.min(100, Math.round((rms / 128) * 100));
|
||||||
|
|
||||||
|
volumeMeterFill.style.width = level + '%';
|
||||||
|
volumeMeterText.textContent = level + '%';
|
||||||
|
|
||||||
|
// Continue animation loop
|
||||||
|
audioDeviceState.animationFrameId = requestAnimationFrame(updateVolumeMeter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set audio status message
|
||||||
|
function setAudioStatus(message, type = '') {
|
||||||
|
audioStatusEl.textContent = message;
|
||||||
|
audioStatusEl.className = 'audio-status';
|
||||||
|
if (type) {
|
||||||
|
audioStatusEl.classList.add(type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start test recording (5 seconds)
|
||||||
|
async function startTestRecording() {
|
||||||
|
if (audioDeviceState.testState !== 'idle') return;
|
||||||
|
|
||||||
|
const deviceId = audioDeviceState.selectedDeviceId;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get audio stream
|
||||||
|
let constraints;
|
||||||
|
if (isAliasDeviceId(deviceId)) {
|
||||||
|
constraints = { audio: true };
|
||||||
|
} else {
|
||||||
|
constraints = { audio: { deviceId: { exact: deviceId } } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||||
|
|
||||||
|
// Create MediaRecorder
|
||||||
|
const mediaRecorder = new MediaRecorder(stream, {
|
||||||
|
mimeType: 'audio/webm;codecs=opus'
|
||||||
|
});
|
||||||
|
|
||||||
|
const chunks = [];
|
||||||
|
mediaRecorder.ondataavailable = (e) => {
|
||||||
|
if (e.data.size > 0) {
|
||||||
|
chunks.push(e.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaRecorder.onstop = () => {
|
||||||
|
// Create blob from chunks
|
||||||
|
audioDeviceState.testRecordingBlob = new Blob(chunks, { type: 'audio/webm' });
|
||||||
|
stream.getTracks().forEach(track => track.stop());
|
||||||
|
|
||||||
|
// Update UI
|
||||||
|
audioDeviceState.testState = 'idle';
|
||||||
|
testRecordBtn.textContent = '🎤 測試錄音';
|
||||||
|
testRecordBtn.classList.remove('recording');
|
||||||
|
testRecordBtn.disabled = false;
|
||||||
|
testPlayBtn.disabled = false;
|
||||||
|
setAudioStatus('錄音完成,可播放測試', 'success');
|
||||||
|
|
||||||
|
// Restart volume monitoring
|
||||||
|
startVolumeMonitoring();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stop volume monitoring during test recording
|
||||||
|
stopVolumeMonitoring();
|
||||||
|
|
||||||
|
// Start recording
|
||||||
|
audioDeviceState.testMediaRecorder = mediaRecorder;
|
||||||
|
audioDeviceState.testState = 'recording';
|
||||||
|
audioDeviceState.testCountdown = 5;
|
||||||
|
mediaRecorder.start(100);
|
||||||
|
|
||||||
|
// Update UI
|
||||||
|
testRecordBtn.classList.add('recording');
|
||||||
|
testPlayBtn.disabled = true;
|
||||||
|
updateTestRecordingCountdown();
|
||||||
|
|
||||||
|
// Auto-stop after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
if (audioDeviceState.testState === 'recording') {
|
||||||
|
stopTestRecording();
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to start test recording:', error);
|
||||||
|
|
||||||
|
if (error.name === 'NotAllowedError') {
|
||||||
|
setAudioStatus('麥克風權限被拒絕', 'error');
|
||||||
|
} else if (error.name === 'NotReadableError') {
|
||||||
|
setAudioStatus('麥克風被其他應用程式佔用', 'error');
|
||||||
|
} else {
|
||||||
|
setAudioStatus('無法開始錄音: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
audioDeviceState.testState = 'idle';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update countdown during test recording
|
||||||
|
function updateTestRecordingCountdown() {
|
||||||
|
if (audioDeviceState.testState !== 'recording') return;
|
||||||
|
|
||||||
|
testRecordBtn.textContent = `⏹️ 錄音中... ${audioDeviceState.testCountdown}s`;
|
||||||
|
setAudioStatus(`錄音中... ${audioDeviceState.testCountdown} 秒`, 'recording');
|
||||||
|
|
||||||
|
if (audioDeviceState.testCountdown > 0) {
|
||||||
|
audioDeviceState.testCountdown--;
|
||||||
|
setTimeout(updateTestRecordingCountdown, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop test recording
|
||||||
|
function stopTestRecording() {
|
||||||
|
if (audioDeviceState.testState !== 'recording') return;
|
||||||
|
|
||||||
|
if (audioDeviceState.testMediaRecorder && audioDeviceState.testMediaRecorder.state !== 'inactive') {
|
||||||
|
audioDeviceState.testMediaRecorder.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Play test recording
|
||||||
|
function playTestRecording() {
|
||||||
|
if (!audioDeviceState.testRecordingBlob || audioDeviceState.testState !== 'idle') return;
|
||||||
|
|
||||||
|
// Create audio element
|
||||||
|
const blobUrl = URL.createObjectURL(audioDeviceState.testRecordingBlob);
|
||||||
|
const audio = new Audio(blobUrl);
|
||||||
|
audioDeviceState.testAudioElement = audio;
|
||||||
|
|
||||||
|
audio.onplay = () => {
|
||||||
|
audioDeviceState.testState = 'playing';
|
||||||
|
testPlayBtn.textContent = '⏹️ 停止播放';
|
||||||
|
testPlayBtn.classList.add('playing');
|
||||||
|
testRecordBtn.disabled = true;
|
||||||
|
setAudioStatus('播放中...', 'success');
|
||||||
|
};
|
||||||
|
|
||||||
|
audio.onended = () => {
|
||||||
|
audioDeviceState.testState = 'idle';
|
||||||
|
testPlayBtn.textContent = '▶️ 播放測試';
|
||||||
|
testPlayBtn.classList.remove('playing');
|
||||||
|
testRecordBtn.disabled = false;
|
||||||
|
setAudioStatus('播放完成', 'success');
|
||||||
|
URL.revokeObjectURL(blobUrl);
|
||||||
|
audioDeviceState.testAudioElement = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
audio.onerror = () => {
|
||||||
|
audioDeviceState.testState = 'idle';
|
||||||
|
testPlayBtn.textContent = '▶️ 播放測試';
|
||||||
|
testPlayBtn.classList.remove('playing');
|
||||||
|
testRecordBtn.disabled = false;
|
||||||
|
setAudioStatus('播放失敗', 'error');
|
||||||
|
URL.revokeObjectURL(blobUrl);
|
||||||
|
audioDeviceState.testAudioElement = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
audio.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop test playback
|
||||||
|
function stopTestPlayback() {
|
||||||
|
if (audioDeviceState.testAudioElement) {
|
||||||
|
audioDeviceState.testAudioElement.pause();
|
||||||
|
audioDeviceState.testAudioElement.currentTime = 0;
|
||||||
|
|
||||||
|
// Trigger onended manually
|
||||||
|
audioDeviceState.testState = 'idle';
|
||||||
|
testPlayBtn.textContent = '▶️ 播放測試';
|
||||||
|
testPlayBtn.classList.remove('playing');
|
||||||
|
testRecordBtn.disabled = false;
|
||||||
|
setAudioStatus('準備就緒', 'success');
|
||||||
|
|
||||||
|
if (audioDeviceState.testAudioElement.src) {
|
||||||
|
URL.revokeObjectURL(audioDeviceState.testAudioElement.src);
|
||||||
|
}
|
||||||
|
audioDeviceState.testAudioElement = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle panel collapse
|
||||||
|
function toggleAudioDevicePanel() {
|
||||||
|
audioDevicePanel.classList.toggle('collapsed');
|
||||||
|
const isCollapsed = audioDevicePanel.classList.contains('collapsed');
|
||||||
|
localStorage.setItem('audioDevice.panelCollapsed', isCollapsed);
|
||||||
|
|
||||||
|
if (isCollapsed) {
|
||||||
|
stopVolumeMonitoring();
|
||||||
|
} else {
|
||||||
|
startVolumeMonitoring();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize audio device panel
|
||||||
|
async function initAudioDevicePanel() {
|
||||||
|
// Restore panel collapse state
|
||||||
|
const isCollapsed = localStorage.getItem('audioDevice.panelCollapsed') === 'true';
|
||||||
|
if (isCollapsed) {
|
||||||
|
audioDevicePanel.classList.add('collapsed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event listeners
|
||||||
|
audioDeviceHeader.addEventListener('click', toggleAudioDevicePanel);
|
||||||
|
|
||||||
|
audioDeviceSelect.addEventListener('change', (e) => {
|
||||||
|
selectAudioDevice(e.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
audioRefreshBtn.addEventListener('click', async () => {
|
||||||
|
setAudioStatus('重新整理中...', '');
|
||||||
|
await enumerateAudioDevices();
|
||||||
|
});
|
||||||
|
|
||||||
|
testRecordBtn.addEventListener('click', () => {
|
||||||
|
if (audioDeviceState.testState === 'idle') {
|
||||||
|
startTestRecording();
|
||||||
|
} else if (audioDeviceState.testState === 'recording') {
|
||||||
|
stopTestRecording();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
testPlayBtn.addEventListener('click', () => {
|
||||||
|
if (audioDeviceState.testState === 'idle') {
|
||||||
|
playTestRecording();
|
||||||
|
} else if (audioDeviceState.testState === 'playing') {
|
||||||
|
stopTestPlayback();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Browser mode button - opens in system browser when audio is blocked
|
||||||
|
if (openBrowserBtn && window.electronAPI && window.electronAPI.openInBrowser) {
|
||||||
|
openBrowserBtn.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
openBrowserBtn.disabled = true;
|
||||||
|
openBrowserBtn.textContent = '開啟中...';
|
||||||
|
const result = await window.electronAPI.openInBrowser();
|
||||||
|
if (result.error) {
|
||||||
|
console.error('Failed to open browser:', result.error);
|
||||||
|
openBrowserBtn.textContent = '開啟失敗';
|
||||||
|
} else {
|
||||||
|
openBrowserBtn.textContent = '已開啟';
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
openBrowserBtn.disabled = false;
|
||||||
|
openBrowserBtn.textContent = '在瀏覽器中開啟';
|
||||||
|
}, 2000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error opening browser:', error);
|
||||||
|
openBrowserBtn.disabled = false;
|
||||||
|
openBrowserBtn.textContent = '在瀏覽器中開啟';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for device changes (hot-plug)
|
||||||
|
navigator.mediaDevices.addEventListener('devicechange', () => {
|
||||||
|
console.log('Audio devices changed');
|
||||||
|
enumerateAudioDevices();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial enumeration (only if panel is not collapsed)
|
||||||
|
if (!isCollapsed) {
|
||||||
|
await enumerateAudioDevices();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get selected device for main recording
|
||||||
|
function getSelectedAudioDevice() {
|
||||||
|
return audioDeviceState.selectedDeviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize audio device panel on page load
|
||||||
|
initAudioDevicePanel();
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// End Audio Device Management
|
||||||
|
// ========================================
|
||||||
|
|
||||||
// Update Whisper status display
|
// Update Whisper status display
|
||||||
async function updateWhisperStatus() {
|
async function updateWhisperStatus() {
|
||||||
try {
|
try {
|
||||||
const status = await window.electronAPI.getSidecarStatus();
|
const status = await window.electronAPI.getSidecarStatus();
|
||||||
if (status.whisper) {
|
if (status.whisper) {
|
||||||
|
// Check if there was an error loading the model
|
||||||
|
if (status.whisper.error) {
|
||||||
|
whisperStatusEl.textContent = `❌ Model error: ${status.whisper.error}`;
|
||||||
|
whisperStatusEl.style.color = '#dc3545';
|
||||||
|
whisperStatusEl.title = 'Model failed to load';
|
||||||
|
} else {
|
||||||
const readyIcon = status.ready ? '✅' : '⏳';
|
const readyIcon = status.ready ? '✅' : '⏳';
|
||||||
whisperStatusEl.textContent = `${readyIcon} Model: ${status.whisper.model} | Device: ${status.whisper.device} | Compute: ${status.whisper.compute}`;
|
whisperStatusEl.textContent = `${readyIcon} Model: ${status.whisper.model} | Device: ${status.whisper.device} | Compute: ${status.whisper.compute}`;
|
||||||
whisperStatusEl.title = `Config source: ${status.whisper.configSource || 'unknown'}`;
|
whisperStatusEl.title = `Config source: ${status.whisper.configSource || 'unknown'}`;
|
||||||
whisperStatusEl.style.color = status.ready ? '#28a745' : '#ffc107';
|
whisperStatusEl.style.color = status.ready ? '#28a745' : '#ffc107';
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
whisperStatusEl.textContent = status.ready ? '✅ Ready' : '⏳ Loading...';
|
whisperStatusEl.textContent = status.ready ? '✅ Ready' : '⏳ Loading...';
|
||||||
whisperStatusEl.style.color = status.ready ? '#28a745' : '#ffc107';
|
whisperStatusEl.style.color = status.ready ? '#28a745' : '#ffc107';
|
||||||
@@ -414,50 +1168,46 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for available audio devices first
|
// Stop volume monitoring during main recording
|
||||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
stopVolumeMonitoring();
|
||||||
const audioInputs = devices.filter(d => d.kind === 'audioinput');
|
|
||||||
console.log('Available audio inputs:', audioInputs.length, audioInputs);
|
|
||||||
|
|
||||||
if (audioInputs.length === 0) {
|
// Get selected device from audio device panel
|
||||||
alert('No microphone found. Please connect a microphone and try again.');
|
const selectedDeviceId = getSelectedAudioDevice();
|
||||||
return;
|
console.log('Using selected audio device:', selectedDeviceId);
|
||||||
}
|
|
||||||
|
|
||||||
// Filter out Stereo Mix (立體聲混音) - it's not a real microphone
|
// Get microphone stream with user-selected device
|
||||||
const realMicrophones = audioInputs.filter(d =>
|
|
||||||
!d.label.includes('立體聲混音') &&
|
|
||||||
!d.label.toLowerCase().includes('stereo mix') &&
|
|
||||||
d.deviceId !== 'default' // Skip default which might be Stereo Mix
|
|
||||||
);
|
|
||||||
|
|
||||||
// Prefer communications device or actual microphone
|
|
||||||
let selectedMic = realMicrophones.find(d =>
|
|
||||||
d.deviceId === 'communications' ||
|
|
||||||
d.label.includes('麥克風') ||
|
|
||||||
d.label.toLowerCase().includes('microphone')
|
|
||||||
) || realMicrophones[0];
|
|
||||||
|
|
||||||
console.log('Real microphones found:', realMicrophones.length);
|
|
||||||
console.log('Selected microphone:', selectedMic);
|
|
||||||
|
|
||||||
if (!selectedMic) {
|
|
||||||
alert('No real microphone found. Only Stereo Mix detected. Please connect a microphone.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try with the selected microphone
|
|
||||||
try {
|
try {
|
||||||
mediaStream = await navigator.mediaDevices.getUserMedia({
|
let constraints;
|
||||||
audio: { deviceId: { exact: selectedMic.deviceId } }
|
if (isAliasDeviceId(selectedDeviceId)) {
|
||||||
});
|
// For alias deviceIds (default/communications), let the system choose
|
||||||
console.log('Successfully connected to:', selectedMic.label);
|
console.log('Using system default (alias detected)');
|
||||||
|
constraints = { audio: true };
|
||||||
|
} else if (selectedDeviceId) {
|
||||||
|
// For real deviceIds, try exact first, then ideal as fallback
|
||||||
|
constraints = { audio: { deviceId: { exact: selectedDeviceId } } };
|
||||||
|
} else {
|
||||||
|
// No device selected, use default
|
||||||
|
constraints = { audio: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
mediaStream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||||
} catch (exactErr) {
|
} catch (exactErr) {
|
||||||
console.warn('Exact device ID failed, trying preferred:', exactErr);
|
if (selectedDeviceId && !isAliasDeviceId(selectedDeviceId)) {
|
||||||
// Fallback: try with preferred instead of exact
|
console.warn('Exact device ID failed, trying ideal:', exactErr);
|
||||||
mediaStream = await navigator.mediaDevices.getUserMedia({
|
mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||||
audio: { deviceId: selectedMic.deviceId }
|
audio: { deviceId: { ideal: selectedDeviceId } }
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
throw exactErr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('Successfully connected to microphone');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('getUserMedia failed:', err.name, err.message);
|
||||||
|
// Restart volume monitoring on error
|
||||||
|
startVolumeMonitoring();
|
||||||
|
throw err; // Let outer catch handle the error message
|
||||||
}
|
}
|
||||||
|
|
||||||
isRecording = true;
|
isRecording = true;
|
||||||
@@ -473,13 +1223,13 @@
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Start recording error:', error);
|
console.error('Start recording error:', error);
|
||||||
let errorMsg = 'Error starting recording: ' + error.message;
|
let errorMsg = '無法開始錄音: ' + error.message;
|
||||||
if (error.name === 'NotAllowedError') {
|
if (error.name === 'NotAllowedError') {
|
||||||
errorMsg = 'Microphone access denied. Please grant permission and try again.';
|
errorMsg = '麥克風權限被拒絕,請在系統設定中允許存取麥克風。';
|
||||||
} else if (error.name === 'NotFoundError') {
|
} else if (error.name === 'NotFoundError') {
|
||||||
errorMsg = 'No microphone found. Please connect a microphone and try again.';
|
errorMsg = '未偵測到麥克風,請連接麥克風後重試。';
|
||||||
} else if (error.name === 'NotReadableError') {
|
} else if (error.name === 'NotReadableError') {
|
||||||
errorMsg = 'Microphone is in use by another application. Please close other apps using the microphone.';
|
errorMsg = '麥克風正被其他應用程式使用,請關閉其他使用麥克風的程式後重試。';
|
||||||
}
|
}
|
||||||
alert(errorMsg);
|
alert(errorMsg);
|
||||||
await cleanupRecording();
|
await cleanupRecording();
|
||||||
@@ -614,6 +1364,11 @@
|
|||||||
recordBtn.classList.add('btn-danger');
|
recordBtn.classList.add('btn-danger');
|
||||||
streamingStatusEl.classList.add('hidden');
|
streamingStatusEl.classList.add('hidden');
|
||||||
processingIndicatorEl.classList.add('hidden');
|
processingIndicatorEl.classList.add('hidden');
|
||||||
|
|
||||||
|
// Restart volume monitoring after recording ends
|
||||||
|
if (!audioDevicePanel.classList.contains('collapsed')) {
|
||||||
|
startVolumeMonitoring();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Audio File Upload ===
|
// === Audio File Upload ===
|
||||||
|
|||||||
@@ -67,6 +67,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="module">
|
<script type="module">
|
||||||
|
// Browser mode polyfill (must be first)
|
||||||
|
import '../services/browser-api.js';
|
||||||
import { initApp } from '../services/init.js';
|
import { initApp } from '../services/init.js';
|
||||||
import { getMeetings, createMeeting, clearToken } from '../services/api.js';
|
import { getMeetings, createMeeting, clearToken } from '../services/api.js';
|
||||||
|
|
||||||
|
|||||||
@@ -40,4 +40,8 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
|||||||
onTranscriptionResult: (callback) => {
|
onTranscriptionResult: (callback) => {
|
||||||
ipcRenderer.on("transcription-result", (event, text) => callback(text));
|
ipcRenderer.on("transcription-result", (event, text) => callback(text));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// === Browser Mode ===
|
||||||
|
// Open current page in system browser (useful when Electron audio is blocked)
|
||||||
|
openInBrowser: () => ipcRenderer.invoke("open-in-browser"),
|
||||||
});
|
});
|
||||||
|
|||||||
339
client/src/services/browser-api.js
Normal file
339
client/src/services/browser-api.js
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
/**
|
||||||
|
* Browser API Implementation
|
||||||
|
*
|
||||||
|
* Provides a compatible interface for pages that normally use electronAPI
|
||||||
|
* when running in browser mode. Uses HTTP API to communicate with the
|
||||||
|
* backend sidecar for transcription functionality.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Check if we're running in Electron or browser
|
||||||
|
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined;
|
||||||
|
|
||||||
|
// Base URL for API calls (relative in browser mode)
|
||||||
|
const API_BASE = '';
|
||||||
|
|
||||||
|
// Progress listeners
|
||||||
|
const progressListeners = [];
|
||||||
|
const segmentListeners = [];
|
||||||
|
const streamStopListeners = [];
|
||||||
|
|
||||||
|
// WebSocket for streaming
|
||||||
|
let streamingSocket = null;
|
||||||
|
|
||||||
|
// Browser mode API implementation
|
||||||
|
const browserAPI = {
|
||||||
|
// Get app configuration (browser mode fetches from backend or uses defaults)
|
||||||
|
getConfig: async () => {
|
||||||
|
try {
|
||||||
|
// Try to fetch config from backend
|
||||||
|
const response = await fetch(`${API_BASE}/config/settings.js`);
|
||||||
|
if (response.ok) {
|
||||||
|
// settings.js exports a config object, parse it
|
||||||
|
const text = await response.text();
|
||||||
|
// Simple extraction of the config object
|
||||||
|
const match = text.match(/export\s+const\s+config\s*=\s*(\{[\s\S]*?\});/);
|
||||||
|
if (match) {
|
||||||
|
// Use eval cautiously here - it's our own config file
|
||||||
|
const configStr = match[1];
|
||||||
|
return eval('(' + configStr + ')');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('[Browser Mode] Could not load config from server, using defaults');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return browser mode defaults
|
||||||
|
return {
|
||||||
|
apiBaseUrl: `${window.location.origin}/api`,
|
||||||
|
uploadTimeout: 600000,
|
||||||
|
appTitle: "Meeting Assistant",
|
||||||
|
whisper: {
|
||||||
|
model: "medium",
|
||||||
|
device: "cpu",
|
||||||
|
compute: "int8"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// Navigate to a page
|
||||||
|
navigate: (page) => {
|
||||||
|
const pageMap = {
|
||||||
|
'login': '/login',
|
||||||
|
'meetings': '/meetings',
|
||||||
|
'meeting-detail': '/meeting-detail'
|
||||||
|
};
|
||||||
|
window.location.href = pageMap[page] || `/${page}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get sidecar status
|
||||||
|
getSidecarStatus: async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/api/sidecar/status`);
|
||||||
|
if (response.ok) {
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ready: false,
|
||||||
|
streaming: false,
|
||||||
|
whisper: null,
|
||||||
|
browserMode: true,
|
||||||
|
message: '無法取得轉寫引擎狀態'
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Browser Mode] getSidecarStatus error:', error);
|
||||||
|
return {
|
||||||
|
ready: false,
|
||||||
|
streaming: false,
|
||||||
|
whisper: null,
|
||||||
|
browserMode: true,
|
||||||
|
available: false,
|
||||||
|
message: '無法連接到後端服務'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Model download progress listener
|
||||||
|
onModelDownloadProgress: (callback) => {
|
||||||
|
progressListeners.push(callback);
|
||||||
|
|
||||||
|
// Start polling for status updates
|
||||||
|
if (progressListeners.length === 1) {
|
||||||
|
startProgressPolling();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Save audio file and return path (for browser mode, we handle differently)
|
||||||
|
saveAudioFile: async (arrayBuffer) => {
|
||||||
|
// In browser mode, we don't save to file system
|
||||||
|
// Instead, we'll convert to base64 and return it
|
||||||
|
// The transcribeAudio function will handle the base64 data
|
||||||
|
const base64 = arrayBufferToBase64(arrayBuffer);
|
||||||
|
return `base64:${base64}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Transcribe audio
|
||||||
|
transcribeAudio: async (filePath) => {
|
||||||
|
try {
|
||||||
|
let response;
|
||||||
|
|
||||||
|
if (filePath.startsWith('base64:')) {
|
||||||
|
// Handle base64 encoded audio from saveAudioFile
|
||||||
|
const base64Data = filePath.substring(7);
|
||||||
|
response = await fetch(`${API_BASE}/api/sidecar/transcribe`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ audio_data: base64Data })
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Handle actual file path (shouldn't happen in browser mode)
|
||||||
|
throw new Error('File path transcription not supported in browser mode');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.detail || 'Transcription failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Browser Mode] transcribeAudio error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Transcription segment listener (for streaming mode)
|
||||||
|
onTranscriptionSegment: (callback) => {
|
||||||
|
segmentListeners.push(callback);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Stream stopped listener
|
||||||
|
onStreamStopped: (callback) => {
|
||||||
|
streamStopListeners.push(callback);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Start recording stream (WebSocket-based)
|
||||||
|
startRecordingStream: async () => {
|
||||||
|
try {
|
||||||
|
// Use HTTP endpoint for starting stream
|
||||||
|
const response = await fetch(`${API_BASE}/api/sidecar/stream/start`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
return { error: error.detail || 'Failed to start stream' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.status === 'streaming') {
|
||||||
|
return { status: 'streaming', session_id: result.session_id };
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Browser Mode] startRecordingStream error:', error);
|
||||||
|
return { error: error.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Stream audio chunk
|
||||||
|
streamAudioChunk: async (base64Audio) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/api/sidecar/stream/chunk`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ data: base64Audio })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
return { error: error.detail || 'Failed to send chunk' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
// If we got a segment, notify listeners
|
||||||
|
if (result.segment && result.segment.text) {
|
||||||
|
segmentListeners.forEach(cb => {
|
||||||
|
try {
|
||||||
|
cb(result.segment);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Browser Mode] Segment listener error:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Browser Mode] streamAudioChunk error:', error);
|
||||||
|
return { error: error.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Stop recording stream
|
||||||
|
stopRecordingStream: async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/api/sidecar/stream/stop`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
return { error: error.detail || 'Failed to stop stream' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
// Notify stream stop listeners
|
||||||
|
streamStopListeners.forEach(cb => {
|
||||||
|
try {
|
||||||
|
cb(result);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Browser Mode] Stream stop listener error:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Browser Mode] stopRecordingStream error:', error);
|
||||||
|
return { error: error.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get backend status
|
||||||
|
getBackendStatus: async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/health');
|
||||||
|
if (response.ok) {
|
||||||
|
return { ready: true };
|
||||||
|
}
|
||||||
|
return { ready: false };
|
||||||
|
} catch {
|
||||||
|
return { ready: false };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Open in browser - no-op in browser mode (already in browser)
|
||||||
|
openInBrowser: async () => {
|
||||||
|
console.log('[Browser Mode] Already running in browser');
|
||||||
|
return { success: true, url: window.location.href };
|
||||||
|
},
|
||||||
|
|
||||||
|
// Legacy transcription result listener (for file-based mode)
|
||||||
|
onTranscriptionResult: (callback) => {
|
||||||
|
// Not used in browser streaming mode, but provide for compatibility
|
||||||
|
console.log('[Browser Mode] onTranscriptionResult registered (legacy)');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Stream started listener
|
||||||
|
onStreamStarted: (callback) => {
|
||||||
|
// HTTP-based streaming doesn't have this event
|
||||||
|
console.log('[Browser Mode] onStreamStarted registered');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to convert ArrayBuffer to base64
|
||||||
|
function arrayBufferToBase64(buffer) {
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
let binary = '';
|
||||||
|
for (let i = 0; i < bytes.byteLength; i++) {
|
||||||
|
binary += String.fromCharCode(bytes[i]);
|
||||||
|
}
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll for sidecar status/progress updates
|
||||||
|
let progressPollingInterval = null;
|
||||||
|
let lastStatus = {};
|
||||||
|
|
||||||
|
function startProgressPolling() {
|
||||||
|
if (progressPollingInterval) return;
|
||||||
|
|
||||||
|
progressPollingInterval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/api/sidecar/status`);
|
||||||
|
if (response.ok) {
|
||||||
|
const status = await response.json();
|
||||||
|
|
||||||
|
// Check for status changes to report
|
||||||
|
const currentStatus = status.status || (status.ready ? 'ready' : 'loading');
|
||||||
|
|
||||||
|
if (currentStatus !== lastStatus.status) {
|
||||||
|
// Notify progress listeners
|
||||||
|
progressListeners.forEach(cb => {
|
||||||
|
try {
|
||||||
|
cb(status);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Browser Mode] Progress listener error:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
lastStatus = status;
|
||||||
|
|
||||||
|
// Stop polling once ready
|
||||||
|
if (status.ready) {
|
||||||
|
clearInterval(progressPollingInterval);
|
||||||
|
progressPollingInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Browser Mode] Progress polling error:', error);
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export the appropriate API based on environment
|
||||||
|
export const electronAPI = isElectron ? window.electronAPI : browserAPI;
|
||||||
|
|
||||||
|
// Also set it on window for pages that access it directly
|
||||||
|
if (!isElectron && typeof window !== 'undefined') {
|
||||||
|
window.electronAPI = browserAPI;
|
||||||
|
console.log('[Browser Mode] Running in browser mode with full transcription support');
|
||||||
|
console.log('[Browser Mode] 透過後端 Sidecar 提供即時語音轉寫功能');
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# Design: Extract Environment Variables
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
專案需要支援以下部署場景:
|
||||||
|
1. 開發環境:前後端在本地同時運行
|
||||||
|
2. 生產環境:後端部署於 1Panel 伺服器,前端(Electron 應用)獨立打包部署
|
||||||
|
|
||||||
|
**架構說明**:
|
||||||
|
- **後端**:FastAPI 服務,使用兩個 Dify 服務(LLM 摘要 + STT 轉錄)
|
||||||
|
- **前端**:Electron 應用,包含 Sidecar(本地 Whisper 即時轉錄服務)
|
||||||
|
|
||||||
|
目前的硬編碼配置使得部署困難,且敏感資訊(如 API 密鑰、資料庫密碼)散落在代碼中。
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
### Goals
|
||||||
|
- 將所有硬編碼配置提取到環境變數
|
||||||
|
- 提供完整的 `.env.example` 範例檔案
|
||||||
|
- 支援前端獨立打包時指定後端 API URL
|
||||||
|
- 提供 1Panel 部署完整指南和腳本
|
||||||
|
- 確保向後相容(預設值與現有行為一致)
|
||||||
|
|
||||||
|
### Non-Goals
|
||||||
|
- 不實現配置熱重載
|
||||||
|
- 不實現密鑰輪換機制
|
||||||
|
- 不實現多環境配置管理(如 .env.production, .env.staging)
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 1. 環境變數命名規範
|
||||||
|
|
||||||
|
**決定**:使用大寫蛇形命名法,前端變數加 `VITE_` 前綴
|
||||||
|
|
||||||
|
**原因**:
|
||||||
|
- Vite 要求客戶端環境變數必須以 `VITE_` 開頭
|
||||||
|
- 大寫蛇形是環境變數的標準慣例
|
||||||
|
|
||||||
|
### 2. 前端 API URL 配置
|
||||||
|
|
||||||
|
**決定**:使用 `VITE_API_BASE_URL` 環境變數,在 `api.js` 中讀取
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "http://localhost:8000/api";
|
||||||
|
```
|
||||||
|
|
||||||
|
**替代方案**:
|
||||||
|
- 使用 runtime 配置檔案(如 `/config.js`)- 更靈活但增加部署複雜度
|
||||||
|
- 使用相對路徑 `/api` - 需要 nginx 反向代理,不適合獨立部署
|
||||||
|
|
||||||
|
### 3. 超時配置單位
|
||||||
|
|
||||||
|
**決定**:統一使用毫秒(ms),與 JavaScript 一致
|
||||||
|
|
||||||
|
**後端配置項**:
|
||||||
|
| 變數名 | 預設值 | 用途 |
|
||||||
|
|--------|--------|------|
|
||||||
|
| UPLOAD_TIMEOUT | 600000 | 大檔案上傳(10分鐘) |
|
||||||
|
| DIFY_STT_TIMEOUT | 300000 | Dify STT 轉錄每個分塊(5分鐘) |
|
||||||
|
| LLM_TIMEOUT | 120000 | Dify LLM 摘要處理(2分鐘) |
|
||||||
|
| AUTH_TIMEOUT | 30000 | 認證 API 調用(30秒) |
|
||||||
|
|
||||||
|
**前端/Sidecar 配置項**:
|
||||||
|
| 變數名 | 預設值 | 用途 |
|
||||||
|
|--------|--------|------|
|
||||||
|
| WHISPER_MODEL | medium | 本地 Whisper 模型大小 |
|
||||||
|
| WHISPER_DEVICE | cpu | 執行裝置(cpu/cuda) |
|
||||||
|
| WHISPER_COMPUTE | int8 | 運算精度 |
|
||||||
|
|
||||||
|
### 4. 1Panel 部署架構
|
||||||
|
|
||||||
|
**決定**:使用 systemd 管理後端服務,nginx 反向代理
|
||||||
|
|
||||||
|
```
|
||||||
|
[Client] → [Nginx:443] → [Uvicorn:8000]
|
||||||
|
↓
|
||||||
|
[Static Files]
|
||||||
|
```
|
||||||
|
|
||||||
|
**原因**:
|
||||||
|
- systemd 提供進程管理、日誌、自動重啟
|
||||||
|
- nginx 處理 HTTPS、靜態檔案、反向代理
|
||||||
|
- 這是 1Panel 的標準部署模式
|
||||||
|
|
||||||
|
### 5. CORS 配置
|
||||||
|
|
||||||
|
**決定**:保持 `allow_origins=["*"]`,不額外配置
|
||||||
|
|
||||||
|
**原因**:
|
||||||
|
- 前端是 Electron 桌面應用,分發到多台電腦
|
||||||
|
- Electron 主進程的 HTTP 請求不受 CORS 限制
|
||||||
|
- 簡化部署配置,IT 只需關心 HOST 和 PORT
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
### 風險 1:環境變數遺漏
|
||||||
|
- **風險**:部署時遺漏必要的環境變數導致服務異常
|
||||||
|
- **緩解**:提供完整的 `.env.example`,啟動時檢查必要變數
|
||||||
|
|
||||||
|
### 風險 2:前端打包後無法修改 API URL
|
||||||
|
- **風險**:Vite 環境變數在打包時固定
|
||||||
|
- **緩解**:文件中說明需要為不同環境分別打包,或考慮未來實現 runtime 配置
|
||||||
|
|
||||||
|
### 風險 3:敏感資訊外洩
|
||||||
|
- **風險**:`.env` 檔案被提交到版本控制
|
||||||
|
- **緩解**:確保 `.gitignore` 包含 `.env`,只提交 `.env.example`
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. **Phase 1 - 後端配置**
|
||||||
|
- 更新 `config.py` 添加新配置項
|
||||||
|
- 更新各 router 使用配置
|
||||||
|
- 更新 `.env` 和 `.env.example`
|
||||||
|
|
||||||
|
2. **Phase 2 - 前端配置**
|
||||||
|
- 創建 `.env` 和 `.env.example`
|
||||||
|
- 更新 `api.js` 使用環境變數
|
||||||
|
|
||||||
|
3. **Phase 3 - 部署文件**
|
||||||
|
- 創建 1Panel 部署指南
|
||||||
|
- 創建部署腳本
|
||||||
|
|
||||||
|
4. **Rollback**
|
||||||
|
- 所有配置都有預設值,回滾只需刪除環境變數
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Q: 是否需要支援 Docker 部署?
|
||||||
|
- A: 暫不包含,但環境變數配置天然支援 Docker
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Change: Extract Hardcoded Configurations to Environment Variables
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
專案中存在大量硬編碼的路徑、URL、API 端點、埠號及敏感資訊,這些配置散落在前後端程式碼中。為了支援獨立部署(後端部署於 1Panel 伺服器,前端獨立打包),需要將這些配置統一提取到環境變數檔案中管理,提高部署彈性與安全性。
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
### 後端配置提取
|
||||||
|
|
||||||
|
後端使用兩個 Dify 服務:
|
||||||
|
- **LLM 服務**(`DIFY_API_KEY`)- 產生會議結論及行動事項
|
||||||
|
- **STT 服務**(`DIFY_STT_API_KEY`)- 上傳音訊檔案的語音轉文字
|
||||||
|
|
||||||
|
1. **新增環境變數**
|
||||||
|
- `BACKEND_HOST` - 後端監聽地址(預設:0.0.0.0)
|
||||||
|
- `BACKEND_PORT` - 後端監聽埠號(預設:8000)
|
||||||
|
- `DB_POOL_SIZE` - 資料庫連線池大小(預設:5)
|
||||||
|
- `JWT_EXPIRE_HOURS` - JWT Token 過期時間(預設:24)
|
||||||
|
- `UPLOAD_TIMEOUT` - 檔案上傳超時時間(預設:600000ms)
|
||||||
|
- `DIFY_STT_TIMEOUT` - Dify STT 轉錄超時時間(預設:300000ms)
|
||||||
|
- `LLM_TIMEOUT` - Dify LLM 處理超時時間(預設:120000ms)
|
||||||
|
- `AUTH_TIMEOUT` - 認證 API 超時時間(預設:30000ms)
|
||||||
|
- `TEMPLATE_DIR` - Excel 範本目錄路徑
|
||||||
|
- `RECORD_DIR` - 會議記錄匯出目錄路徑
|
||||||
|
- `MAX_FILE_SIZE` - 最大上傳檔案大小(預設:500MB)
|
||||||
|
- `SUPPORTED_AUDIO_FORMATS` - 支援的音訊格式
|
||||||
|
|
||||||
|
**註**:CORS 保持 `allow_origins=["*"]`,因為前端是 Electron 桌面應用,無需細粒度控制。
|
||||||
|
|
||||||
|
2. **已存在環境變數**(確認文件化)
|
||||||
|
- `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASS`, `DB_NAME` - 資料庫配置
|
||||||
|
- `AUTH_API_URL` - 認證 API 端點
|
||||||
|
- `DIFY_API_URL` - Dify API 基礎 URL
|
||||||
|
- `DIFY_API_KEY` - Dify LLM 服務金鑰
|
||||||
|
- `DIFY_STT_API_KEY` - Dify STT 服務金鑰
|
||||||
|
- `ADMIN_EMAIL` - 管理員郵箱
|
||||||
|
- `JWT_SECRET` - JWT 密鑰
|
||||||
|
|
||||||
|
### 前端/Electron 配置提取
|
||||||
|
|
||||||
|
前端包含 Sidecar(本地 Whisper 即時轉錄服務)。
|
||||||
|
|
||||||
|
1. **Vite 環境變數**(打包時使用)
|
||||||
|
- `VITE_API_BASE_URL` - 後端 API 基礎 URL(預設:http://localhost:8000/api)
|
||||||
|
- `VITE_UPLOAD_TIMEOUT` - 大檔案上傳超時時間(預設:600000ms)
|
||||||
|
- `VITE_APP_TITLE` - 應用程式標題
|
||||||
|
|
||||||
|
2. **Sidecar/Whisper 環境變數**(執行時使用)
|
||||||
|
- `WHISPER_MODEL` - 模型大小(預設:medium)
|
||||||
|
- `WHISPER_DEVICE` - 執行裝置(預設:cpu)
|
||||||
|
- `WHISPER_COMPUTE` - 運算精度(預設:int8)
|
||||||
|
- `SIDECAR_DIR` - Sidecar 目錄路徑(Electron 打包時使用)
|
||||||
|
|
||||||
|
### 部署文件與腳本
|
||||||
|
|
||||||
|
1. **1Panel 部署指南** - `docs/1panel-deployment.md`
|
||||||
|
2. **後端部署腳本** - `scripts/deploy-backend.sh`
|
||||||
|
3. **環境變數範例檔案**
|
||||||
|
- 更新 `backend/.env.example`
|
||||||
|
- 新增 `client/.env.example`
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Affected specs: `middleware`
|
||||||
|
- Affected code:
|
||||||
|
- `backend/app/config.py` - 新增配置項
|
||||||
|
- `backend/app/database.py` - 使用連線池配置
|
||||||
|
- `backend/app/routers/ai.py` - 使用 Dify 超時配置
|
||||||
|
- `backend/app/routers/auth.py` - 使用認證超時配置
|
||||||
|
- `backend/app/routers/export.py` - 使用目錄路徑配置
|
||||||
|
- `client/src/services/api.js` - 使用 Vite 環境變數
|
||||||
|
- `client/src/main.js` - Sidecar 路徑配置
|
||||||
|
- `start.sh` - 更新啟動腳本
|
||||||
|
|
||||||
|
## 部署流程簡化
|
||||||
|
|
||||||
|
**IT 只需提供:**
|
||||||
|
1. 後端伺服器 IP/域名
|
||||||
|
2. 後端使用的 PORT
|
||||||
|
|
||||||
|
**開發者打包前端時:**
|
||||||
|
1. 設定 `VITE_API_BASE_URL=http://<伺服器>:<PORT>/api`
|
||||||
|
2. 執行打包命令
|
||||||
|
3. 分發 EXE 給使用者
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: FastAPI Server Configuration
|
||||||
|
The middleware server SHALL be implemented using Python FastAPI framework with comprehensive environment-based configuration supporting standalone deployment.
|
||||||
|
|
||||||
|
#### Scenario: Server startup with valid configuration
|
||||||
|
- **WHEN** the server starts with valid .env file containing all required variables (DB_HOST, DB_PORT, DB_USER, DB_PASS, DB_NAME, DIFY_API_URL, DIFY_API_KEY, AUTH_API_URL)
|
||||||
|
- **THEN** the server SHALL start successfully and accept connections on the configured BACKEND_HOST and BACKEND_PORT
|
||||||
|
|
||||||
|
#### Scenario: Server startup with missing configuration
|
||||||
|
- **WHEN** the server starts with missing required environment variables
|
||||||
|
- **THEN** the server SHALL fail to start with descriptive error message
|
||||||
|
|
||||||
|
#### Scenario: Server startup with optional configuration
|
||||||
|
- **WHEN** optional environment variables (BACKEND_PORT, DB_POOL_SIZE, etc.) are not set
|
||||||
|
- **THEN** the server SHALL use sensible defaults and start normally
|
||||||
|
|
||||||
|
### Requirement: Database Connection Pool
|
||||||
|
The middleware server SHALL maintain a configurable connection pool to the MySQL database using environment variables.
|
||||||
|
|
||||||
|
#### Scenario: Database connection success
|
||||||
|
- **WHEN** the server connects to MySQL with valid credentials from environment
|
||||||
|
- **THEN** a connection pool SHALL be established with DB_POOL_SIZE connections
|
||||||
|
|
||||||
|
#### Scenario: Database connection failure
|
||||||
|
- **WHEN** the database is unreachable
|
||||||
|
- **THEN** the server SHALL return HTTP 503 with error details for affected endpoints
|
||||||
|
|
||||||
|
### Requirement: CORS Configuration
|
||||||
|
The middleware server SHALL allow cross-origin requests from all origins to support Electron desktop application clients.
|
||||||
|
|
||||||
|
#### Scenario: CORS preflight request
|
||||||
|
- **WHEN** any client sends OPTIONS request
|
||||||
|
- **THEN** the server SHALL respond with CORS headers allowing the request (allow_origins=["*"])
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Backend Server Configuration
|
||||||
|
The middleware server SHALL support configurable host and port through environment variables for flexible deployment.
|
||||||
|
|
||||||
|
#### Scenario: Custom port binding
|
||||||
|
- **WHEN** BACKEND_PORT environment variable is set to 9000
|
||||||
|
- **THEN** the server SHALL listen on port 9000
|
||||||
|
|
||||||
|
#### Scenario: Production host binding
|
||||||
|
- **WHEN** BACKEND_HOST is set to 0.0.0.0
|
||||||
|
- **THEN** the server SHALL accept connections from any network interface
|
||||||
|
|
||||||
|
#### Scenario: Default configuration
|
||||||
|
- **WHEN** BACKEND_HOST and BACKEND_PORT are not set
|
||||||
|
- **THEN** the server SHALL default to 0.0.0.0:8000
|
||||||
|
|
||||||
|
### Requirement: Timeout Configuration
|
||||||
|
The middleware server SHALL support configurable timeout values for different operations through environment variables.
|
||||||
|
|
||||||
|
#### Scenario: File upload timeout
|
||||||
|
- **WHEN** UPLOAD_TIMEOUT is set to 900000 (15 minutes)
|
||||||
|
- **THEN** file upload operations SHALL allow up to 15 minutes before timeout
|
||||||
|
|
||||||
|
#### Scenario: LLM processing timeout
|
||||||
|
- **WHEN** LLM_TIMEOUT is set to 180000 (3 minutes)
|
||||||
|
- **THEN** Dify LLM summarization operations SHALL allow up to 3 minutes before timeout
|
||||||
|
|
||||||
|
#### Scenario: Dify STT timeout
|
||||||
|
- **WHEN** DIFY_STT_TIMEOUT is set to 600000 (10 minutes)
|
||||||
|
- **THEN** Dify STT audio transcription per chunk SHALL allow up to 10 minutes before timeout
|
||||||
|
|
||||||
|
#### Scenario: Authentication timeout
|
||||||
|
- **WHEN** AUTH_TIMEOUT is set to 60000 (1 minute)
|
||||||
|
- **THEN** authentication API calls SHALL allow up to 1 minute before timeout
|
||||||
|
|
||||||
|
### Requirement: File Path Configuration
|
||||||
|
The middleware server SHALL support configurable directory paths for templates and records.
|
||||||
|
|
||||||
|
#### Scenario: Custom template directory
|
||||||
|
- **WHEN** TEMPLATE_DIR environment variable is set to /data/templates
|
||||||
|
- **THEN** Excel templates SHALL be loaded from /data/templates
|
||||||
|
|
||||||
|
#### Scenario: Custom record directory
|
||||||
|
- **WHEN** RECORD_DIR environment variable is set to /data/records
|
||||||
|
- **THEN** exported meeting records SHALL be saved to /data/records
|
||||||
|
|
||||||
|
#### Scenario: Relative path resolution
|
||||||
|
- **WHEN** directory paths are relative
|
||||||
|
- **THEN** they SHALL be resolved relative to the backend application root
|
||||||
|
|
||||||
|
### Requirement: Frontend Environment Configuration
|
||||||
|
The frontend Electron application SHALL support environment-based API URL configuration for connecting to deployed backend.
|
||||||
|
|
||||||
|
#### Scenario: Custom API URL in production build
|
||||||
|
- **WHEN** VITE_API_BASE_URL is set to http://192.168.1.100:8000/api during build
|
||||||
|
- **THEN** the built Electron app SHALL connect to http://192.168.1.100:8000/api
|
||||||
|
|
||||||
|
#### Scenario: Default API URL in development
|
||||||
|
- **WHEN** VITE_API_BASE_URL is not set
|
||||||
|
- **THEN** the frontend SHALL default to http://localhost:8000/api
|
||||||
|
|
||||||
|
### Requirement: Sidecar Whisper Configuration
|
||||||
|
The Electron frontend's Sidecar (local Whisper transcription service) SHALL support environment-based model configuration.
|
||||||
|
|
||||||
|
#### Scenario: Custom Whisper model
|
||||||
|
- **WHEN** WHISPER_MODEL environment variable is set to "large"
|
||||||
|
- **THEN** the Sidecar SHALL load the large Whisper model for transcription
|
||||||
|
|
||||||
|
#### Scenario: GPU acceleration
|
||||||
|
- **WHEN** WHISPER_DEVICE is set to "cuda" and WHISPER_COMPUTE is set to "float16"
|
||||||
|
- **THEN** the Sidecar SHALL use GPU for faster transcription
|
||||||
|
|
||||||
|
#### Scenario: Default CPU mode
|
||||||
|
- **WHEN** WHISPER_DEVICE is not set
|
||||||
|
- **THEN** the Sidecar SHALL default to CPU with int8 compute type
|
||||||
|
|
||||||
|
### Requirement: Environment Example Files
|
||||||
|
The project SHALL provide example environment files documenting all configuration options.
|
||||||
|
|
||||||
|
#### Scenario: Backend environment example
|
||||||
|
- **WHEN** developer sets up backend
|
||||||
|
- **THEN** backend/.env.example SHALL list all environment variables with descriptions and example values (without sensitive data)
|
||||||
|
|
||||||
|
#### Scenario: Frontend environment example
|
||||||
|
- **WHEN** developer sets up frontend
|
||||||
|
- **THEN** client/.env.example SHALL list all VITE_ prefixed and WHISPER_ prefixed environment variables with descriptions
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Tasks: Extract Environment Variables
|
||||||
|
|
||||||
|
## 1. Backend Configuration
|
||||||
|
|
||||||
|
- [x] 1.1 Update `backend/app/config.py` with new environment variables
|
||||||
|
- Add: BACKEND_HOST, BACKEND_PORT
|
||||||
|
- Add: DB_POOL_SIZE, JWT_EXPIRE_HOURS
|
||||||
|
- Add: UPLOAD_TIMEOUT, DIFY_STT_TIMEOUT, LLM_TIMEOUT, AUTH_TIMEOUT
|
||||||
|
- Add: TEMPLATE_DIR, RECORD_DIR, MAX_FILE_SIZE, SUPPORTED_AUDIO_FORMATS
|
||||||
|
- [x] 1.2 Update `backend/app/database.py` to use DB_POOL_SIZE from config
|
||||||
|
- [x] 1.3 Update `backend/app/routers/ai.py` to use Dify timeout configs (DIFY_STT_TIMEOUT, LLM_TIMEOUT)
|
||||||
|
- [x] 1.4 Update `backend/app/routers/auth.py` to use AUTH_TIMEOUT and JWT_EXPIRE_HOURS from config
|
||||||
|
- [x] 1.5 Update `backend/app/routers/export.py` to use TEMPLATE_DIR and RECORD_DIR from config
|
||||||
|
- [x] 1.6 Update `backend/.env` with all new variables
|
||||||
|
- [x] 1.7 Update `backend/.env.example` with all variables (without sensitive values)
|
||||||
|
|
||||||
|
## 2. Frontend/Electron Configuration
|
||||||
|
|
||||||
|
- [x] 2.1 Create `client/.env` with VITE_API_BASE_URL and Whisper settings
|
||||||
|
- [x] 2.2 Create `client/.env.example` as template
|
||||||
|
- [x] 2.3 Update `client/src/services/api.js` to use import.meta.env.VITE_API_BASE_URL
|
||||||
|
- [x] 2.4 Update `client/src/main.js` to pass Whisper env vars to Sidecar process
|
||||||
|
|
||||||
|
## 3. Startup Scripts
|
||||||
|
|
||||||
|
- [x] 3.1 Update `start.sh` to load environment variables properly
|
||||||
|
- [x] 3.2 Create `scripts/deploy-backend.sh` for standalone backend deployment
|
||||||
|
|
||||||
|
## 4. Deployment Documentation
|
||||||
|
|
||||||
|
- [x] 4.1 Create `docs/1panel-deployment.md` with step-by-step guide
|
||||||
|
- Include: Prerequisites and system requirements
|
||||||
|
- Include: Python environment setup
|
||||||
|
- Include: Environment variable configuration (IT only needs HOST + PORT)
|
||||||
|
- Include: Nginx reverse proxy configuration example
|
||||||
|
- Include: Systemd service file example
|
||||||
|
- Include: SSL/HTTPS setup guide (optional)
|
||||||
|
- Include: Troubleshooting common issues
|
||||||
|
|
||||||
|
## 5. Validation
|
||||||
|
|
||||||
|
- [ ] 5.1 Test backend starts with new config
|
||||||
|
- [ ] 5.2 Test frontend builds with environment variables
|
||||||
|
- [ ] 5.3 Test API connectivity between frontend and backend
|
||||||
|
- [ ] 5.4 Verify all hardcoded values are externalized
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
# Design: add-audio-device-selector
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
### Component Structure
|
||||||
|
```
|
||||||
|
meeting-detail.html
|
||||||
|
├── Audio Device Panel (新增)
|
||||||
|
│ ├── Device Selector (dropdown)
|
||||||
|
│ ├── Volume Meter (canvas/div bars)
|
||||||
|
│ ├── Test Controls
|
||||||
|
│ │ ├── Start Test Button
|
||||||
|
│ │ ├── Stop Test Button
|
||||||
|
│ │ └── Play Test Button
|
||||||
|
│ └── Status Indicator
|
||||||
|
└── Existing Recording Controls
|
||||||
|
└── Uses selected device from panel
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
```
|
||||||
|
User selects device → Update localStorage → Update AudioContext
|
||||||
|
→ Start volume monitoring
|
||||||
|
→ Enable test recording
|
||||||
|
|
||||||
|
Test Recording Flow:
|
||||||
|
Start Test → getUserMedia(selected device) → MediaRecorder → Blob
|
||||||
|
Play Test → Audio element → Play blob URL
|
||||||
|
|
||||||
|
Main Recording Flow:
|
||||||
|
Start Recording → Read selected device from state
|
||||||
|
→ getUserMedia(selected device)
|
||||||
|
→ Existing transcription flow
|
||||||
|
```
|
||||||
|
|
||||||
|
## Technical Decisions
|
||||||
|
|
||||||
|
### TD-1: Volume Meter Implementation
|
||||||
|
**Options Considered:**
|
||||||
|
1. **Web Audio API AnalyserNode** - Real-time frequency/amplitude analysis
|
||||||
|
2. **MediaRecorder + periodic sampling** - Sample audio levels periodically
|
||||||
|
3. **CSS-only animation** - Fake animation without real audio data
|
||||||
|
|
||||||
|
**Decision:** Web Audio API AnalyserNode
|
||||||
|
- Provides accurate real-time audio level data
|
||||||
|
- Low latency visualization
|
||||||
|
- Standard browser API, well-supported in Electron
|
||||||
|
|
||||||
|
### TD-2: Device Preference Storage
|
||||||
|
**Options Considered:**
|
||||||
|
1. **localStorage** - Simple key-value storage
|
||||||
|
2. **config.json** - App configuration file
|
||||||
|
3. **Backend database** - Per-user settings
|
||||||
|
|
||||||
|
**Decision:** localStorage
|
||||||
|
- No backend changes required
|
||||||
|
- Immediate persistence
|
||||||
|
- Per-device settings (user may use different mics on different computers)
|
||||||
|
|
||||||
|
### TD-3: Test Recording Duration
|
||||||
|
**Decision:** 5 seconds fixed duration
|
||||||
|
- Long enough to verify audio quality
|
||||||
|
- Short enough to not waste time
|
||||||
|
- Auto-stop prevents forgotten recordings
|
||||||
|
|
||||||
|
### TD-4: UI Placement
|
||||||
|
**Options Considered:**
|
||||||
|
1. **Modal dialog** - Opens on demand
|
||||||
|
2. **Collapsible panel** - Always visible but can be collapsed
|
||||||
|
3. **Settings page** - Separate page for audio settings
|
||||||
|
|
||||||
|
**Decision:** Collapsible panel in meeting-detail page
|
||||||
|
- Quick access before recording
|
||||||
|
- No page navigation needed
|
||||||
|
- Can be collapsed when not needed
|
||||||
|
|
||||||
|
## UI Mockup
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ Audio Device Settings [▼] │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ Microphone: [▼ Realtek Microphone (Realtek Audio) ▼] │
|
||||||
|
│ │
|
||||||
|
│ Input Level: ████████░░░░░░░░░░░░ 45% │
|
||||||
|
│ │
|
||||||
|
│ [🎤 Test Recording] [▶️ Play Test] Status: Ready │
|
||||||
|
│ │
|
||||||
|
│ ℹ️ Click "Test Recording" to verify your microphone │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## State Management
|
||||||
|
|
||||||
|
### Audio Device State
|
||||||
|
```javascript
|
||||||
|
const audioDeviceState = {
|
||||||
|
availableDevices: [], // Array of MediaDeviceInfo
|
||||||
|
selectedDeviceId: null, // Selected device ID or null for default
|
||||||
|
isMonitoring: false, // Volume meter active
|
||||||
|
currentLevel: 0, // Current audio level 0-100
|
||||||
|
testRecording: null, // Blob of test recording
|
||||||
|
testState: 'idle' // 'idle' | 'recording' | 'playing'
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### localStorage Keys
|
||||||
|
- `audioDevice.selectedId` - Last selected device ID
|
||||||
|
- `audioDevice.lastUsedLabel` - Device label for display fallback
|
||||||
|
|
||||||
|
## Integration Points
|
||||||
|
|
||||||
|
### With Existing Recording
|
||||||
|
1. `startRecording()` will read `selectedDeviceId` from state
|
||||||
|
2. If no device selected, use current auto-selection logic
|
||||||
|
3. If selected device unavailable, show error and prompt reselection
|
||||||
|
|
||||||
|
### IPC Considerations
|
||||||
|
- No new IPC handlers needed
|
||||||
|
- All audio device operations happen in renderer process
|
||||||
|
- Uses existing `navigator.mediaDevices` API
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
| Error | User Message | Recovery |
|
||||||
|
|-------|-------------|----------|
|
||||||
|
| No devices found | "未偵測到麥克風,請連接麥克風後重試" | Refresh device list |
|
||||||
|
| Device disconnected | "選擇的麥克風已斷開連接" | Auto-switch to default |
|
||||||
|
| Permission denied | "麥克風權限被拒絕,請在系統設定中允許" | Show permission guide |
|
||||||
|
| Device busy | "麥克風正被其他應用程式使用" | Retry button |
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Manual Testing
|
||||||
|
1. Connect multiple microphones
|
||||||
|
2. Verify all appear in dropdown
|
||||||
|
3. Select each and verify volume meter responds
|
||||||
|
4. Record and play test audio for each
|
||||||
|
5. Unplug device during use and verify error handling
|
||||||
|
6. Restart app and verify saved preference loads
|
||||||
|
|
||||||
|
### Automated Testing (Future)
|
||||||
|
- Mock `navigator.mediaDevices` for unit tests
|
||||||
|
- Test device switching logic
|
||||||
|
- Test localStorage persistence
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Proposal: add-audio-device-selector
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
新增音訊設備選擇與驗證功能,讓使用者可以手動選擇麥克風、即時預覽音量、進行收音測試及播放測試錄音。
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
目前系統自動選擇麥克風,使用者無法:
|
||||||
|
1. 查看可用的音訊輸入設備清單
|
||||||
|
2. 手動選擇偏好的麥克風
|
||||||
|
3. 在錄音前確認麥克風是否正常運作
|
||||||
|
4. 測試收音品質
|
||||||
|
|
||||||
|
這導致使用者在錄音失敗時難以診斷問題,也無法在多個麥克風之間切換。
|
||||||
|
|
||||||
|
## Proposed Solution
|
||||||
|
在會議詳情頁面新增音訊設備管理面板,包含:
|
||||||
|
|
||||||
|
1. **設備選擇器**:下拉選單顯示所有可用麥克風
|
||||||
|
2. **音量指示器**:即時顯示麥克風輸入音量(VU meter)
|
||||||
|
3. **收音測試**:錄製 5 秒測試音訊
|
||||||
|
4. **播放測試**:播放剛錄製的測試音訊
|
||||||
|
5. **設備狀態指示**:顯示目前選中設備的連線狀態
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
- **In Scope**:
|
||||||
|
- 前端 UI 元件(設備選擇器、音量計、測試按鈕)
|
||||||
|
- 設備列舉與切換邏輯
|
||||||
|
- 測試錄音與播放功能
|
||||||
|
- 使用者偏好設定儲存(localStorage)
|
||||||
|
|
||||||
|
- **Out of Scope**:
|
||||||
|
- 系統音訊輸出設備選擇
|
||||||
|
- 音訊處理效果(降噪、增益等)
|
||||||
|
- 遠端音訊設備支援
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
- 使用者可以看到所有可用麥克風並選擇一個
|
||||||
|
- 選擇麥克風後可即時看到音量變化
|
||||||
|
- 測試錄音功能可錄製 5 秒音訊並播放
|
||||||
|
- 偏好設定在下次開啟時保留
|
||||||
|
- 錄音功能使用使用者選擇的麥克風
|
||||||
|
|
||||||
|
## Stakeholders
|
||||||
|
- End Users: 會議記錄人員
|
||||||
|
- Developers: 前端開發團隊
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# audio-device-management Specification Delta
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Audio Device Enumeration
|
||||||
|
The frontend SHALL enumerate and display all available audio input devices.
|
||||||
|
|
||||||
|
#### Scenario: List available devices
|
||||||
|
- **WHEN** user opens meeting detail page
|
||||||
|
- **THEN** system SHALL enumerate all audio input devices
|
||||||
|
- **AND** display them in a dropdown selector
|
||||||
|
- **AND** exclude virtual/system devices like "Stereo Mix"
|
||||||
|
|
||||||
|
#### Scenario: Refresh device list
|
||||||
|
- **WHEN** user clicks refresh button or device is connected/disconnected
|
||||||
|
- **THEN** system SHALL re-enumerate devices
|
||||||
|
- **AND** update dropdown options
|
||||||
|
- **AND** preserve current selection if still available
|
||||||
|
|
||||||
|
#### Scenario: Device label display
|
||||||
|
- **WHEN** devices are listed
|
||||||
|
- **THEN** each device SHALL display its friendly name (label)
|
||||||
|
- **AND** indicate if it's the system default device
|
||||||
|
|
||||||
|
### Requirement: Manual Device Selection
|
||||||
|
The frontend SHALL allow users to manually select their preferred audio input device.
|
||||||
|
|
||||||
|
#### Scenario: Select device from dropdown
|
||||||
|
- **WHEN** user selects a device from dropdown
|
||||||
|
- **THEN** system SHALL update selected device state
|
||||||
|
- **AND** start volume monitoring on new device
|
||||||
|
- **AND** save selection to localStorage
|
||||||
|
|
||||||
|
#### Scenario: Load saved preference
|
||||||
|
- **WHEN** meeting detail page loads
|
||||||
|
- **THEN** system SHALL check localStorage for saved device preference
|
||||||
|
- **AND** if saved device is available, auto-select it
|
||||||
|
- **AND** if saved device unavailable, fall back to system default
|
||||||
|
|
||||||
|
#### Scenario: Selected device unavailable
|
||||||
|
- **WHEN** previously selected device is no longer available
|
||||||
|
- **THEN** system SHALL show warning message
|
||||||
|
- **AND** fall back to system default device
|
||||||
|
- **AND** prompt user to select new device
|
||||||
|
|
||||||
|
### Requirement: Real-time Volume Indicator
|
||||||
|
The frontend SHALL display real-time audio input level from the selected microphone.
|
||||||
|
|
||||||
|
#### Scenario: Display volume meter
|
||||||
|
- **WHEN** a device is selected
|
||||||
|
- **THEN** system SHALL show animated volume meter
|
||||||
|
- **AND** update meter at least 10 times per second
|
||||||
|
- **AND** display level as percentage (0-100%)
|
||||||
|
|
||||||
|
#### Scenario: Volume meter accuracy
|
||||||
|
- **WHEN** user speaks into microphone
|
||||||
|
- **THEN** volume meter SHALL reflect actual audio amplitude
|
||||||
|
- **AND** peak levels SHALL be visually distinct
|
||||||
|
|
||||||
|
#### Scenario: Muted or silent input
|
||||||
|
- **WHEN** no audio input detected for 3 seconds
|
||||||
|
- **THEN** volume meter SHALL show minimal/zero level
|
||||||
|
- **AND** optionally show "No input detected" hint
|
||||||
|
|
||||||
|
### Requirement: Audio Test Recording
|
||||||
|
The frontend SHALL allow users to record a short test audio clip.
|
||||||
|
|
||||||
|
#### Scenario: Start test recording
|
||||||
|
- **WHEN** user clicks "Test Recording" button
|
||||||
|
- **THEN** system SHALL start recording from selected device
|
||||||
|
- **AND** button SHALL change to "Stop" with countdown timer
|
||||||
|
- **AND** recording SHALL auto-stop after 5 seconds
|
||||||
|
|
||||||
|
#### Scenario: Stop test recording
|
||||||
|
- **WHEN** recording reaches 5 seconds or user clicks stop
|
||||||
|
- **THEN** recording SHALL stop
|
||||||
|
- **AND** audio blob SHALL be stored in memory
|
||||||
|
- **AND** "Play Test" button SHALL become enabled
|
||||||
|
|
||||||
|
#### Scenario: Recording indicator
|
||||||
|
- **WHEN** test recording is in progress
|
||||||
|
- **THEN** UI SHALL show recording indicator (pulsing dot)
|
||||||
|
- **AND** remaining time SHALL be displayed
|
||||||
|
|
||||||
|
### Requirement: Test Audio Playback
|
||||||
|
The frontend SHALL allow users to play back their test recording.
|
||||||
|
|
||||||
|
#### Scenario: Play test recording
|
||||||
|
- **WHEN** user clicks "Play Test" button
|
||||||
|
- **THEN** system SHALL play the recorded audio through default output
|
||||||
|
- **AND** button SHALL change to indicate playing state
|
||||||
|
- **AND** playback SHALL stop at end of recording
|
||||||
|
|
||||||
|
#### Scenario: No test recording available
|
||||||
|
- **WHEN** no test recording has been made
|
||||||
|
- **THEN** "Play Test" button SHALL be disabled
|
||||||
|
- **AND** tooltip SHALL indicate "Record a test first"
|
||||||
|
|
||||||
|
### Requirement: Integration with Main Recording
|
||||||
|
The main recording function SHALL use the user-selected audio device.
|
||||||
|
|
||||||
|
#### Scenario: Use selected device for recording
|
||||||
|
- **WHEN** user starts main recording
|
||||||
|
- **THEN** system SHALL use the device selected in audio settings panel
|
||||||
|
- **AND** if no device selected, use auto-selection logic
|
||||||
|
|
||||||
|
#### Scenario: Device changed during recording
|
||||||
|
- **WHEN** user changes device selection while recording
|
||||||
|
- **THEN** change SHALL NOT affect current recording
|
||||||
|
- **AND** new selection SHALL apply to next recording session
|
||||||
|
|
||||||
|
### Requirement: Audio Settings Panel UI
|
||||||
|
The frontend SHALL display audio settings in a collapsible panel.
|
||||||
|
|
||||||
|
#### Scenario: Panel visibility
|
||||||
|
- **WHEN** meeting detail page loads
|
||||||
|
- **THEN** audio settings panel SHALL be visible but collapsible
|
||||||
|
- **AND** panel state (expanded/collapsed) SHALL be saved
|
||||||
|
|
||||||
|
#### Scenario: Panel layout
|
||||||
|
- **WHEN** panel is expanded
|
||||||
|
- **THEN** it SHALL display:
|
||||||
|
- Device dropdown selector
|
||||||
|
- Volume meter visualization
|
||||||
|
- Test recording button
|
||||||
|
- Play test button
|
||||||
|
- Status indicator
|
||||||
|
|
||||||
|
#### Scenario: Compact mode
|
||||||
|
- **WHEN** panel is collapsed
|
||||||
|
- **THEN** it SHALL show only selected device name and expand button
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# Tasks: add-audio-device-selector
|
||||||
|
|
||||||
|
## Phase 1: Core Device Management
|
||||||
|
|
||||||
|
### Task 1.1: Add Audio Settings Panel HTML Structure
|
||||||
|
- [x] Add collapsible panel container in meeting-detail.html
|
||||||
|
- [x] Add device dropdown selector element
|
||||||
|
- [x] Add volume meter container (canvas or div bars)
|
||||||
|
- [x] Add test recording/playback buttons
|
||||||
|
- [x] Add status indicator element
|
||||||
|
- **Validation**: Panel renders correctly, all elements visible
|
||||||
|
|
||||||
|
### Task 1.2: Implement Device Enumeration
|
||||||
|
- [x] Create `enumerateAudioDevices()` function
|
||||||
|
- [x] Filter out virtual devices (Stereo Mix)
|
||||||
|
- [x] Populate dropdown with device labels
|
||||||
|
- [x] Mark default device in dropdown
|
||||||
|
- [x] Add device change event listener for hot-plug support
|
||||||
|
- **Validation**: All connected microphones appear in dropdown
|
||||||
|
|
||||||
|
### Task 1.3: Implement Device Selection Logic
|
||||||
|
- [x] Create `selectAudioDevice(deviceId)` function
|
||||||
|
- [x] Stop existing audio context when switching
|
||||||
|
- [x] Create new AudioContext with selected device
|
||||||
|
- [x] Save selection to localStorage
|
||||||
|
- [x] Handle device unavailable errors
|
||||||
|
- **Validation**: Selecting device updates state, persists after refresh
|
||||||
|
|
||||||
|
## Phase 2: Volume Monitoring
|
||||||
|
|
||||||
|
### Task 2.1: Implement Volume Meter
|
||||||
|
- [x] Create AudioContext and AnalyserNode
|
||||||
|
- [x] Connect selected device to analyser
|
||||||
|
- [x] Create volume calculation function (RMS or peak)
|
||||||
|
- [x] Implement requestAnimationFrame loop for updates
|
||||||
|
- [x] Render volume level as visual bar
|
||||||
|
- **Validation**: Meter responds to voice input, updates smoothly
|
||||||
|
|
||||||
|
### Task 2.2: Volume Meter Styling
|
||||||
|
- [x] Add CSS for volume meter bar
|
||||||
|
- [x] Add gradient colors (green → yellow → red)
|
||||||
|
- [x] Add percentage text display
|
||||||
|
- [x] Add "No input detected" indicator
|
||||||
|
- **Validation**: Visual feedback is clear and responsive
|
||||||
|
|
||||||
|
## Phase 3: Test Recording
|
||||||
|
|
||||||
|
### Task 3.1: Implement Test Recording Function
|
||||||
|
- [x] Create `startTestRecording()` function
|
||||||
|
- [x] Use MediaRecorder with selected device
|
||||||
|
- [x] Implement 5-second auto-stop timer
|
||||||
|
- [x] Store recording as Blob
|
||||||
|
- [x] Update UI during recording (countdown, indicator)
|
||||||
|
- **Validation**: Can record 5 seconds, blob created
|
||||||
|
|
||||||
|
### Task 3.2: Implement Test Playback Function
|
||||||
|
- [x] Create `playTestRecording()` function
|
||||||
|
- [x] Create Audio element from blob URL
|
||||||
|
- [x] Handle play/stop states
|
||||||
|
- [x] Update UI during playback
|
||||||
|
- [x] Clean up blob URL when done
|
||||||
|
- **Validation**: Recorded audio plays back correctly
|
||||||
|
|
||||||
|
### Task 3.3: Test Recording UI State Management
|
||||||
|
- [x] Disable recording button during recording
|
||||||
|
- [x] Show countdown timer during recording
|
||||||
|
- [x] Enable play button after recording
|
||||||
|
- [x] Disable test controls during main recording
|
||||||
|
- **Validation**: UI states transition correctly
|
||||||
|
|
||||||
|
## Phase 4: Integration
|
||||||
|
|
||||||
|
### Task 4.1: Integrate with Main Recording
|
||||||
|
- [x] Modify `startRecording()` to use selected device
|
||||||
|
- [x] Add fallback to auto-selection if no preference
|
||||||
|
- [x] Handle selected device being unavailable
|
||||||
|
- [x] Stop volume monitoring during main recording
|
||||||
|
- **Validation**: Main recording uses selected device
|
||||||
|
|
||||||
|
### Task 4.2: Add Panel Collapse/Expand
|
||||||
|
- [x] Add collapse toggle button
|
||||||
|
- [x] Save panel state to localStorage
|
||||||
|
- [x] Load panel state on page load
|
||||||
|
- [x] Stop volume monitoring when collapsed
|
||||||
|
- **Validation**: Panel remembers collapse state
|
||||||
|
|
||||||
|
### Task 4.3: Add Refresh Device List Button
|
||||||
|
- [x] Add refresh icon button
|
||||||
|
- [x] Re-enumerate devices on click
|
||||||
|
- [x] Preserve selection if still available
|
||||||
|
- [x] Update dropdown options
|
||||||
|
- **Validation**: New devices appear after refresh
|
||||||
|
|
||||||
|
## Phase 5: Polish & Error Handling
|
||||||
|
|
||||||
|
### Task 5.1: Error Handling
|
||||||
|
- [x] Handle "No devices found" state
|
||||||
|
- [x] Handle permission denied errors
|
||||||
|
- [x] Handle device disconnection during use
|
||||||
|
- [x] Show user-friendly error messages (Chinese)
|
||||||
|
- **Validation**: All error states show appropriate messages
|
||||||
|
|
||||||
|
### Task 5.2: Localization
|
||||||
|
- [x] Add Chinese labels for all UI elements
|
||||||
|
- [x] Add Chinese error messages
|
||||||
|
- [x] Add tooltips for buttons
|
||||||
|
- **Validation**: All text is in Traditional Chinese
|
||||||
|
|
||||||
|
### Task 5.3: Testing & Documentation
|
||||||
|
- [x] Manual testing with multiple microphones
|
||||||
|
- [x] Test USB microphone hot-plug
|
||||||
|
- [x] Test headset microphone switching
|
||||||
|
- [x] Update DEPLOYMENT.md if needed
|
||||||
|
- **Validation**: Feature works with various microphone types
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- Task 1.2 depends on Task 1.1
|
||||||
|
- Task 2.1 depends on Task 1.3
|
||||||
|
- Task 3.1 depends on Task 1.3
|
||||||
|
- Task 4.1 depends on Tasks 1.3, 3.1
|
||||||
|
- Phase 5 depends on all previous phases
|
||||||
|
|
||||||
|
## Parallelizable Work
|
||||||
|
- Task 1.1 (HTML) and Task 2.2 (CSS) can run in parallel
|
||||||
|
- Task 3.1 (Recording) and Task 2.1 (Volume) can run in parallel after Task 1.3
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# Design: Embedded Backend Packaging
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Meeting Assistant uses a three-tier architecture: Electron Client → FastAPI Middleware → MySQL/Dify. For enterprise deployment, administrators want to distribute a single executable that users can run without additional setup. The backend must still connect to remote MySQL and Dify services (no local database).
|
||||||
|
|
||||||
|
**Stakeholders:**
|
||||||
|
- Enterprise IT administrators (simplified deployment)
|
||||||
|
- End users (double-click to run)
|
||||||
|
- Developers (maintain backward compatibility)
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
### Goals
|
||||||
|
- Package backend as a sidecar executable using PyInstaller
|
||||||
|
- Electron manages backend lifecycle (start on launch, stop on close)
|
||||||
|
- Single `config.json` for all configuration (frontend, backend, whisper)
|
||||||
|
- Health check ensures backend is ready before showing UI
|
||||||
|
- Backward compatible with existing separate-deployment mode
|
||||||
|
- Show Whisper model download progress to improve UX
|
||||||
|
|
||||||
|
### Non-Goals
|
||||||
|
- Embedding MySQL database (still remote)
|
||||||
|
- Embedding LLM model (still uses Dify API)
|
||||||
|
- Removing external authentication (still requires company SSO)
|
||||||
|
- Pre-bundling Whisper model (user downloads on first run)
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Decision 1: Use PyInstaller for Backend Packaging
|
||||||
|
**What:** Package FastAPI + uvicorn as standalone executable using PyInstaller `--onedir` mode.
|
||||||
|
|
||||||
|
**Why:**
|
||||||
|
- Consistent with existing transcriber sidecar approach
|
||||||
|
- `--onedir` provides faster startup than `--onefile`
|
||||||
|
- Team already has PyInstaller expertise
|
||||||
|
|
||||||
|
**Alternatives considered:**
|
||||||
|
- Nuitka: Better optimization but longer build times
|
||||||
|
- cx_Freeze: Less community support for FastAPI
|
||||||
|
|
||||||
|
### Decision 2: Configuration via Extended config.json
|
||||||
|
**What:** Add `backend` section to existing `config.json` with database, API, and auth settings.
|
||||||
|
|
||||||
|
**Why:**
|
||||||
|
- Single configuration file for users to manage
|
||||||
|
- Runtime modifiable without rebuilding
|
||||||
|
- Consistent with existing whisper config pattern
|
||||||
|
|
||||||
|
**Schema:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"apiBaseUrl": "http://localhost:8000/api",
|
||||||
|
"backend": {
|
||||||
|
"embedded": true,
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": 8000,
|
||||||
|
"database": { "host": "", "port": 33306, "user": "", "password": "", "database": "" },
|
||||||
|
"externalApis": { "authApiUrl": "", "difyApiUrl": "", "difyApiKey": "", "difySttApiKey": "" },
|
||||||
|
"auth": { "adminEmail": "", "jwtSecret": "", "jwtExpireHours": 24 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Decision 3: Health Check Before Window Display
|
||||||
|
**What:** Electron polls `/api/health` endpoint before creating main window.
|
||||||
|
|
||||||
|
**Why:**
|
||||||
|
- Prevents "connection refused" errors on startup
|
||||||
|
- Provides clear feedback if backend fails to start
|
||||||
|
- Maximum 30 attempts with 1-second intervals
|
||||||
|
|
||||||
|
### Decision 4: Backward Compatibility via Feature Flag
|
||||||
|
**What:** `backend.embedded: false` (default) preserves existing behavior; `true` enables embedded mode.
|
||||||
|
|
||||||
|
**Why:**
|
||||||
|
- Existing deployments continue working unchanged
|
||||||
|
- Gradual migration path for enterprises
|
||||||
|
- Same codebase supports both deployment models
|
||||||
|
|
||||||
|
### Decision 5: Huggingface Hub Progress Callback for Model Download
|
||||||
|
**What:** Intercept huggingface_hub download progress and emit JSON status messages.
|
||||||
|
|
||||||
|
**Why:**
|
||||||
|
- faster-whisper uses huggingface_hub internally
|
||||||
|
- Can emit progress without modifying faster-whisper source
|
||||||
|
- JSON format consistent with existing sidecar protocol
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
| Risk | Impact | Mitigation |
|
||||||
|
|------|--------|------------|
|
||||||
|
| PyInstaller hidden imports missing | Backend fails to start | Comprehensive hidden-import list; test on clean Windows |
|
||||||
|
| Config file contains sensitive data | Security exposure | Document security best practices; consider encryption |
|
||||||
|
| Backend startup timeout | Poor UX | Increase timeout; show loading indicator |
|
||||||
|
| Port 8000 already in use | Backend fails | Allow configurable port; detect and report conflicts |
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
### For New Deployments (All-in-One)
|
||||||
|
1. Build with `--embedded-backend` flag
|
||||||
|
2. Configure `config.json` with database/API credentials
|
||||||
|
3. Distribute single exe to users
|
||||||
|
|
||||||
|
### For Existing Deployments (Separate Backend)
|
||||||
|
1. No changes required
|
||||||
|
2. Ensure `backend.embedded: false` in config
|
||||||
|
3. Continue using existing backend deployment
|
||||||
|
|
||||||
|
### Rollback
|
||||||
|
- Set `backend.embedded: false` to disable embedded backend
|
||||||
|
- Deploy backend separately as before
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
- Should we add config validation UI on first startup?
|
||||||
|
- Should backend port be auto-discovered if 8000 is in use?
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Change: Add Embedded Backend Packaging for All-in-One Deployment
|
||||||
|
|
||||||
|
## Why
|
||||||
|
Currently, deploying Meeting Assistant requires setting up both the Electron client and a separate backend server. For enterprise internal deployment, users want a simpler experience: **double-click the exe and it works** without needing to understand or configure backend services separately.
|
||||||
|
|
||||||
|
Additionally, when users first run the packaged application, the Whisper model download (~1.5GB) shows only "loading_model" status with no progress indication, causing confusion about whether the download is actually happening.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
- **New capability: Embedded Backend** - Package FastAPI backend as a sidecar managed by Electron
|
||||||
|
- **Backend sidecar management** - Electron starts/stops backend process automatically
|
||||||
|
- **Health check mechanism** - Wait for backend readiness before loading frontend
|
||||||
|
- **Configuration unification** - All settings (DB, API keys, auth) in single `config.json`
|
||||||
|
- **Backward compatible** - Existing deployment method (separate backend) still works via `backend.embedded: false` flag
|
||||||
|
- **Model download progress** - Show real-time download percentage for Whisper model
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
- Affected specs: `embedded-backend` (new), `transcription` (modified)
|
||||||
|
- Affected code:
|
||||||
|
- `backend/run_server.py` (new) - Backend entry point for packaging
|
||||||
|
- `backend/build.py` (new) - PyInstaller build script
|
||||||
|
- `backend/app/config.py` - Support frozen executable paths
|
||||||
|
- `client/src/main.js` - Backend sidecar management
|
||||||
|
- `client/src/preload.js` - Expose backend status API
|
||||||
|
- `client/config.json` - Extended configuration schema
|
||||||
|
- `client/package.json` - Build configuration for backend resources
|
||||||
|
- `sidecar/transcriber.py` - Model download progress reporting
|
||||||
|
- `scripts/build-client.bat` - Integrated build script
|
||||||
|
- `scripts/build-all.ps1` - PowerShell build script
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Embedded Backend Packaging
|
||||||
|
The FastAPI backend SHALL be packaged as a standalone executable using PyInstaller for all-in-one deployment.
|
||||||
|
|
||||||
|
#### Scenario: Backend executable creation
|
||||||
|
- **WHEN** build script runs with embedded backend flag
|
||||||
|
- **THEN** PyInstaller SHALL create `backend/dist/backend/backend.exe` containing FastAPI, uvicorn, and all dependencies
|
||||||
|
|
||||||
|
#### Scenario: Backend executable startup
|
||||||
|
- **WHEN** backend executable is launched
|
||||||
|
- **THEN** it SHALL read configuration from `config.json` in the same directory
|
||||||
|
- **AND** start uvicorn server on configured host and port
|
||||||
|
|
||||||
|
### Requirement: Electron Backend Sidecar Management
|
||||||
|
The Electron main process SHALL manage the embedded backend as a sidecar process.
|
||||||
|
|
||||||
|
#### Scenario: Start backend on app launch
|
||||||
|
- **WHEN** Electron app launches with `backend.embedded: true` in config
|
||||||
|
- **THEN** main process SHALL spawn backend executable as child process
|
||||||
|
- **AND** pass configuration via environment variables
|
||||||
|
|
||||||
|
#### Scenario: Skip backend when disabled
|
||||||
|
- **WHEN** Electron app launches with `backend.embedded: false` in config
|
||||||
|
- **THEN** main process SHALL NOT spawn backend executable
|
||||||
|
- **AND** frontend SHALL connect to remote backend via `apiBaseUrl`
|
||||||
|
|
||||||
|
#### Scenario: Terminate backend on app close
|
||||||
|
- **WHEN** user closes Electron app
|
||||||
|
- **THEN** main process SHALL send SIGTERM to backend process
|
||||||
|
- **AND** force kill after 5 seconds if still running
|
||||||
|
|
||||||
|
### Requirement: Backend Health Check
|
||||||
|
The Electron main process SHALL verify backend readiness before showing the main window.
|
||||||
|
|
||||||
|
#### Scenario: Health check success
|
||||||
|
- **WHEN** backend `/api/health` returns HTTP 200
|
||||||
|
- **THEN** main process SHALL proceed to create main window
|
||||||
|
- **AND** set `backendReady` state to true
|
||||||
|
|
||||||
|
#### Scenario: Health check timeout
|
||||||
|
- **WHEN** backend does not respond within 30 seconds (30 attempts, 1s interval)
|
||||||
|
- **THEN** main process SHALL display error dialog
|
||||||
|
- **AND** log detailed error for debugging
|
||||||
|
|
||||||
|
#### Scenario: Health check polling
|
||||||
|
- **WHEN** health check attempt fails
|
||||||
|
- **THEN** main process SHALL retry after 1 second
|
||||||
|
- **AND** log attempt number for debugging
|
||||||
|
|
||||||
|
### Requirement: Unified Configuration Schema
|
||||||
|
All configuration for frontend, backend, and whisper SHALL be in a single `config.json` file.
|
||||||
|
|
||||||
|
#### Scenario: Backend configuration loading
|
||||||
|
- **WHEN** backend sidecar starts
|
||||||
|
- **THEN** it SHALL read database credentials from `config.json` backend.database section
|
||||||
|
- **AND** read API keys from `config.json` backend.externalApis section
|
||||||
|
- **AND** read auth settings from `config.json` backend.auth section
|
||||||
|
|
||||||
|
#### Scenario: Configuration priority
|
||||||
|
- **WHEN** both environment variable and config.json value exist
|
||||||
|
- **THEN** environment variable SHALL take precedence
|
||||||
|
|
||||||
|
#### Scenario: Default values
|
||||||
|
- **WHEN** configuration value is not specified
|
||||||
|
- **THEN** system SHALL use sensible defaults (host: 127.0.0.1, port: 8000)
|
||||||
|
|
||||||
|
### Requirement: Backend Status API
|
||||||
|
The Electron app SHALL expose backend status to the renderer process.
|
||||||
|
|
||||||
|
#### Scenario: Get backend status
|
||||||
|
- **WHEN** renderer calls `window.electronAPI.getBackendStatus()`
|
||||||
|
- **THEN** it SHALL return object with `ready` boolean and `url` string
|
||||||
|
|
||||||
|
#### Scenario: Backend status in UI
|
||||||
|
- **WHEN** backend is starting
|
||||||
|
- **THEN** UI MAY display loading indicator
|
||||||
|
|
||||||
|
### Requirement: Backward Compatibility
|
||||||
|
The embedded backend feature SHALL NOT break existing separate-deployment mode.
|
||||||
|
|
||||||
|
#### Scenario: Separate deployment unchanged
|
||||||
|
- **WHEN** `backend.embedded` is false or undefined
|
||||||
|
- **THEN** system SHALL behave exactly as before this change
|
||||||
|
- **AND** frontend connects to `apiBaseUrl` without spawning local backend
|
||||||
|
|
||||||
|
#### Scenario: Existing scripts work
|
||||||
|
- **WHEN** user runs `./start.sh start` or `./scripts/setup-backend.sh`
|
||||||
|
- **THEN** backend SHALL start normally as standalone server
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Model Download Progress Display
|
||||||
|
The sidecar SHALL report Whisper model download progress to enable UI feedback.
|
||||||
|
|
||||||
|
#### Scenario: Emit download start
|
||||||
|
- **WHEN** Whisper model download begins
|
||||||
|
- **THEN** sidecar SHALL emit JSON to stdout: `{"status": "downloading_model", "model": "<size>", "progress": 0, "total_mb": <size>}`
|
||||||
|
|
||||||
|
#### Scenario: Emit download progress
|
||||||
|
- **WHEN** download progress updates
|
||||||
|
- **THEN** sidecar SHALL emit JSON: `{"status": "downloading_model", "progress": <percent>, "downloaded_mb": <current>, "total_mb": <total>}`
|
||||||
|
- **AND** progress updates SHALL occur at least every 5% or every 5 seconds
|
||||||
|
|
||||||
|
#### Scenario: Emit download complete
|
||||||
|
- **WHEN** model download completes
|
||||||
|
- **THEN** sidecar SHALL emit JSON: `{"status": "model_downloaded", "model": "<size>"}`
|
||||||
|
- **AND** proceed to model loading
|
||||||
|
|
||||||
|
#### Scenario: Skip download for cached model
|
||||||
|
- **WHEN** model already exists in huggingface cache
|
||||||
|
- **THEN** sidecar SHALL NOT emit download progress messages
|
||||||
|
- **AND** proceed directly to loading
|
||||||
|
|
||||||
|
### Requirement: Frontend Model Download Progress Display
|
||||||
|
The Electron frontend SHALL display model download progress to users.
|
||||||
|
|
||||||
|
#### Scenario: Show download progress in transcript panel
|
||||||
|
- **WHEN** sidecar emits download progress
|
||||||
|
- **THEN** whisper status element SHALL display download percentage and size
|
||||||
|
- **AND** format: "Downloading: XX% (YYY MB / ZZZ MB)"
|
||||||
|
|
||||||
|
#### Scenario: Show download complete
|
||||||
|
- **WHEN** sidecar emits model_downloaded status
|
||||||
|
- **THEN** whisper status element SHALL briefly show "Model downloaded"
|
||||||
|
- **AND** transition to loading state
|
||||||
|
|
||||||
|
#### Scenario: Forward progress events via IPC
|
||||||
|
- **WHEN** main process receives download progress from sidecar
|
||||||
|
- **THEN** it SHALL forward to renderer via `model-download-progress` IPC channel
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Tasks: Add Embedded Backend Packaging
|
||||||
|
|
||||||
|
## 1. Backend Packaging Infrastructure
|
||||||
|
- [x] 1.1 Create `backend/run_server.py` - Entry point that loads config and starts uvicorn
|
||||||
|
- [x] 1.2 Create `backend/build.py` - PyInstaller build script with hidden imports
|
||||||
|
- [x] 1.3 Modify `backend/app/config.py` - Support frozen executable path detection
|
||||||
|
- [ ] 1.4 Test backend executable runs standalone on Windows
|
||||||
|
|
||||||
|
## 2. Electron Backend Sidecar Management
|
||||||
|
- [x] 2.1 Add `backendProcess` and `backendReady` state variables in `main.js`
|
||||||
|
- [x] 2.2 Implement `startBackendSidecar()` function
|
||||||
|
- [x] 2.3 Implement `waitForBackendReady()` health check polling
|
||||||
|
- [x] 2.4 Modify `app.whenReady()` to start backend before window
|
||||||
|
- [x] 2.5 Modify window close handler to terminate backend process
|
||||||
|
- [x] 2.6 Add `get-backend-status` IPC handler
|
||||||
|
|
||||||
|
## 3. Configuration Schema Extension
|
||||||
|
- [x] 3.1 Extend `client/config.json` with `backend` section
|
||||||
|
- [x] 3.2 Modify `client/src/preload.js` to expose backend status API
|
||||||
|
- [x] 3.3 Add configuration loading in backend entry point
|
||||||
|
- [ ] 3.4 Document configuration options in DEPLOYMENT.md
|
||||||
|
|
||||||
|
## 4. Build Script Integration
|
||||||
|
- [x] 4.1 Modify `scripts/build-client.bat` to build backend sidecar
|
||||||
|
- [ ] 4.2 Modify `scripts/build-all.ps1` to build backend sidecar
|
||||||
|
- [x] 4.3 Update `client/package.json` extraResources for backend
|
||||||
|
- [x] 4.4 Add `--embedded-backend` flag to build scripts
|
||||||
|
|
||||||
|
## 5. Model Download Progress Display
|
||||||
|
- [x] 5.1 Modify `sidecar/transcriber.py` to emit download progress JSON
|
||||||
|
- [x] 5.2 Add progress event forwarding in `main.js`
|
||||||
|
- [x] 5.3 Expose `onModelDownloadProgress` in `preload.js`
|
||||||
|
- [x] 5.4 Update `meeting-detail.html` to display download progress
|
||||||
|
|
||||||
|
## 6. Testing and Documentation
|
||||||
|
- [ ] 6.1 Test embedded mode on clean Windows machine
|
||||||
|
- [ ] 6.2 Test backward compatibility (embedded: false)
|
||||||
|
- [ ] 6.3 Test model download progress display
|
||||||
|
- [ ] 6.4 Update DEPLOYMENT.md with all-in-one deployment instructions
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Change: Add Flexible Deployment Options
|
||||||
|
|
||||||
|
## Why
|
||||||
|
Enterprise deployment environments vary significantly. Some networks block MySQL port 33306, preventing access to cloud databases. Additionally, the current portable executable extracts to a random folder in `%TEMP%`, causing Windows Defender warnings on each launch and potential permission issues.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
- **SQLite database support** - Allow choosing between MySQL (cloud) and SQLite (local) databases at build time via `--database-type` parameter
|
||||||
|
- **Fixed portable extraction path** - Configure `unpackDirName` to use a predictable folder name instead of random UUID
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
- Affected specs: `embedded-backend` (modified)
|
||||||
|
- Affected code:
|
||||||
|
- `client/config.json` - Add `database.type` and `database.sqlitePath` fields
|
||||||
|
- `client/package.json` - Add `unpackDirName` to portable configuration
|
||||||
|
- `backend/app/config.py` - Add `DB_TYPE` and `SQLITE_PATH` settings
|
||||||
|
- `backend/app/database.py` - Conditional SQLite/MySQL initialization
|
||||||
|
- `backend/run_server.py` - Pass database type environment variables
|
||||||
|
- `scripts/build-client.bat` - Add `--database-type` parameter
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: SQLite Database Support
|
||||||
|
The backend SHALL support SQLite as an alternative to MySQL for offline/standalone deployments.
|
||||||
|
|
||||||
|
#### Scenario: SQLite mode initialization
|
||||||
|
- **WHEN** `database.type` is set to `"sqlite"` in config.json
|
||||||
|
- **THEN** backend SHALL create SQLite database at `database.sqlitePath`
|
||||||
|
- **AND** initialize all required tables using SQLite-compatible syntax
|
||||||
|
|
||||||
|
#### Scenario: MySQL mode initialization
|
||||||
|
- **WHEN** `database.type` is set to `"mysql"` or not specified in config.json
|
||||||
|
- **THEN** backend SHALL connect to MySQL using credentials from `database` section
|
||||||
|
- **AND** behave exactly as before this change
|
||||||
|
|
||||||
|
#### Scenario: SQLite thread safety
|
||||||
|
- **WHEN** multiple concurrent requests access SQLite database
|
||||||
|
- **THEN** backend SHALL use thread lock to serialize database operations
|
||||||
|
- **AND** use `check_same_thread=False` for SQLite connection
|
||||||
|
|
||||||
|
#### Scenario: SQLite data persistence
|
||||||
|
- **WHEN** app is closed and reopened
|
||||||
|
- **THEN** all meeting data SHALL persist in SQLite file
|
||||||
|
- **AND** be accessible on next launch
|
||||||
|
|
||||||
|
### Requirement: Portable Extraction Path Configuration
|
||||||
|
The portable Windows build SHALL extract to a predictable folder name.
|
||||||
|
|
||||||
|
#### Scenario: Fixed extraction folder
|
||||||
|
- **WHEN** portable executable starts
|
||||||
|
- **THEN** it SHALL extract to `%TEMP%\Meeting-Assistant` instead of random UUID folder
|
||||||
|
|
||||||
|
#### Scenario: Windows Defender consistency
|
||||||
|
- **WHEN** user launches portable executable multiple times
|
||||||
|
- **THEN** Windows Defender SHALL NOT prompt for permission each time
|
||||||
|
- **BECAUSE** extraction path is consistent across launches
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Unified Configuration Schema
|
||||||
|
All configuration for frontend, backend, and whisper SHALL be in a single `config.json` file.
|
||||||
|
|
||||||
|
#### Scenario: Backend configuration loading
|
||||||
|
- **WHEN** backend sidecar starts
|
||||||
|
- **THEN** it SHALL read database type from `config.json` backend.database.type section
|
||||||
|
- **AND** read SQLite path from `config.json` backend.database.sqlitePath section (if SQLite mode)
|
||||||
|
- **AND** read database credentials from `config.json` backend.database section (if MySQL mode)
|
||||||
|
- **AND** read API keys from `config.json` backend.externalApis section
|
||||||
|
- **AND** read auth settings from `config.json` backend.auth section
|
||||||
|
|
||||||
|
#### Scenario: Configuration priority
|
||||||
|
- **WHEN** both environment variable and config.json value exist
|
||||||
|
- **THEN** environment variable SHALL take precedence
|
||||||
|
|
||||||
|
#### Scenario: Default values
|
||||||
|
- **WHEN** configuration value is not specified
|
||||||
|
- **THEN** system SHALL use sensible defaults (host: 127.0.0.1, port: 8000, database.type: mysql)
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Tasks: Add Flexible Deployment Options
|
||||||
|
|
||||||
|
## 1. Portable Extraction Path
|
||||||
|
- [x] 1.1 Update `client/package.json` - Add `unpackDirName: "Meeting-Assistant"` to portable config
|
||||||
|
|
||||||
|
## 2. Configuration Schema for SQLite
|
||||||
|
- [x] 2.1 Update `client/config.json` - Add `database.type` field (default: "mysql")
|
||||||
|
- [x] 2.2 Update `client/config.json` - Add `database.sqlitePath` field (default: "data/meeting.db")
|
||||||
|
|
||||||
|
## 3. Backend Configuration
|
||||||
|
- [x] 3.1 Update `backend/app/config.py` - Add `DB_TYPE` setting
|
||||||
|
- [x] 3.2 Update `backend/app/config.py` - Add `SQLITE_PATH` setting
|
||||||
|
- [x] 3.3 Update `backend/run_server.py` - Pass `DB_TYPE` and `SQLITE_PATH` to environment
|
||||||
|
|
||||||
|
## 4. Database Abstraction Layer
|
||||||
|
- [x] 4.1 Refactor `backend/app/database.py` - Create `init_db()` dispatcher function
|
||||||
|
- [x] 4.2 Implement `init_sqlite()` - SQLite connection with row_factory
|
||||||
|
- [x] 4.3 Implement `init_mysql()` - Keep existing MySQL pool logic
|
||||||
|
- [x] 4.4 Create unified `get_db_cursor()` context manager for both backends
|
||||||
|
- [x] 4.5 Add SQLite table creation statements (convert MySQL syntax)
|
||||||
|
- [x] 4.6 Add thread lock for SQLite connection safety
|
||||||
|
|
||||||
|
## 5. Build Script Integration
|
||||||
|
- [x] 5.1 Update `scripts/build-client.bat` - Add `--database-type` parameter parsing
|
||||||
|
- [x] 5.2 Update `scripts/build-client.bat` - Add `update_config_database` function
|
||||||
|
- [x] 5.3 Update help message with new parameter
|
||||||
|
|
||||||
|
## 6. Testing
|
||||||
|
- [x] 6.1 Test SQLite mode - Create meeting, query, update, delete
|
||||||
|
- [x] 6.2 Test MySQL mode - Ensure backward compatibility
|
||||||
|
- [ ] 6.3 Test portable extraction to `%TEMP%\Meeting-Assistant` (requires Windows build)
|
||||||
|
|
||||||
|
## 7. Documentation
|
||||||
|
- [x] 7.1 Update DEPLOYMENT.md with SQLite mode instructions
|
||||||
|
- [x] 7.2 Update DEPLOYMENT.md with --database-type parameter
|
||||||
|
- [x] 7.3 Update DEPLOYMENT.md with portable extraction path info
|
||||||
133
openspec/specs/audio-device-management/spec.md
Normal file
133
openspec/specs/audio-device-management/spec.md
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
# audio-device-management Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
TBD - created by archiving change add-audio-device-selector. Update Purpose after archive.
|
||||||
|
## Requirements
|
||||||
|
### Requirement: Audio Device Enumeration
|
||||||
|
The frontend SHALL enumerate and display all available audio input devices.
|
||||||
|
|
||||||
|
#### Scenario: List available devices
|
||||||
|
- **WHEN** user opens meeting detail page
|
||||||
|
- **THEN** system SHALL enumerate all audio input devices
|
||||||
|
- **AND** display them in a dropdown selector
|
||||||
|
- **AND** exclude virtual/system devices like "Stereo Mix"
|
||||||
|
|
||||||
|
#### Scenario: Refresh device list
|
||||||
|
- **WHEN** user clicks refresh button or device is connected/disconnected
|
||||||
|
- **THEN** system SHALL re-enumerate devices
|
||||||
|
- **AND** update dropdown options
|
||||||
|
- **AND** preserve current selection if still available
|
||||||
|
|
||||||
|
#### Scenario: Device label display
|
||||||
|
- **WHEN** devices are listed
|
||||||
|
- **THEN** each device SHALL display its friendly name (label)
|
||||||
|
- **AND** indicate if it's the system default device
|
||||||
|
|
||||||
|
### Requirement: Manual Device Selection
|
||||||
|
The frontend SHALL allow users to manually select their preferred audio input device.
|
||||||
|
|
||||||
|
#### Scenario: Select device from dropdown
|
||||||
|
- **WHEN** user selects a device from dropdown
|
||||||
|
- **THEN** system SHALL update selected device state
|
||||||
|
- **AND** start volume monitoring on new device
|
||||||
|
- **AND** save selection to localStorage
|
||||||
|
|
||||||
|
#### Scenario: Load saved preference
|
||||||
|
- **WHEN** meeting detail page loads
|
||||||
|
- **THEN** system SHALL check localStorage for saved device preference
|
||||||
|
- **AND** if saved device is available, auto-select it
|
||||||
|
- **AND** if saved device unavailable, fall back to system default
|
||||||
|
|
||||||
|
#### Scenario: Selected device unavailable
|
||||||
|
- **WHEN** previously selected device is no longer available
|
||||||
|
- **THEN** system SHALL show warning message
|
||||||
|
- **AND** fall back to system default device
|
||||||
|
- **AND** prompt user to select new device
|
||||||
|
|
||||||
|
### Requirement: Real-time Volume Indicator
|
||||||
|
The frontend SHALL display real-time audio input level from the selected microphone.
|
||||||
|
|
||||||
|
#### Scenario: Display volume meter
|
||||||
|
- **WHEN** a device is selected
|
||||||
|
- **THEN** system SHALL show animated volume meter
|
||||||
|
- **AND** update meter at least 10 times per second
|
||||||
|
- **AND** display level as percentage (0-100%)
|
||||||
|
|
||||||
|
#### Scenario: Volume meter accuracy
|
||||||
|
- **WHEN** user speaks into microphone
|
||||||
|
- **THEN** volume meter SHALL reflect actual audio amplitude
|
||||||
|
- **AND** peak levels SHALL be visually distinct
|
||||||
|
|
||||||
|
#### Scenario: Muted or silent input
|
||||||
|
- **WHEN** no audio input detected for 3 seconds
|
||||||
|
- **THEN** volume meter SHALL show minimal/zero level
|
||||||
|
- **AND** optionally show "No input detected" hint
|
||||||
|
|
||||||
|
### Requirement: Audio Test Recording
|
||||||
|
The frontend SHALL allow users to record a short test audio clip.
|
||||||
|
|
||||||
|
#### Scenario: Start test recording
|
||||||
|
- **WHEN** user clicks "Test Recording" button
|
||||||
|
- **THEN** system SHALL start recording from selected device
|
||||||
|
- **AND** button SHALL change to "Stop" with countdown timer
|
||||||
|
- **AND** recording SHALL auto-stop after 5 seconds
|
||||||
|
|
||||||
|
#### Scenario: Stop test recording
|
||||||
|
- **WHEN** recording reaches 5 seconds or user clicks stop
|
||||||
|
- **THEN** recording SHALL stop
|
||||||
|
- **AND** audio blob SHALL be stored in memory
|
||||||
|
- **AND** "Play Test" button SHALL become enabled
|
||||||
|
|
||||||
|
#### Scenario: Recording indicator
|
||||||
|
- **WHEN** test recording is in progress
|
||||||
|
- **THEN** UI SHALL show recording indicator (pulsing dot)
|
||||||
|
- **AND** remaining time SHALL be displayed
|
||||||
|
|
||||||
|
### Requirement: Test Audio Playback
|
||||||
|
The frontend SHALL allow users to play back their test recording.
|
||||||
|
|
||||||
|
#### Scenario: Play test recording
|
||||||
|
- **WHEN** user clicks "Play Test" button
|
||||||
|
- **THEN** system SHALL play the recorded audio through default output
|
||||||
|
- **AND** button SHALL change to indicate playing state
|
||||||
|
- **AND** playback SHALL stop at end of recording
|
||||||
|
|
||||||
|
#### Scenario: No test recording available
|
||||||
|
- **WHEN** no test recording has been made
|
||||||
|
- **THEN** "Play Test" button SHALL be disabled
|
||||||
|
- **AND** tooltip SHALL indicate "Record a test first"
|
||||||
|
|
||||||
|
### Requirement: Integration with Main Recording
|
||||||
|
The main recording function SHALL use the user-selected audio device.
|
||||||
|
|
||||||
|
#### Scenario: Use selected device for recording
|
||||||
|
- **WHEN** user starts main recording
|
||||||
|
- **THEN** system SHALL use the device selected in audio settings panel
|
||||||
|
- **AND** if no device selected, use auto-selection logic
|
||||||
|
|
||||||
|
#### Scenario: Device changed during recording
|
||||||
|
- **WHEN** user changes device selection while recording
|
||||||
|
- **THEN** change SHALL NOT affect current recording
|
||||||
|
- **AND** new selection SHALL apply to next recording session
|
||||||
|
|
||||||
|
### Requirement: Audio Settings Panel UI
|
||||||
|
The frontend SHALL display audio settings in a collapsible panel.
|
||||||
|
|
||||||
|
#### Scenario: Panel visibility
|
||||||
|
- **WHEN** meeting detail page loads
|
||||||
|
- **THEN** audio settings panel SHALL be visible but collapsible
|
||||||
|
- **AND** panel state (expanded/collapsed) SHALL be saved
|
||||||
|
|
||||||
|
#### Scenario: Panel layout
|
||||||
|
- **WHEN** panel is expanded
|
||||||
|
- **THEN** it SHALL display:
|
||||||
|
- Device dropdown selector
|
||||||
|
- Volume meter visualization
|
||||||
|
- Test recording button
|
||||||
|
- Play test button
|
||||||
|
- Status indicator
|
||||||
|
|
||||||
|
#### Scenario: Compact mode
|
||||||
|
- **WHEN** panel is collapsed
|
||||||
|
- **THEN** it SHALL show only selected device name and expand button
|
||||||
|
|
||||||
130
openspec/specs/embedded-backend/spec.md
Normal file
130
openspec/specs/embedded-backend/spec.md
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
# embedded-backend Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
TBD - created by archiving change add-embedded-backend-packaging. Update Purpose after archive.
|
||||||
|
## Requirements
|
||||||
|
### Requirement: Embedded Backend Packaging
|
||||||
|
The FastAPI backend SHALL be packaged as a standalone executable using PyInstaller for all-in-one deployment.
|
||||||
|
|
||||||
|
#### Scenario: Backend executable creation
|
||||||
|
- **WHEN** build script runs with embedded backend flag
|
||||||
|
- **THEN** PyInstaller SHALL create `backend/dist/backend/backend.exe` containing FastAPI, uvicorn, and all dependencies
|
||||||
|
|
||||||
|
#### Scenario: Backend executable startup
|
||||||
|
- **WHEN** backend executable is launched
|
||||||
|
- **THEN** it SHALL read configuration from `config.json` in the same directory
|
||||||
|
- **AND** start uvicorn server on configured host and port
|
||||||
|
|
||||||
|
### Requirement: Electron Backend Sidecar Management
|
||||||
|
The Electron main process SHALL manage the embedded backend as a sidecar process.
|
||||||
|
|
||||||
|
#### Scenario: Start backend on app launch
|
||||||
|
- **WHEN** Electron app launches with `backend.embedded: true` in config
|
||||||
|
- **THEN** main process SHALL spawn backend executable as child process
|
||||||
|
- **AND** pass configuration via environment variables
|
||||||
|
|
||||||
|
#### Scenario: Skip backend when disabled
|
||||||
|
- **WHEN** Electron app launches with `backend.embedded: false` in config
|
||||||
|
- **THEN** main process SHALL NOT spawn backend executable
|
||||||
|
- **AND** frontend SHALL connect to remote backend via `apiBaseUrl`
|
||||||
|
|
||||||
|
#### Scenario: Terminate backend on app close
|
||||||
|
- **WHEN** user closes Electron app
|
||||||
|
- **THEN** main process SHALL send SIGTERM to backend process
|
||||||
|
- **AND** force kill after 5 seconds if still running
|
||||||
|
|
||||||
|
### Requirement: Backend Health Check
|
||||||
|
The Electron main process SHALL verify backend readiness before showing the main window.
|
||||||
|
|
||||||
|
#### Scenario: Health check success
|
||||||
|
- **WHEN** backend `/api/health` returns HTTP 200
|
||||||
|
- **THEN** main process SHALL proceed to create main window
|
||||||
|
- **AND** set `backendReady` state to true
|
||||||
|
|
||||||
|
#### Scenario: Health check timeout
|
||||||
|
- **WHEN** backend does not respond within 30 seconds (30 attempts, 1s interval)
|
||||||
|
- **THEN** main process SHALL display error dialog
|
||||||
|
- **AND** log detailed error for debugging
|
||||||
|
|
||||||
|
#### Scenario: Health check polling
|
||||||
|
- **WHEN** health check attempt fails
|
||||||
|
- **THEN** main process SHALL retry after 1 second
|
||||||
|
- **AND** log attempt number for debugging
|
||||||
|
|
||||||
|
### Requirement: Unified Configuration Schema
|
||||||
|
All configuration for frontend, backend, and whisper SHALL be in a single `config.json` file.
|
||||||
|
|
||||||
|
#### Scenario: Backend configuration loading
|
||||||
|
- **WHEN** backend sidecar starts
|
||||||
|
- **THEN** it SHALL read database type from `config.json` backend.database.type section
|
||||||
|
- **AND** read SQLite path from `config.json` backend.database.sqlitePath section (if SQLite mode)
|
||||||
|
- **AND** read database credentials from `config.json` backend.database section (if MySQL mode)
|
||||||
|
- **AND** read API keys from `config.json` backend.externalApis section
|
||||||
|
- **AND** read auth settings from `config.json` backend.auth section
|
||||||
|
|
||||||
|
#### Scenario: Configuration priority
|
||||||
|
- **WHEN** both environment variable and config.json value exist
|
||||||
|
- **THEN** environment variable SHALL take precedence
|
||||||
|
|
||||||
|
#### Scenario: Default values
|
||||||
|
- **WHEN** configuration value is not specified
|
||||||
|
- **THEN** system SHALL use sensible defaults (host: 127.0.0.1, port: 8000, database.type: mysql)
|
||||||
|
|
||||||
|
### Requirement: Backend Status API
|
||||||
|
The Electron app SHALL expose backend status to the renderer process.
|
||||||
|
|
||||||
|
#### Scenario: Get backend status
|
||||||
|
- **WHEN** renderer calls `window.electronAPI.getBackendStatus()`
|
||||||
|
- **THEN** it SHALL return object with `ready` boolean and `url` string
|
||||||
|
|
||||||
|
#### Scenario: Backend status in UI
|
||||||
|
- **WHEN** backend is starting
|
||||||
|
- **THEN** UI MAY display loading indicator
|
||||||
|
|
||||||
|
### Requirement: Backward Compatibility
|
||||||
|
The embedded backend feature SHALL NOT break existing separate-deployment mode.
|
||||||
|
|
||||||
|
#### Scenario: Separate deployment unchanged
|
||||||
|
- **WHEN** `backend.embedded` is false or undefined
|
||||||
|
- **THEN** system SHALL behave exactly as before this change
|
||||||
|
- **AND** frontend connects to `apiBaseUrl` without spawning local backend
|
||||||
|
|
||||||
|
#### Scenario: Existing scripts work
|
||||||
|
- **WHEN** user runs `./start.sh start` or `./scripts/setup-backend.sh`
|
||||||
|
- **THEN** backend SHALL start normally as standalone server
|
||||||
|
|
||||||
|
### Requirement: SQLite Database Support
|
||||||
|
The backend SHALL support SQLite as an alternative to MySQL for offline/standalone deployments.
|
||||||
|
|
||||||
|
#### Scenario: SQLite mode initialization
|
||||||
|
- **WHEN** `database.type` is set to `"sqlite"` in config.json
|
||||||
|
- **THEN** backend SHALL create SQLite database at `database.sqlitePath`
|
||||||
|
- **AND** initialize all required tables using SQLite-compatible syntax
|
||||||
|
|
||||||
|
#### Scenario: MySQL mode initialization
|
||||||
|
- **WHEN** `database.type` is set to `"mysql"` or not specified in config.json
|
||||||
|
- **THEN** backend SHALL connect to MySQL using credentials from `database` section
|
||||||
|
- **AND** behave exactly as before this change
|
||||||
|
|
||||||
|
#### Scenario: SQLite thread safety
|
||||||
|
- **WHEN** multiple concurrent requests access SQLite database
|
||||||
|
- **THEN** backend SHALL use thread lock to serialize database operations
|
||||||
|
- **AND** use `check_same_thread=False` for SQLite connection
|
||||||
|
|
||||||
|
#### Scenario: SQLite data persistence
|
||||||
|
- **WHEN** app is closed and reopened
|
||||||
|
- **THEN** all meeting data SHALL persist in SQLite file
|
||||||
|
- **AND** be accessible on next launch
|
||||||
|
|
||||||
|
### Requirement: Portable Extraction Path Configuration
|
||||||
|
The portable Windows build SHALL extract to a predictable folder name.
|
||||||
|
|
||||||
|
#### Scenario: Fixed extraction folder
|
||||||
|
- **WHEN** portable executable starts
|
||||||
|
- **THEN** it SHALL extract to `%TEMP%\Meeting-Assistant` instead of random UUID folder
|
||||||
|
|
||||||
|
#### Scenario: Windows Defender consistency
|
||||||
|
- **WHEN** user launches portable executable multiple times
|
||||||
|
- **THEN** Windows Defender SHALL NOT prompt for permission each time
|
||||||
|
- **BECAUSE** extraction path is consistent across launches
|
||||||
|
|
||||||
@@ -175,3 +175,42 @@ The system SHALL support both real-time local transcription and file-based cloud
|
|||||||
- **WHEN** transcription completes from either source
|
- **WHEN** transcription completes from either source
|
||||||
- **THEN** result SHALL be displayed in the same transcript area in meeting detail page
|
- **THEN** result SHALL be displayed in the same transcript area in meeting detail page
|
||||||
|
|
||||||
|
### Requirement: Model Download Progress Display
|
||||||
|
The sidecar SHALL report Whisper model download progress to enable UI feedback.
|
||||||
|
|
||||||
|
#### Scenario: Emit download start
|
||||||
|
- **WHEN** Whisper model download begins
|
||||||
|
- **THEN** sidecar SHALL emit JSON to stdout: `{"status": "downloading_model", "model": "<size>", "progress": 0, "total_mb": <size>}`
|
||||||
|
|
||||||
|
#### Scenario: Emit download progress
|
||||||
|
- **WHEN** download progress updates
|
||||||
|
- **THEN** sidecar SHALL emit JSON: `{"status": "downloading_model", "progress": <percent>, "downloaded_mb": <current>, "total_mb": <total>}`
|
||||||
|
- **AND** progress updates SHALL occur at least every 5% or every 5 seconds
|
||||||
|
|
||||||
|
#### Scenario: Emit download complete
|
||||||
|
- **WHEN** model download completes
|
||||||
|
- **THEN** sidecar SHALL emit JSON: `{"status": "model_downloaded", "model": "<size>"}`
|
||||||
|
- **AND** proceed to model loading
|
||||||
|
|
||||||
|
#### Scenario: Skip download for cached model
|
||||||
|
- **WHEN** model already exists in huggingface cache
|
||||||
|
- **THEN** sidecar SHALL NOT emit download progress messages
|
||||||
|
- **AND** proceed directly to loading
|
||||||
|
|
||||||
|
### Requirement: Frontend Model Download Progress Display
|
||||||
|
The Electron frontend SHALL display model download progress to users.
|
||||||
|
|
||||||
|
#### Scenario: Show download progress in transcript panel
|
||||||
|
- **WHEN** sidecar emits download progress
|
||||||
|
- **THEN** whisper status element SHALL display download percentage and size
|
||||||
|
- **AND** format: "Downloading: XX% (YYY MB / ZZZ MB)"
|
||||||
|
|
||||||
|
#### Scenario: Show download complete
|
||||||
|
- **WHEN** sidecar emits model_downloaded status
|
||||||
|
- **THEN** whisper status element SHALL briefly show "Model downloaded"
|
||||||
|
- **AND** transition to loading state
|
||||||
|
|
||||||
|
#### Scenario: Forward progress events via IPC
|
||||||
|
- **WHEN** main process receives download progress from sidecar
|
||||||
|
- **THEN** it SHALL forward to renderer via `model-download-progress` IPC channel
|
||||||
|
|
||||||
|
|||||||
@@ -290,6 +290,8 @@ pyinstaller ^
|
|||||||
--hidden-import=app.routers.meetings ^
|
--hidden-import=app.routers.meetings ^
|
||||||
--hidden-import=app.routers.ai ^
|
--hidden-import=app.routers.ai ^
|
||||||
--hidden-import=app.routers.export ^
|
--hidden-import=app.routers.export ^
|
||||||
|
--hidden-import=app.routers.sidecar ^
|
||||||
|
--hidden-import=app.sidecar_manager ^
|
||||||
--collect-data=pydantic ^
|
--collect-data=pydantic ^
|
||||||
--collect-data=uvicorn ^
|
--collect-data=uvicorn ^
|
||||||
run_server.py
|
run_server.py
|
||||||
|
|||||||
260
start-browser.sh
Executable file
260
start-browser.sh
Executable file
@@ -0,0 +1,260 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Meeting Assistant - Browser Mode Startup Script
|
||||||
|
# 使用瀏覽器運行 Meeting Assistant(完整功能,包含即時語音轉寫)
|
||||||
|
#
|
||||||
|
# 此模式下:
|
||||||
|
# - 後端會自動啟動並管理 Sidecar(Whisper 語音轉寫引擎)
|
||||||
|
# - 前端在 Chrome/Edge 瀏覽器中運行
|
||||||
|
# - 所有功能皆可正常使用
|
||||||
|
#
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# 顏色定義
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# 專案路徑
|
||||||
|
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
BACKEND_DIR="$PROJECT_DIR/backend"
|
||||||
|
SIDECAR_DIR="$PROJECT_DIR/sidecar"
|
||||||
|
|
||||||
|
# Server Configuration (can be overridden by .env)
|
||||||
|
BACKEND_HOST="${BACKEND_HOST:-0.0.0.0}"
|
||||||
|
BACKEND_PORT="${BACKEND_PORT:-8000}"
|
||||||
|
|
||||||
|
# Whisper Configuration (can be overridden by .env)
|
||||||
|
export WHISPER_MODEL="${WHISPER_MODEL:-medium}"
|
||||||
|
export WHISPER_DEVICE="${WHISPER_DEVICE:-cpu}"
|
||||||
|
export WHISPER_COMPUTE="${WHISPER_COMPUTE:-int8}"
|
||||||
|
|
||||||
|
# Browser mode flag - tells backend to manage sidecar
|
||||||
|
export BROWSER_MODE="true"
|
||||||
|
|
||||||
|
# 函數:印出訊息
|
||||||
|
log_info() {
|
||||||
|
echo -e "${BLUE}[INFO]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_success() {
|
||||||
|
echo -e "${GREEN}[OK]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_warn() {
|
||||||
|
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_error() {
|
||||||
|
echo -e "${RED}[ERROR]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Load environment variables from .env file if it exists
|
||||||
|
if [ -f "$BACKEND_DIR/.env" ]; then
|
||||||
|
log_info "Loading backend environment from $BACKEND_DIR/.env"
|
||||||
|
export $(grep -v '^#' "$BACKEND_DIR/.env" | grep -v '^$' | xargs)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 函數:檢查 port 是否被佔用
|
||||||
|
check_port() {
|
||||||
|
local port=$1
|
||||||
|
if lsof -i :$port > /dev/null 2>&1; then
|
||||||
|
return 0 # port 被佔用
|
||||||
|
else
|
||||||
|
return 1 # port 可用
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# 函數:開啟瀏覽器
|
||||||
|
open_browser() {
|
||||||
|
local url=$1
|
||||||
|
log_info "Opening browser at $url"
|
||||||
|
|
||||||
|
# Try different browser commands
|
||||||
|
if command -v xdg-open &> /dev/null; then
|
||||||
|
xdg-open "$url" &
|
||||||
|
elif command -v wslview &> /dev/null; then
|
||||||
|
wslview "$url" &
|
||||||
|
elif command -v explorer.exe &> /dev/null; then
|
||||||
|
# WSL: use Windows browser
|
||||||
|
explorer.exe "$url" &
|
||||||
|
elif command -v open &> /dev/null; then
|
||||||
|
# macOS
|
||||||
|
open "$url" &
|
||||||
|
else
|
||||||
|
log_warn "Could not find a browser to open. Please manually visit: $url"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# 函數:檢查環境
|
||||||
|
check_environment() {
|
||||||
|
local all_ok=true
|
||||||
|
|
||||||
|
# 檢查後端虛擬環境
|
||||||
|
if [ ! -d "$BACKEND_DIR/venv" ]; then
|
||||||
|
log_error "Backend virtual environment not found"
|
||||||
|
log_error "Please run: cd $BACKEND_DIR && python3 -m venv venv && source venv/bin/activate && pip install -r requirements.txt"
|
||||||
|
all_ok=false
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 檢查 Sidecar 虛擬環境
|
||||||
|
if [ ! -d "$SIDECAR_DIR/venv" ]; then
|
||||||
|
log_warn "Sidecar virtual environment not found"
|
||||||
|
log_warn "即時語音轉寫功能將無法使用"
|
||||||
|
log_warn "To enable: cd $SIDECAR_DIR && python3 -m venv venv && source venv/bin/activate && pip install -r requirements.txt"
|
||||||
|
else
|
||||||
|
log_success "Sidecar environment found - 即時語音轉寫功能可用"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$all_ok" = false ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# 函數:啟動後端(包含 Sidecar)
|
||||||
|
start_backend() {
|
||||||
|
log_info "Checking backend status..."
|
||||||
|
|
||||||
|
# Check if backend is already running
|
||||||
|
if check_port $BACKEND_PORT; then
|
||||||
|
# Verify it's our backend by checking health endpoint
|
||||||
|
if curl -s http://localhost:$BACKEND_PORT/api/health > /dev/null 2>&1; then
|
||||||
|
log_success "Backend is already running on port $BACKEND_PORT"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
log_warn "Port $BACKEND_PORT is in use but not by our backend"
|
||||||
|
log_error "Please stop the process using port $BACKEND_PORT and try again"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "Starting backend server (with Sidecar management)..."
|
||||||
|
log_info "Whisper config: model=$WHISPER_MODEL, device=$WHISPER_DEVICE, compute=$WHISPER_COMPUTE"
|
||||||
|
|
||||||
|
cd "$BACKEND_DIR"
|
||||||
|
source venv/bin/activate
|
||||||
|
|
||||||
|
# Start uvicorn in background
|
||||||
|
nohup uvicorn app.main:app --host $BACKEND_HOST --port $BACKEND_PORT > "$PROJECT_DIR/backend-browser.log" 2>&1 &
|
||||||
|
local backend_pid=$!
|
||||||
|
|
||||||
|
# Wait for backend to be ready
|
||||||
|
log_info "Waiting for backend and sidecar to start..."
|
||||||
|
log_info "(This may take a minute if Whisper model needs to download)"
|
||||||
|
local max_wait=120 # 2 minutes for model download
|
||||||
|
local waited=0
|
||||||
|
|
||||||
|
while [ $waited -lt $max_wait ]; do
|
||||||
|
sleep 2
|
||||||
|
waited=$((waited + 2))
|
||||||
|
|
||||||
|
if curl -s http://localhost:$BACKEND_PORT/api/health > /dev/null 2>&1; then
|
||||||
|
log_success "Backend started (PID: $backend_pid)"
|
||||||
|
|
||||||
|
# Check sidecar status
|
||||||
|
local sidecar_status=$(curl -s http://localhost:$BACKEND_PORT/api/sidecar/status 2>/dev/null)
|
||||||
|
if echo "$sidecar_status" | grep -q '"ready":true'; then
|
||||||
|
log_success "Sidecar (Whisper) ready"
|
||||||
|
elif echo "$sidecar_status" | grep -q '"available":false'; then
|
||||||
|
log_warn "Sidecar not available - transcription disabled"
|
||||||
|
else
|
||||||
|
log_info "Sidecar loading... (model may be downloading)"
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Show progress every 10 seconds
|
||||||
|
if [ $((waited % 10)) -eq 0 ]; then
|
||||||
|
log_info "Still waiting... ($waited seconds)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
log_error "Backend failed to start. Check $PROJECT_DIR/backend-browser.log for details"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# 函數:停止服務
|
||||||
|
stop_services() {
|
||||||
|
log_info "Stopping services..."
|
||||||
|
pkill -f "uvicorn app.main:app" 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
log_success "Services stopped"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 主程式
|
||||||
|
main() {
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo " Meeting Assistant - Browser Mode"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check environment
|
||||||
|
check_environment
|
||||||
|
|
||||||
|
# Start backend (which manages sidecar)
|
||||||
|
start_backend
|
||||||
|
|
||||||
|
# Give it a moment
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
# Open browser
|
||||||
|
local url="http://localhost:$BACKEND_PORT"
|
||||||
|
open_browser "$url"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
log_success "Browser mode started!"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
echo " Access URL: $url"
|
||||||
|
echo " API Docs: $url/docs"
|
||||||
|
echo ""
|
||||||
|
echo " Features:"
|
||||||
|
echo " - 即時語音轉寫(透過後端 Sidecar)"
|
||||||
|
echo " - 上傳音訊轉寫"
|
||||||
|
echo " - AI 摘要"
|
||||||
|
echo " - 匯出 Excel"
|
||||||
|
echo ""
|
||||||
|
echo " To stop: $0 stop"
|
||||||
|
echo ""
|
||||||
|
log_info "Press Ctrl+C to exit (backend will keep running)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Keep script running
|
||||||
|
trap 'echo ""; log_info "Exiting (backend still running)"; exit 0' INT TERM
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
sleep 60
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# 處理命令
|
||||||
|
case "${1:-start}" in
|
||||||
|
start)
|
||||||
|
main
|
||||||
|
;;
|
||||||
|
stop)
|
||||||
|
stop_services
|
||||||
|
;;
|
||||||
|
restart)
|
||||||
|
stop_services
|
||||||
|
sleep 2
|
||||||
|
main
|
||||||
|
;;
|
||||||
|
status)
|
||||||
|
if check_port $BACKEND_PORT; then
|
||||||
|
log_success "Backend running on port $BACKEND_PORT"
|
||||||
|
curl -s http://localhost:$BACKEND_PORT/api/sidecar/status | python3 -m json.tool 2>/dev/null || echo "(Could not parse sidecar status)"
|
||||||
|
else
|
||||||
|
log_warn "Backend not running"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Usage: $0 {start|stop|restart|status}"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
Reference in New Issue
Block a user