# Project export: Aquacalm AI

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: Cal Hacks 12.0
- Tagline: An AI therapist assistant with real time conversation
- Devpost: https://devpost.com/software/aquacalm-ai
- GitHub: https://github.com/falcon-140/Aquacalm-AI
- Team: 0 GitHub contributor(s) — 

## Devpost submission (written by the team)

### Inspiration

We are student so, we go through lot of stress and sometimes anxiety when the results comes out.

### What it does

It's a therapist

### How we built it

using FishAudio API for the TTS, SST , Voice Cloning and Claude API for our LLM usage for the system prompts

### Accomplishments we're proud of

Integration of FishAudio with Claude and mostly the Voice Cloning feature where the user can add their own voice or any other voice like psychiatrist for they betterment.

### What's next

Group Therapy where we can include Multiplayer AI audio's and other Human Users where the anonymity is must.

## README (from the GitHub repository)

No README available.

## Detected evidence (automated analysis)

Indexed codebase: 7 recognized source files, 22 KB.
- FastAPI (technology) — detected in the code
- HTML (language) — detected in the code
- Python (language) — detected in the code
- C++ (language) — claimed on Devpost, not found in the code

## Codebase structure (from repository index)

### Files (12 of 12)

```
.env
AI_driven_Cloud_scaling_for_LLM_services.ipynb
convo_memory.db
main.py
requirements.txt
services/anthropic_client.py
services/convo_memory.py
services/stt_google.py
services/tts_fish.py
services/voice_clone.py
static/index.html
uvicorn.log
```

### Dependencies

- requirements.txt: aiofiles, alembic, fastapi, google-cloud-speech, httpx, pydantic, python-dotenv, python-multipart, requests, soundfile, sqlalchemy, uvicorn, uvicorn[standard]

### Recent commits (newest first)

- Created using Colab
- First commit

## Key source files (fetched from GitHub, selected and truncated for size)

### requirements.txt

```
fastapi
uvicorn[standard]
httpx
sqlalchemy
alembic             # optional for migrations
pydantic
python-dotenv
google-cloud-speech  # google speech-to-text client
soundfile
aiofiles
python-multipart
requests
uvicorn

```

### main.py

```python
# main.py
import os
import uuid
import tempfile
import asyncio
from fastapi import FastAPI, UploadFile, File, Form
from fastapi.responses import FileResponse, JSONResponse
import shutil
import mimetypes
from fastapi.staticfiles import StaticFiles
from dotenv import load_dotenv

# Load environment variables from .env as early as possible so service modules
# that read env vars at import time (e.g., anthropic client) see them.
load_dotenv()

from services.stt_google import transcribe_audio
from services.tts_fish import synthesize_tts
from services.anthropic_client import send_to_claude
from services.convo_memory import ConversationStore
from services.voice_clone import ensure_voice_profile, clone_voice_if_needed
from fastapi import WebSocket, WebSocketDisconnect

load_dotenv()
PORT = int(os.getenv("PORT", 8787))

app = FastAPI()
app.mount("/static", StaticFiles(directory="static"), name="static")

# ensure tts cache directory exists
os.makedirs("tts_cache", exist_ok=True)

db = ConversationStore("sqlite:///convo_memory.db")

@app.post("/session/new")
async def new_session(user_name: str = Form(...)):
    session_id = str(uuid.uuid4())
    db.create_session(session_id, user_name)
    return {"session_id": session_id}

@app.post("/audio/turn")
async def audio_turn(
    session_id: str = Form(...),
    audio: UploadFile = File(...),
    voice_profile: str = Form(None)  # optional voice profile id
):
    # save uploaded file to temp
    suffix = os.path.splitext(audio.filename)[1] or ".wav"
    with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
        tmp.write(await audio.read())
        tmp_path = tmp.name

    # Attempt STT on the uploaded file so the frontend can show a transcript.
    transcript = ""
    try:
        transcript = transcribe_audio(tmp_path, language=os.getenv("SPEECH_LANGUAGE", "en-US"))
    except Exception as e:
        # don't fail the request; log and continue to return the audio back
        print("STT error:", e)

    # Move the uploaded audio into tts_cache and return its URL.
    dest_name = f"{uuid.uuid4()}{suffix}"
    dest_path = os.path.join("tts_cache", dest_name)
    try:
        shutil.move(tmp_path, dest_path)
    except Exception:
        # fallback to copy if move fails
        shutil.copy(tmp_path, dest_path)

    # If we have a transcript, try to produce an LLM-based assistant reply via Anthropic.
    assistant_text = ""
    if transcript:
        try:
            # send_to_claude is async; we call it and await its result
            claude_resp = await send_to_claude(transcript, system_prompt=db.system_prompt())
            assistant_text = claude_resp or f"I heard: '{transcript}'. How can I help you further?"
        except Exception as e:
            # on any LLM error, fall back to a local empathetic reply
            print("Anthropic error:", e)
            assistant_text = f"I heard: '{transcript}'. How can I help you further?"
    else:
        assistant_text = "(no assistant response)"

    # Generate TTS for the assistant's response
    try:
        tts_path = synthesize_tts(assistant_text)
        tts_filename = os.path.basename(tts_path)
    except Exception as e:
        print("TTS error:", e)
        tts_filename = dest_name  # fallback to original audio if TTS fails

    return {
        "transcript": transcript,
        "assistant_text": assistant_text,
        "tts_url": f"/audio/tts/{tts_filename}"
    }

@app.get("/audio/tts/{filename}")
def get_tts(filename: str):
    path = os.path.join("tts_cache", filename)
    if not os.path.exists(path):
        return JSONResponse({"error": "not found"}, status_code=404)
    mime, _ = mimetypes.guess_type(path)
    media_type = mime or "application/octet-stream"
    return FileResponse(path, media_type=media_type)

@app.get("/")
def home():
    return FileResponse("static/index.html")


@app.websocket("/ws/llm")
async def websocket_llm(ws: WebSocket):
    await ws.accept()
    try:
        while True:
            msg = await ws.receive_json()
            # expect {"transcript": "..."}
            transcript = msg.get("transcript")
            if not transcript:
                await ws.send_json({"error": "no_transcript"})
                continue

            # call the LLM and stream the response back in chunks
            try:
                claude_resp = await send_to_claude(transcript, system_prompt=db.system_prompt())
                if not claude_resp:
                    claude_resp = "(empty response)"
            except Exception as e:
                await ws.send_json({"error": "llm_error", "detail": str(e)})
                continue

            # stream the response in small chunks to simulate realtime
            chunk_size = 120
            for i in range(0, len(claude_resp), chunk_size):
                chunk = claude_resp[i:i+chunk_size]
                await ws.send_json({"chunk": chunk})

            # Generate TTS for the complete response
            try:
                tts_path = synthesize_tts(claude_resp)
                tts_filename = os.path.basename(tts_path)
                await ws.send_json({
                    "done": True,
                    "tts_url": f"/audio/tts/{tts_filename}"
                })
            except Exception as e:
                print("TTS error:", e)
                await ws.send_json({"done": True})
    except WebSocketDisconnect:
        return
@app.post("/text/turn")
async def text_turn(payload: dict):
    user_text = payload.get("text", "")
    if not user_text:
        return {"reply": "(no input text)"}

    try:
        claude_resp = await send_to_claude(user_text, system_prompt=db.system_prompt())
        reply_text = claude_resp or "(empty response)"
    except Exception as e:
        print("LLM error:", e)
        reply_text = f"I heard: '{user_text}'. How can I help you further?"

    try:
        tts_path = synthesize_tts(reply_text)
        tts_filename = os.path.basename(tts_path)
        return {
      
[truncated — 279 more characters]
```

### services/voice_clone.py

```python
# services/voice_clone.py
# Voice cloning is model-dependent and often requires uploading a reference audio sample
# to the voice provider. This module outlines the workflow; implement with your chosen service.

def ensure_voice_profile(user_id: str):
    # check if user has voice profile in DB — return profile id or None
    return None

def clone_voice_if_needed(user_id: str, sample_audio_path: str):
    # Upload sample to Fish API or a voice-clone provider, get voice_id, store in DB
    # Return voice_id
    raise NotImplementedError("Implement voice cloning per provider terms and APIs")

```

### services/tts_fish.py

```python
# services/tts_fish.py
import os
import time
import requests
import pathlib
import uuid

FISH_API_KEY = os.getenv("FISH_API_KEY", "cdb035ad393f421987df4879369b9b7f")
FISH_API_ROOT = "https://api.fish.audio/v1/tts"  # Correct endpoint

def synthesize_tts(text: str, voice_id=None, format="mp3"):
    if voice_id is None:
        voice_id = os.getenv("FISH_VOICE_ID")

    headers = {
        "Authorization": f"Bearer {FISH_API_KEY}",
        "Content-Type": "application/json",
    }

    payload = {
        "text": text,
        "model": os.getenv("FISH_TTS_MODEL", "speech-1.6"),
    }

    try:
        # Call the Fish Audio API
        resp = requests.post(FISH_API_ROOT, headers=headers, json=payload)

        # 402 means invalid key or no credits
        if resp.status_code == 402:
            raise Exception("Fish API key invalid or credits exhausted.")

        # 200 means we got audio data (binary)
        if resp.status_code != 200:
            raise Exception(f"Bad response {resp.status_code}: {resp.text}")

        # Save the binary MP3/WAV data
        out_dir = pathlib.Path("tts_cache")
        out_dir.mkdir(exist_ok=True)
        filename = f"tts_{uuid.uuid4()}.{format}"
        path = out_dir / filename

        path.write_bytes(resp.content)
        return str(path)

    except Exception as e:
        print("TTS error:", e)
        return None

```

### services/anthropic_client.py

```python
# services/anthropic_client.py
import os, httpx, asyncio

# Support multiple possible environment variable names for the Anthropic/Claude API key
ANTHROPIC_KEY = (
    os.getenv("ANTHROPIC_API_KEY") or os.getenv("CLAUDEAPI") or os.getenv("CLAUDE_API_KEY") or os.getenv("ANTHROPIC_KEY")
)
ANTHROPIC_MODEL = os.getenv("ANTHROPIC_MODEL", "claude-3-opus-20240229")
ANTHROPIC_URL = "https://api.anthropic.com/v1/messages"  # Messages API endpoint


async def send_to_claude(user_prompt: str, system_prompt: str):
    # Build the messages/prompt that Claude expects (Anthropic uses a specific format)
    prompt = f"{system_prompt}\n\nHuman: {user_prompt}\n\nAssistant:"

    if not ANTHROPIC_KEY:
        # Raise a clear error so the caller can return a helpful JSON response
        raise RuntimeError("Missing ANTHROPIC_API_KEY environment variable")

    # Recent Anthropic API versions require an 'anthropic-version' header.
    headers = {
        "x-api-key": str(ANTHROPIC_KEY),
        "Content-Type": "application/json",
        "anthropic-version": os.getenv("ANTHROPIC_VERSION", "2023-10-01"),
    }
    payload = {
        "model": ANTHROPIC_MODEL,
        "prompt": prompt,
        "max_tokens_to_sample": 800,
        "temperature": 0.7,
    }

    try:
        async with httpx.AsyncClient(timeout=30.0) as client:
            # Messages API format
            messages_payload = {
                "model": ANTHROPIC_MODEL,
                "messages": [
                    {"role": "user", "content": user_prompt}
                ],
                "max_tokens": 800,
                "temperature": 0.7
            }
            if system_prompt:
                messages_payload["system"] = system_prompt

            r = await client.post(ANTHROPIC_URL, json=messages_payload, headers=headers)
            r.raise_for_status()
            body = r.json()
            # Messages API returns content in message.content
            return body.get("content", [{}])[0].get("text", "")
    except httpx.HTTPStatusError as e:
        # Surface HTTP errors with status and body
        raise RuntimeError(f"Anthropic API error: {e.response.status_code} {e.response.text}")
    except Exception as e:
        raise RuntimeError(f"Anthropic request failed: {e}")

```

### services/convo_memory.py

```python
# services/convo_memory.py
import sqlalchemy as sa
from sqlalchemy.orm import sessionmaker
from sqlalchemy.ext.declarative import declarative_base
import datetime
import json

Base = declarative_base()

class Message(Base):
    __tablename__ = "messages"
    id = sa.Column(sa.Integer, primary_key=True)
    session_id = sa.Column(sa.String, index=True)
    role = sa.Column(sa.String)
    text = sa.Column(sa.Text)
    created_at = sa.Column(sa.DateTime, default=datetime.datetime.utcnow)

class Session(Base):
    __tablename__ = "sessions"
    id = sa.Column(sa.String, primary_key=True)
    user_name = sa.Column(sa.String)
    meta = sa.Column(sa.Text, default="{}")

class ConversationStore:
    def __init__(self, db_url="sqlite:///convo_memory.db"):
        self.engine = sa.create_engine(db_url, connect_args={"check_same_thread": False})
        Base.metadata.create_all(self.engine)
        self.Session = sessionmaker(bind=self.engine)

    def create_session(self, sid, user_name):
        s = self.Session()
        sess = Session(id=sid, user_name=user_name, meta="{}")
        s.add(sess)
        s.commit()
        s.close()

    def append_message(self, sid, role, text):
        s = self.Session()
        m = Message(session_id=sid, role=role, text=text)
        s.add(m)
        s.commit()
        s.close()

    def get_session(self, sid):
        s = self.Session()
        sess = s.query(Session).filter_by(id=sid).first()
        s.close()
        return sess

    def build_prompt(self, sid, user_text):
        # Build a prompt with limited recent history
        s = self.Session()
        msgs = s.query(Message).filter(Message.session_id==sid).order_by(Message.created_at.desc()).limit(8).all()
        s.close()
        # reverse for chronological
        msgs = list(reversed(msgs))
        # system guidance
        system = self.system_prompt()
        convo = "\n".join([f"{m.role.capitalize()}: {m.text}" for m in msgs])
        prompt = f"{convo}\nUser: {user_text}"
        return prompt

    def system_prompt(self):
        return (
            """You are a compassionate, calming psychotherapy-style conversational assistant. 
At the start of the session, you should gently clarify once that you are not a licensed therapist or medical professional. 
After that, respond naturally, empathetically, and conversationally — without repeating the disclaimer again.

Your tone should be warm, validating, and concise. Encourage reflection but keep replies short (2–4 sentences max).
"""
        )

```

### services/stt_google.py

```python
# services/stt_google.py
from google.cloud import speech_v1p1beta1 as speech
import os
import subprocess
import tempfile


def _has_ffmpeg() -> bool:
    """Return True if ffmpeg is available on PATH."""
    return subprocess.run(["which", "ffmpeg"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0


def _transcode_to_wav(input_path: str) -> str:
    """Transcode input audio to 16k mono WAV using ffmpeg.

    Returns path to the created WAV file. Raises subprocess.CalledProcessError on failure.
    """
    fd, out_path = tempfile.mkstemp(suffix=".wav")
    os.close(fd)
    cmd = [
        "ffmpeg",
        "-y",
        "-i",
        input_path,
        "-ar",
        "16000",
        "-ac",
        "1",
        out_path,
    ]
    subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    return out_path


def transcribe_audio(file_path: str, language="en-US"):
    """Transcribe an audio file using Google Speech-to-Text.

    If ffmpeg is available and the input is not a WAV/PCM file, we transcode to
    16k mono WAV for reliable recognition. Otherwise we try to call the API
    with the original bytes and let it infer the encoding.
    """
    client = speech.SpeechClient()

    # If not a WAV/PCM file, try to transcode with ffmpeg when available
    _, ext = os.path.splitext(file_path)
    ext = ext.lower()
    temp_wav = None
    try:
        if ext not in (".wav", ".pcm") and _has_ffmpeg():
            try:
                temp_wav = _transcode_to_wav(file_path)
                use_path = temp_wav
            except Exception:
                # If transcoding fails, fall back to original file
                use_path = file_path
        else:
            use_path = file_path

        with open(use_path, "rb") as f:
            content = f.read()

        audio = speech.RecognitionAudio(content=content)

        # If we transcribed to WAV we supply LINEAR16/16000, otherwise let API infer
        if use_path.endswith(".wav") or use_path.endswith(".pcm"):
            config = speech.RecognitionConfig(
                encoding=speech.RecognitionConfig.AudioEncoding.LINEAR16,
                sample_rate_hertz=16000,
                language_code=language,
                enable_automatic_punctuation=True,
            )
        else:
            config = speech.RecognitionConfig(
                encoding=speech.RecognitionConfig.AudioEncoding.ENCODING_UNSPECIFIED,
                language_code=language,
                enable_automatic_punctuation=True,
            )

        response = client.recognize(config=config, audio=audio)
        if not response.results:
            return ""
        return " ".join([r.alternatives[0].transcript for r in response.results])
    finally:
        if temp_wav:
            try:
                os.remove(temp_wav)
            except Exception:
                pass

```

### static/index.html

```html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <title>Psychotherapy Voice Agent</title>
  <link rel="icon" href="data:image/svg+xml;utf8,
  <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'>
    <rect width='16' height='16' fill='%23007acc'/>
    <text x='8' y='11' font-size='10' text-anchor='middle' fill='white'>PA</text>
  </svg>">

  <style>
    body {
      margin: 0;
      padding: 0;
      font-family: 'Segoe UI', sans-serif;
      background: linear-gradient(135deg, #7f7fd5, #86a8e7, #91eae4);
      height: 100vh;
      display: flex;
      justify-content: center;
      align-items: center;
    }

    .chat-container {
      width: 420px;
      height: 600px;
      background: rgba(255, 255, 255, 0.15);
      backdrop-filter: blur(12px);
      border-radius: 20px;
      box-shadow: 0 8px 32px rgba(31, 38, 135, 0.3);
      display: flex;
      flex-direction: column;
      overflow: hidden;
    }

    .chat-header {
      text-align: center;
      padding: 20px;
      background: rgba(255, 255, 255, 0.2);
      font-size: 1.2em;
      font-weight: bold;
      color: #fff;
      border-bottom: 1px solid rgba(255, 255, 255, 0.2);
      letter-spacing: 0.5px;
    }

    .chat-body {
      flex: 1;
      padding: 15px;
      overflow-y: auto;
      color: #fff;
      scroll-behavior: smooth;
      display: flex;
      flex-direction: column;
    }

    .bubble {
      max-width: 80%;
      padding: 10px 14px;
      border-radius: 15px;
      margin-bottom: 12px;
      line-height: 1.4em;
      word-wrap: break-word;
      animation: fadeIn 0.3s ease;
    }

    .user {
      align-self: flex-end;
      background: rgba(255, 255, 255, 0.8);
      color: #222;
      border-bottom-right-radius: 0;
    }

    .bot {
      align-self: flex-start;
      background: rgba(0, 0, 0, 0.4);
      color: #fff;
      border-bottom-left-radius: 0;
    }

    .chat-footer {
      display: flex;
      flex-direction: column;
      align-items: center;
      padding: 15px;
      border-top: 1px solid rgba(255, 255, 255, 0.2);
    }

    #mic-btn {
      width: 70px;
      height: 70px;
      background: rgba(255, 255, 255, 0.25);
      border-radius: 50%;
      border: none;
      outline: none;
      cursor: pointer;
      display: flex;
      justify-content: center;
      align-items: center;
      transition: 0.3s ease;
      position: relative;
    }

    #mic-btn:hover {
      background: rgba(255, 255, 255, 0.4);
      transform: scale(1.05);
    }

    .mic-icon {
      font-size: 32px;
      color: white;
      transition: transform 0.3s ease, color 0.3s ease;
    }

    .mic-active .mic-icon {
      transform: scale(1.3);
      color: #91eae4;
      animation: pulse 1.2s infinite;
    }

    @keyframes fadeIn {
      from { opacity: 0; transform: translateY(10px); }
      to { opacity: 1; transform: translateY(0); }
    }

    @keyframes pulse {
      0% { box-shadow: 0 0 0 0 rgba(255,255,255,0.4); }
      70% { box-shadow: 0 0 0 20px rgba(255,255,255,0); }
      100% { box-shadow: 0 0 0 0 rgba(255,255,255,0); }
    }

    #player { display: none; }
  </style>
</head>

<body>
  <div class="chat-container">
    <div class="chat-header">🧠 Psychotherapy Voice Agent</div>

    <div id="chat-body" class="chat-body">
      <div class="bubble bot">Hello! I'm your therapy assistant. How are you feeling today?</div>
    </div>

    <div class="chat-footer">
      <button id="mic-btn"><span class="mic-icon">🎙️</span></button>
      <audio id="player"></audio>
    </div>
  </div>

  <script>
    const recognition = new (window.SpeechRecognition || window.webkitSpeechRecognition)();
    recognition.continuous = true; // <-- keeps listening as long as user talks
    recognition.interimResults = true;
    recognition.lang = "en-US";

    const chatBody = document.getElementById("chat-body");
    const player = document.getElementById("player");
    const micBtn = document.getElementById("mic-btn");
    const micIcon = micBtn.querySelector(".mic-icon");
    let isSpeaking = false;
    let isListening = false;
    let autoMode = false;
    let userTextBuffer = "";

    function appendMessage(text, sender) {
      const bubble = document.createElement("div");
      bubble.classList.add("bubble", sender);
      bubble.textContent = text;
      chatBody.appendChild(bubble);
      chatBody.scrollTop = chatBody.scrollHeight;
    }

    micBtn.onclick = () => {
      if (isSpeaking) return;
      autoMode = true;
      startListening();
    };

    function startListening() {
      if (!isListening) {
        recognition.start();
        isListening = true;
        micBtn.classList.add("mic-active");
        console.log("🎙 Listening...");
      }
    }

    recognition.onresult = async (event) => {
      let interimTranscript = "";
      let finalTranscript = "";

      for (let i = event.resultIndex; i < event.results.length; ++i) {
        if (event.results[i].isFinal) {
          finalTranscript += event.results[i][0].transcript;
        } else {
          interimTranscript += event.results[i][0].transcript;
        }
      }

      if (finalTranscript) {
        userTextBuffer += finalTranscript.trim() + " ";
        console.log("🗣 Final:", userTextBuffer);
      }

      // detect pause
      clearTimeout(window.speechPauseTimeout);
      window.speechPauseTimeout = setTimeout(async () => {
        if (userTextBuffer.trim() !== "") {
          recognition.stop();
          isListening = false;
          micBtn.classList.remove("mic-active");
          appendMessage(userTextBuffer.trim(), "user");

          const res = await fetch("/text/turn", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ text: userTextBuffer.trim() })
          });

          const data = await res.json();
          if (data.reply) appendMessage(data.reply, "bot");
          if (data.tts_url) playResponse(data.tts_url);
      
[truncated — 806 more characters]
```