@@ -80,14 +80,3 @@ SEMANTIC_ENDPOINTING_HOLD_MS=600
|
||||
REALTIME_VOICE_FILLER_DELAY_MS=300
|
||||
REALTIME_VOICE_TTS_CHUNK_SOFT_MIN_CHARS=10
|
||||
REALTIME_VOICE_TTS_CHUNK_SOFT_MIN_WORDS=2
|
||||
|
||||
CRM_INTERACTION_ENABLED=false
|
||||
CRM_INTERACTION_SERVICE_URL=http://interaction-service:8000
|
||||
CRM_APP_TOKEN_SECRET=
|
||||
CRM_QUEUE_ID=
|
||||
|
||||
SALES_SYNC_ENABLED=true
|
||||
SALES_SERVICE_URL=http://sales-service:8000
|
||||
SALES_SYNC_TIMEOUT_SECONDS=8
|
||||
REALTIME_VOICE_QUEUE_ID=
|
||||
REALTIME_VOICE_QUEUE_CODE=
|
||||
|
||||
Generated
-10
@@ -1,10 +0,0 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Ignored default folder with query files
|
||||
/queries/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
Generated
-16
@@ -1,16 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CheckStyle-IDEA" serialisationVersion="2">
|
||||
<checkstyleVersion>13.4.0</checkstyleVersion>
|
||||
<scanScope>JavaOnly</scanScope>
|
||||
<copyLibs>true</copyLibs>
|
||||
<option name="thirdPartyClasspath" />
|
||||
<option name="activeLocationIds" />
|
||||
<option name="locations">
|
||||
<list>
|
||||
<ConfigurationLocation id="bundled-sun-checks" type="BUNDLED" scope="All" description="Sun Checks">(bundled)</ConfigurationLocation>
|
||||
<ConfigurationLocation id="bundled-google-checks" type="BUNDLED" scope="All" description="Google Checks">(bundled)</ConfigurationLocation>
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
Generated
-15
@@ -1,15 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GitToolBoxProjectSettings">
|
||||
<option name="commitMessageIssueKeyValidationOverride">
|
||||
<BoolValueOverride>
|
||||
<option name="enabled" value="true" />
|
||||
</BoolValueOverride>
|
||||
</option>
|
||||
<option name="commitMessageValidationEnabledOverride">
|
||||
<BoolValueOverride>
|
||||
<option name="enabled" value="true" />
|
||||
</BoolValueOverride>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
Generated
-12
@@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="MaterialThemeProjectNewConfig">
|
||||
<option name="metadata">
|
||||
<MTProjectMetadataState>
|
||||
<option name="migrated" value="true" />
|
||||
<option name="pristineConfig" value="false" />
|
||||
<option name="userId" value="-3c075d73:1954b64a9de:-7fdf" />
|
||||
</MTProjectMetadataState>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
Generated
-6
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_23" default="true" project-jdk-name="openjdk-23" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/out" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
-8
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/realtime_voice_service.iml" filepath="$PROJECT_DIR$/.idea/realtime_voice_service.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
Generated
-9
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="JAVA_MODULE" version="4">
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
Generated
-6
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -1,110 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import struct
|
||||
from array import array
|
||||
|
||||
try:
|
||||
import audioop as _audioop
|
||||
except ModuleNotFoundError:
|
||||
try:
|
||||
import audioop_lts as _audioop # type: ignore[import-not-found]
|
||||
except ModuleNotFoundError:
|
||||
_audioop = None
|
||||
|
||||
|
||||
def _native_is_little_endian() -> bool:
|
||||
return struct.pack("=h", 1) == struct.pack("<h", 1)
|
||||
|
||||
|
||||
def _read_int16_samples(fragment: bytes, width: int) -> array:
|
||||
if width != 2:
|
||||
raise NotImplementedError("audioop_compat fallback currently supports only 16-bit PCM")
|
||||
samples = array("h")
|
||||
samples.frombytes(fragment)
|
||||
if not _native_is_little_endian():
|
||||
samples.byteswap()
|
||||
return samples
|
||||
|
||||
|
||||
def _write_int16_samples(samples: array) -> bytes:
|
||||
output = array("h", samples)
|
||||
if not _native_is_little_endian():
|
||||
output.byteswap()
|
||||
return output.tobytes()
|
||||
|
||||
|
||||
def _clip_int16(value: float) -> int:
|
||||
return max(-32768, min(32767, int(round(value))))
|
||||
|
||||
|
||||
class _AudioopFallback:
|
||||
@staticmethod
|
||||
def rms(fragment: bytes, width: int) -> int:
|
||||
samples = _read_int16_samples(fragment, width)
|
||||
if not samples:
|
||||
return 0
|
||||
mean_square = sum(sample * sample for sample in samples) / len(samples)
|
||||
return int(math.sqrt(mean_square))
|
||||
|
||||
@staticmethod
|
||||
def max(fragment: bytes, width: int) -> int:
|
||||
samples = _read_int16_samples(fragment, width)
|
||||
if not samples:
|
||||
return 0
|
||||
return max(abs(sample) for sample in samples)
|
||||
|
||||
@staticmethod
|
||||
def tomono(fragment: bytes, width: int, lfactor: float, rfactor: float) -> bytes:
|
||||
samples = _read_int16_samples(fragment, width)
|
||||
if len(samples) % 2 != 0:
|
||||
raise ValueError("Stereo PCM must contain an even number of samples")
|
||||
mono = array("h")
|
||||
for index in range(0, len(samples), 2):
|
||||
left = samples[index]
|
||||
right = samples[index + 1]
|
||||
mono.append(_clip_int16((left * lfactor) + (right * rfactor)))
|
||||
return _write_int16_samples(mono)
|
||||
|
||||
@staticmethod
|
||||
def ratecv(
|
||||
fragment: bytes,
|
||||
width: int,
|
||||
nchannels: int,
|
||||
inrate: int,
|
||||
outrate: int,
|
||||
state,
|
||||
weightA: int = 1,
|
||||
weightB: int = 0,
|
||||
) -> tuple[bytes, None]:
|
||||
del state, weightA, weightB
|
||||
if nchannels <= 0:
|
||||
raise ValueError("nchannels must be positive")
|
||||
if inrate <= 0 or outrate <= 0:
|
||||
raise ValueError("Sample rates must be positive")
|
||||
samples = _read_int16_samples(fragment, width)
|
||||
if not samples or inrate == outrate:
|
||||
return fragment, None
|
||||
if len(samples) % nchannels != 0:
|
||||
raise ValueError("PCM fragment size does not match channel count")
|
||||
|
||||
frame_count = len(samples) // nchannels
|
||||
output_frame_count = max(1, int(round(frame_count * outrate / inrate)))
|
||||
output = array("h")
|
||||
|
||||
for out_index in range(output_frame_count):
|
||||
position = out_index * inrate / outrate
|
||||
left_index = min(int(position), frame_count - 1)
|
||||
right_index = min(left_index + 1, frame_count - 1)
|
||||
fraction = max(0.0, min(1.0, position - left_index))
|
||||
for channel_index in range(nchannels):
|
||||
left_sample = samples[(left_index * nchannels) + channel_index]
|
||||
right_sample = samples[(right_index * nchannels) + channel_index]
|
||||
interpolated = left_sample + ((right_sample - left_sample) * fraction)
|
||||
output.append(_clip_int16(interpolated))
|
||||
return _write_int16_samples(output), None
|
||||
|
||||
|
||||
audioop = _audioop or _AudioopFallback()
|
||||
|
||||
__all__ = ["audioop"]
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import audioop
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
@@ -9,7 +10,6 @@ import wave
|
||||
from collections.abc import AsyncIterable
|
||||
from collections.abc import Iterable
|
||||
|
||||
from realtime_voice_service.audioop_compat import audioop
|
||||
from realtime_voice_service.providers.base import BaseTTS
|
||||
|
||||
|
||||
|
||||
@@ -1214,14 +1214,6 @@ class CallSession:
|
||||
def conversation(self) -> tuple[tuple[str, str], ...]:
|
||||
return tuple(self._conversation)
|
||||
|
||||
@property
|
||||
def customer_name(self) -> str | None:
|
||||
return self._customer_name
|
||||
|
||||
@property
|
||||
def session_language(self) -> str | None:
|
||||
return self._session_language
|
||||
|
||||
async def run(self) -> None:
|
||||
if self._assistant_task is not None:
|
||||
raise RuntimeError("CallSession.run() can only be called once per session")
|
||||
|
||||
@@ -6,12 +6,10 @@ import logging
|
||||
import os
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import FastAPI, WebSocket
|
||||
|
||||
from realtime_voice_service import crm_client
|
||||
from realtime_voice_service import sales_sync_client
|
||||
from realtime_voice_service.core.filler_audio import FillerAudioLibrary
|
||||
from realtime_voice_service.core.session import CallSession
|
||||
from realtime_voice_service.core.session import _normalize_voice_pronunciation
|
||||
@@ -150,52 +148,6 @@ def _llm_model_name() -> str:
|
||||
return str(os.getenv("OPENAI_LLM_MODEL", "gpt-4o-mini")).strip() or "gpt-4o-mini"
|
||||
|
||||
|
||||
def _utc_now_iso() -> str:
|
||||
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _conversation_transcript(session: CallSession) -> str | None:
|
||||
lines = [f"{speaker}: {text.strip()}" for speaker, text in session.conversation if str(text).strip()]
|
||||
if not lines:
|
||||
return None
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _conversation_summary(session: CallSession) -> str | None:
|
||||
user_turns = [text.strip() for speaker, text in session.conversation if speaker == "user" and str(text).strip()]
|
||||
assistant_turns = [text.strip() for speaker, text in session.conversation if speaker == "assistant" and str(text).strip()]
|
||||
if not user_turns and not assistant_turns:
|
||||
return None
|
||||
parts: list[str] = []
|
||||
if user_turns:
|
||||
parts.append(f"Customer asked: {user_turns[0][:220]}")
|
||||
if assistant_turns:
|
||||
parts.append(f"Assistant response: {assistant_turns[-1][:220]}")
|
||||
if session.customer_name:
|
||||
parts.append(f"Customer name: {session.customer_name}")
|
||||
return " | ".join(parts)[:700]
|
||||
|
||||
|
||||
def _session_sync_metadata(
|
||||
session: CallSession,
|
||||
transport: BaseMediaTransport,
|
||||
*,
|
||||
failure_reason: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"protocol": transport.protocol,
|
||||
"sample_rate_hz": transport.sample_rate_hz,
|
||||
"frame_duration_ms": transport.frame_duration_ms,
|
||||
"frame_bytes": transport.frame_bytes,
|
||||
"session_language": session.session_language,
|
||||
"customer_name": session.customer_name,
|
||||
"interruptions": list(session.interruptions),
|
||||
"latency_ms": dict(session.last_latency_ms),
|
||||
"conversation_entries": len(session.conversation),
|
||||
"failure_reason": failure_reason,
|
||||
}
|
||||
|
||||
|
||||
|
||||
class RealtimeVoiceService:
|
||||
def __init__(self) -> None:
|
||||
@@ -324,23 +276,8 @@ class RealtimeVoiceService:
|
||||
|
||||
async def _run_transport_session(self, transport: BaseMediaTransport) -> None:
|
||||
session = self._build_session(transport)
|
||||
started_at = _utc_now_iso()
|
||||
interaction_id = await crm_client.create_interaction(call_id=session.session_id)
|
||||
await sales_sync_client.sync_voice_session(
|
||||
call_id=session.session_id,
|
||||
interaction_id=interaction_id,
|
||||
voice_session_id=session.session_id,
|
||||
ai_state=str(session.state.value).lower(),
|
||||
telephony_status="connected",
|
||||
call_status="started",
|
||||
started_at=started_at,
|
||||
summary="Realtime voice session started",
|
||||
metadata=_session_sync_metadata(session, transport),
|
||||
)
|
||||
failure_reason: str | None = None
|
||||
call_status = "completed"
|
||||
async with self._track_session(session):
|
||||
try:
|
||||
LOGGER.info(
|
||||
"starting realtime session %s via %s sample_rate=%s frame_ms=%s frame_bytes=%s",
|
||||
session.session_id,
|
||||
@@ -350,25 +287,6 @@ class RealtimeVoiceService:
|
||||
transport.frame_bytes,
|
||||
)
|
||||
await session.run()
|
||||
except Exception as exc:
|
||||
call_status = "failed"
|
||||
failure_reason = str(exc)[:300]
|
||||
raise
|
||||
finally:
|
||||
await sales_sync_client.sync_voice_session(
|
||||
call_id=session.session_id,
|
||||
interaction_id=interaction_id,
|
||||
caller_name=session.customer_name,
|
||||
voice_session_id=session.session_id,
|
||||
ai_state=str(session.state.value).lower(),
|
||||
telephony_status="ended",
|
||||
call_status=call_status,
|
||||
started_at=started_at,
|
||||
ended_at=_utc_now_iso(),
|
||||
summary=_conversation_summary(session),
|
||||
transcript_text=_conversation_transcript(session),
|
||||
metadata=_session_sync_metadata(session, transport, failure_reason=failure_reason),
|
||||
)
|
||||
if interaction_id:
|
||||
await crm_client.close_interaction(interaction_id)
|
||||
|
||||
@@ -446,8 +364,6 @@ async def health() -> dict[str, object]:
|
||||
"sample_rate_hz": service.sample_rate_hz,
|
||||
"active_sessions": service.active_session_count,
|
||||
"audiosocket_port": _audiosocket_port(),
|
||||
"sales_sync_enabled": os.getenv("SALES_SYNC_ENABLED", "0"),
|
||||
"crm_interaction_enabled": os.getenv("CRM_INTERACTION_ENABLED", "0"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import audioop
|
||||
import base64
|
||||
import contextlib
|
||||
import io
|
||||
@@ -12,7 +13,6 @@ import wave
|
||||
from collections.abc import Sequence
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from realtime_voice_service.audioop_compat import audioop
|
||||
from realtime_voice_service.providers.base import BaseSTT
|
||||
from realtime_voice_service.providers.base import BaseSTTStream
|
||||
from realtime_voice_service.providers.base import PartialTranscriptCallback
|
||||
|
||||
+1
-1
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import audioop
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
@@ -11,7 +12,6 @@ from collections.abc import AsyncIterable
|
||||
from urllib.parse import quote
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from realtime_voice_service.audioop_compat import audioop
|
||||
from realtime_voice_service.providers.base import BaseTTS
|
||||
|
||||
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from realtime_voice_service.crm_client import _issue_service_token
|
||||
|
||||
|
||||
LOGGER = logging.getLogger("uvicorn.error")
|
||||
|
||||
|
||||
def _enabled() -> bool:
|
||||
return os.getenv("SALES_SYNC_ENABLED", "0").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _base_url() -> str:
|
||||
return str(os.getenv("SALES_SERVICE_URL", "http://sales-service:8000")).rstrip("/")
|
||||
|
||||
|
||||
def _timeout_seconds() -> float:
|
||||
raw = str(os.getenv("SALES_SYNC_TIMEOUT_SECONDS", "8")).strip()
|
||||
try:
|
||||
return max(float(raw), 1.0)
|
||||
except ValueError:
|
||||
return 8.0
|
||||
|
||||
|
||||
def _secret() -> str:
|
||||
return str(os.getenv("CRM_APP_TOKEN_SECRET", "")).strip()
|
||||
|
||||
|
||||
def _service_headers() -> dict[str, str] | None:
|
||||
secret = _secret()
|
||||
if not secret:
|
||||
LOGGER.warning("sales sync: CRM_APP_TOKEN_SECRET not configured")
|
||||
return None
|
||||
token = _issue_service_token(secret)
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _queue_id() -> str | None:
|
||||
raw = str(
|
||||
os.getenv("REALTIME_VOICE_QUEUE_ID")
|
||||
or os.getenv("CRM_QUEUE_ID")
|
||||
or ""
|
||||
).strip()
|
||||
return raw or None
|
||||
|
||||
|
||||
def _queue_code() -> str | None:
|
||||
raw = str(os.getenv("REALTIME_VOICE_QUEUE_CODE", "")).strip()
|
||||
return raw or None
|
||||
|
||||
|
||||
async def sync_voice_session(
|
||||
*,
|
||||
call_id: str,
|
||||
interaction_id: str | None = None,
|
||||
caller_number: str | None = None,
|
||||
caller_name: str | None = None,
|
||||
voice_session_id: str | None = None,
|
||||
ai_session_id: str | None = None,
|
||||
ai_state: str | None = None,
|
||||
handoff_reason: str | None = None,
|
||||
telephony_status: str | None = None,
|
||||
call_status: str | None = None,
|
||||
started_at: str | None = None,
|
||||
ended_at: str | None = None,
|
||||
summary: str | None = None,
|
||||
transcript_text: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
if not _enabled():
|
||||
return None
|
||||
headers = _service_headers()
|
||||
if headers is None:
|
||||
return None
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"call_id": call_id,
|
||||
"interaction_id": interaction_id,
|
||||
"queue_id": _queue_id(),
|
||||
"queue_code": _queue_code(),
|
||||
"caller_number": caller_number,
|
||||
"caller_name": caller_name,
|
||||
"voice_session_id": voice_session_id or call_id,
|
||||
"ai_session_id": ai_session_id,
|
||||
"ai_state": ai_state,
|
||||
"handoff_reason": handoff_reason,
|
||||
"telephony_status": telephony_status,
|
||||
"call_status": call_status,
|
||||
"started_at": started_at,
|
||||
"ended_at": ended_at,
|
||||
"summary": summary,
|
||||
"transcript_text": transcript_text,
|
||||
"metadata": {
|
||||
"source_service": "realtime_voice_service",
|
||||
**(metadata or {}),
|
||||
},
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_timeout_seconds()) as client:
|
||||
response = await client.post(
|
||||
f"{_base_url()}/internal/sales-sync/voice",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
LOGGER.info(
|
||||
"sales sync: voice session synced call_id=%s voice_session_id=%s status=%s",
|
||||
call_id,
|
||||
voice_session_id or call_id,
|
||||
call_status,
|
||||
)
|
||||
return data if isinstance(data, dict) else None
|
||||
except Exception:
|
||||
LOGGER.exception(
|
||||
"sales sync: failed to sync voice session call_id=%s voice_session_id=%s",
|
||||
call_id,
|
||||
voice_session_id or call_id,
|
||||
)
|
||||
return None
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import audioop
|
||||
import logging
|
||||
import os
|
||||
import struct
|
||||
@@ -8,7 +9,6 @@ import time
|
||||
import uuid
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
from realtime_voice_service.audioop_compat import audioop
|
||||
from realtime_voice_service.transports.base import BaseMediaTransport
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user