Skip to content

Commit 71d00eb

Browse files
AmitAminovclaude
andcommitted
feat: bilingual barge-in + Gemini/CV AI interviewee, Cloud-TTS Hebrew, style-based characters; add demo
- Bilingual voice: English via browser speechSynthesis, Hebrew via Google Cloud TTS (he-IL, gendered, project ADC — no API key) - Barge-in: interrupt the interviewer mid-sentence with an echo guard so the recognizer never picks up the interviewer's own audio - AI interviewee (backend/app/sim/): a simulated candidate that hears via local STT (faster-whisper) and answers with Gemini conditioned on a CV; structural "no backdoor" (receives audio only, never the source text) - Style-based interviewer characters, always gender-consistent with the style's voice; keeps this repo's own licensed faces + manifest - Add EN/HE demo media (GIF/PNG/MP4) and embed the demo GIF in the README Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 32cd06c commit 71d00eb

18 files changed

Lines changed: 675 additions & 116 deletions

File tree

README.md

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,36 @@ TypeScript frontend, WebSocket interview loop, local FAISS RAG, and an optional
1616
sidecar. Designed so that **every external dependency is optional and every failure has a
1717
working fallback** — the app runs fully offline with no API key.
1818

19+
## Demo — bilingual live interview with barge-in
20+
21+
![Live mock interview in English — the interviewer asks a Data-Scientist question aloud; you can talk over it (barge-in) and it stops to listen](docs/demo/en.gif)
22+
23+
*A live interview in **English**: the interviewer speaks the question with a lip-synced
24+
character, and you can **barge in** — start answering mid-question and it stops to listen
25+
(an echo guard keeps it from hearing its own voice).*
26+
27+
![Same interview in Hebrew — the question is spoken and rendered right-to-left, with real gendered Hebrew audio via Google Cloud TTS](docs/demo/he.gif)
28+
29+
*The same experience in **Hebrew** (RTL): questions are spoken with real, gendered Hebrew
30+
audio via Google Cloud TTS, and the transcript renders right-to-left. English falls back to
31+
the browser's built-in speech synthesis, so bilingual voice works with zero setup.*
32+
33+
**New in this build**
34+
35+
- **Bilingual voice (English + Hebrew).** English uses the browser's `speechSynthesis`; Hebrew
36+
is synthesized as real, gendered audio through **Google Cloud Text-to-Speech** (`he-IL`,
37+
authenticated with project ADC — no API key printed or stored). The interviewer's voice
38+
gender always matches the chosen character (see *style-based characters* below).
39+
- **Barge-in.** You can interrupt the interviewer mid-sentence: speaking flushes the TTS
40+
queue and hands the floor to your mic, with an echo guard so the recognizer never picks up
41+
the interviewer's own audio.
42+
- **AI interviewee (both-AI mock interviews).** An optional simulated candidate *hears* the
43+
interviewer through local speech-to-text (`faster-whisper`) and answers with **Gemini
44+
conditioned on a CV** — with a structural "no backdoor" guarantee: the candidate only ever
45+
receives the interviewer's *audio*, never its source text (`backend/app/sim/`).
46+
- **Style-based characters.** The interviewer character is fixed per interviewer *style* and is
47+
always gender-consistent with that style's voice, so face and voice agree in every language.
48+
1949
![Interview setup — role, mode, difficulty, duration, hint policy, interviewer style](docs/screenshots/setup.png)
2050

2151
*The setup page (frontend running standalone, no backend): role, mode, difficulty, duration,
@@ -197,7 +227,10 @@ All settings are environment variables with working defaults (`backend/app/confi
197227
3. **Live interview** — video-call UI: talking avatar, your camera preview, live transcript,
198228
timer, section indicator. Answer by voice (partial transcripts in real time) or by typing.
199229
Follow-ups, style-consistent phrasing, silence check-ins, hints on request or adaptively
200-
(hints cost score), pause/resume/skip/end.
230+
(hints cost score), pause/resume/skip/end. Voice is **bilingual** (English via the browser,
231+
Hebrew via Google Cloud TTS) and supports **barge-in** — talk over the interviewer and it
232+
stops to listen. An optional **AI interviewee** can play the candidate for both-AI mock runs
233+
(`backend/app/sim/`).
201234
4. **Report** — overall score and role-readiness (0–100), per-topic scores, best/weakest
202235
answers, missing concepts, communication + technical feedback, a study plan, and a
203236
recommended next interview.

backend/app/api/routes_voice.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"""
1919
from __future__ import annotations
2020

21+
import json
2122
import logging
2223
import time
2324
from typing import Tuple
@@ -26,6 +27,7 @@
2627
from fastapi import APIRouter, HTTPException, Request, Response
2728

2829
from ..config import settings
30+
from .. import voice_cloud
2931

3032
logger = logging.getLogger(__name__)
3133

@@ -72,6 +74,30 @@ async def voice_tts(request: Request) -> Response:
7274
clear detail message when the sidecar is down.
7375
"""
7476
body = await request.body()
77+
78+
# Non-English (Hebrew) can't use the English-only Kokoro sidecar: route it to
79+
# Google Cloud TTS (gendered, real audio) so voice gender matches the
80+
# character and the audio has real duration. English falls through to Kokoro.
81+
try:
82+
parsed = json.loads(body or b"{}")
83+
except Exception:
84+
parsed = None
85+
if isinstance(parsed, dict):
86+
lang = str(parsed.get("language") or "")
87+
if lang.lower().startswith("he"):
88+
voice = str(parsed.get("voice") or "")
89+
gender = str(parsed.get("gender") or "") or (
90+
"female" if voice.lower().startswith("af") else "male")
91+
try:
92+
result = voice_cloud.synthesize(
93+
str(parsed.get("input") or ""), "he-IL", gender,
94+
float(parsed.get("speed") or 1.0))
95+
return Response(content=json.dumps(result), media_type="application/json")
96+
except Exception as exc: # noqa: BLE001
97+
logger.warning("Cloud TTS (he) failed: %s", exc)
98+
raise HTTPException(
99+
status_code=503, detail="Hebrew cloud TTS failed") from exc
100+
75101
url = f"{settings.voice_server_url}/v1/synthesize"
76102
try:
77103
async with httpx.AsyncClient(timeout=TTS_TIMEOUT_SECONDS) as client:

backend/app/sim/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""Simulation / debugging tools: an AI interviewee (candidate) that hears the
2+
interviewer via speech-to-text and answers with Gemini from a CV, used to run
3+
both-AI mock interviews. Not part of the production request path."""

backend/app/sim/ai_interviewee.py

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
"""AI interviewee — a simulated candidate for both-AI mock interviews.
2+
3+
It **listens** to the interviewer through speech-to-text (faster-whisper) and
4+
answers with **Gemini conditioned on a CV**. The "no backdoor" guarantee is
5+
structural: the candidate only ever receives the interviewer's *audio* via
6+
``listen()``, which transcribes it; ``respond()`` takes **no text argument** and
7+
can only answer what was actually heard (stored in ``self.history``). There is
8+
no code path by which the interviewer's source text reaches the candidate.
9+
10+
Auth for Gemini is the project's Application Default Credentials (Vertex AI),
11+
the same as the live barge-in reply — no API key. STT runs locally on CPU.
12+
13+
Self-check (proves the STT-listen + CV-conditioning loop end to end):
14+
python -m app.sim.ai_interviewee
15+
(run from the backend/ directory with ADC configured).
16+
"""
17+
from __future__ import annotations
18+
19+
import os
20+
import tempfile
21+
from typing import List, Optional, Tuple
22+
23+
# Lazily imported heavy deps (STT model, PDF) so importing this module is cheap.
24+
_stt_model = None
25+
26+
27+
def load_cv(path: str) -> str:
28+
"""Extract plain text from a CV PDF (or read a .txt/.md as-is)."""
29+
if path.lower().endswith((".txt", ".md")):
30+
with open(path, "r", encoding="utf-8", errors="replace") as fh:
31+
return fh.read()
32+
from pypdf import PdfReader
33+
34+
reader = PdfReader(path)
35+
return "\n".join((page.extract_text() or "") for page in reader.pages).strip()
36+
37+
38+
def _get_stt(model_size: str = "base"):
39+
"""Cached multilingual faster-whisper model (CPU, int8). 'base' handles
40+
English and Hebrew; the model is downloaded/cached once by huggingface."""
41+
global _stt_model
42+
if _stt_model is None:
43+
from faster_whisper import WhisperModel
44+
45+
_stt_model = WhisperModel(model_size, device="cpu", compute_type="int8")
46+
return _stt_model
47+
48+
49+
class AIInterviewee:
50+
"""A candidate that hears (STT) and answers (Gemini + CV).
51+
52+
Parameters
53+
----------
54+
cv_text : str the candidate's CV, verbatim (their only factual grounding)
55+
voice : str TTS voice id used by the harness when the candidate speaks
56+
(kept distinct from the interviewer's voice)
57+
gender : str 'male' | 'female' — for the harness's gendered TTS
58+
name : str display name
59+
"""
60+
61+
def __init__(self, cv_text: str, voice: str = "am_fenrir",
62+
gender: str = "male", name: str = "Candidate") -> None:
63+
self.cv_text = cv_text or ""
64+
self.voice = voice
65+
self.gender = gender
66+
self.name = name
67+
# (speaker, text) where speaker in {'interviewer', 'candidate'}. The
68+
# 'interviewer' entries are TRANSCRIPTS of what was heard, never source.
69+
self.history: List[Tuple[str, str]] = []
70+
self._provider = None
71+
72+
# --------------------------------------------------------------- listen
73+
def listen(self, audio_bytes: bytes, language: str = "en") -> str:
74+
"""Transcribe the interviewer's spoken audio. This is the ONLY way the
75+
interviewer's words enter the candidate — as heard text, not source."""
76+
model = _get_stt()
77+
suffix = ".mp3"
78+
tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
79+
try:
80+
tmp.write(audio_bytes)
81+
tmp.close()
82+
lang = "he" if language.lower().startswith("he") else "en"
83+
segments, _info = model.transcribe(tmp.name, language=lang, beam_size=1)
84+
heard = " ".join(seg.text for seg in segments).strip()
85+
finally:
86+
try:
87+
os.unlink(tmp.name)
88+
except OSError:
89+
pass
90+
self.history.append(("interviewer", heard))
91+
return heard
92+
93+
# -------------------------------------------------------------- respond
94+
def respond(self, language: str = "en") -> str:
95+
"""Answer the last thing the candidate HEARD (from self.history), via
96+
Gemini conditioned on the CV. Takes no text argument by design — the
97+
candidate cannot answer anything it did not hear."""
98+
if not any(s == "interviewer" for s, _ in self.history):
99+
raise RuntimeError("respond() called before listen(): nothing heard yet")
100+
101+
system = (
102+
"You are a job candidate in a live, spoken technical interview. "
103+
"Answer in the first person as the candidate — concise, technically "
104+
"correct, and specific. Ground your experience STRICTLY in the CV "
105+
"below; never invent employers, degrees, or results not in it. If you "
106+
"are unsure, reason out loud briefly rather than bluffing.\n\nCV:\n"
107+
+ self.cv_text[:6000]
108+
)
109+
convo = "\n".join(
110+
("Interviewer: " if s == "interviewer" else "You: ") + t
111+
for s, t in self.history[-10:]
112+
)
113+
heard = next(t for s, t in reversed(self.history) if s == "interviewer")
114+
prompt = (
115+
convo
116+
+ "\n\nThe interviewer just said (transcribed from their speech): \""
117+
+ heard
118+
+ "\"\n\nRespond as the candidate in 2-5 sentences."
119+
+ (" Reply in fluent modern Hebrew." if language.lower().startswith("he") else "")
120+
)
121+
answer = self._gemini().complete_text(system, prompt, max_tokens=400,
122+
timeout=15.0).strip()
123+
self.history.append(("candidate", answer))
124+
return answer
125+
126+
# ---------------------------------------------------------------- gemini
127+
def _gemini(self):
128+
if self._provider is None:
129+
from ..llm.provider import GeminiAPIProvider
130+
131+
self._provider = GeminiAPIProvider()
132+
return self._provider
133+
134+
135+
# --------------------------------------------------------------- self-check
136+
def _demo() -> None:
137+
"""End-to-end proof: synthesize an interviewer question to AUDIO, have the
138+
candidate hear it via STT (not read it), and answer from the CV."""
139+
from .. import voice_cloud
140+
import base64
141+
142+
cv_path = os.environ.get(
143+
"TI_CV_PATH",
144+
r"C:\Users\ADMIN\Agentic_Projects\Job_Search\cv\amit-aminov-cv.pdf",
145+
)
146+
cv = load_cv(cv_path)
147+
assert len(cv) > 200, "CV text did not load"
148+
149+
question = ("Tell me about a machine learning project you built end to end, "
150+
"and one modelling trade-off you had to make.")
151+
# Interviewer 'speaks' — the candidate will only ever get this as audio.
152+
tts = voice_cloud.synthesize(question, "en-US", "female", 1.0)
153+
audio = base64.b64decode(tts["audio"])
154+
assert audio, "interviewer TTS produced no audio"
155+
156+
cand = AIInterviewee(cv, voice="am_fenrir", gender="male", name="Amit")
157+
heard = cand.listen(audio, language="en")
158+
print("HEARD (via STT):", heard)
159+
assert heard, "STT heard nothing"
160+
# The candidate never saw `question` — only `heard`.
161+
answer = cand.respond(language="en")
162+
print("ANSWER (Gemini+CV):", answer)
163+
assert len(answer.split()) >= 8, "answer implausibly short"
164+
print("OK: listened via STT and answered from the CV, no source-text backdoor.")
165+
166+
167+
if __name__ == "__main__":
168+
_demo()

0 commit comments

Comments
 (0)