intiial
Some checks failed
Deploy Dev / Build (pull_request) Successful in 1m19s
Deploy Dev / Push (pull_request) Failing after 1s
Deploy Dev / Deploy dev (pull_request) Has been skipped

This commit is contained in:
Egor Matveev
2025-11-24 02:36:02 +03:00
commit aaaa29a5a5
8 changed files with 435 additions and 0 deletions

20
.deploy/deploy-dev.yaml Normal file
View File

@@ -0,0 +1,20 @@
version: "3.4"
services:
caller-nginx:
image: mathwave/sprint-repo:caller
networks:
- common-infra-nginx-development
deploy:
mode: replicated
restart_policy:
condition: any
update_config:
parallelism: 1
order: start-first
networks:
common-infra-nginx-development:
external: true

View File

@@ -0,0 +1,41 @@
name: Deploy Dev
on:
pull_request:
branches:
- dev
types: [closed]
jobs:
build:
name: Build
runs-on: [ dev ]
steps:
- name: login
run: docker login -u mathwave -p ${{ secrets.DOCKERHUB_PASSWORD }}
- name: checkout
uses: actions/checkout@v4
with:
ref: dev
- name: build
run: docker build -t mathwave/sprint-repo:callter .
push:
name: Push
runs-on: [ dev ]
needs: build
steps:
- name: push
run: docker push mathwave/sprint-repo:caller
deploy-dev:
name: Deploy dev
runs-on: [prod]
needs: push
steps:
- name: login
run: docker login -u mathwave -p ${{ secrets.DOCKERHUB_PASSWORD }}
- name: checkout
uses: actions/checkout@v4
with:
ref: dev
- name: deploy
run: docker stack deploy --with-registry-auth -c ./.deploy/deploy-dev.yaml caller-development

120
.gitignore vendored Normal file
View File

@@ -0,0 +1,120 @@
# Django #
*.log
*.pot
*.pyc
__pycache__
db.sqlite3
media
data
*/__pycache__
# Backup files #
*.bak
# If you are using PyCharm #
.idea
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/dictionaries
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.xml
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/gradle.xml
.idea/**/libraries
*.iws /out/
# Python #
*.py[cod]
*$py.class
# Distribution / packaging
.Python build/
develop-eggs/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
.pytest_cache/
nosetests.xml
coverage.xml
*.cover
.hypothesis/
postgres-data
# Jupyter Notebook
.ipynb_checkpoints
# pyenv
.python-version
# celery
celerybeat-schedule.*
# SageMath parsed files
*.sage.py
# Environments
.venv
env/
ENV/
venv/
env.bak/
venv.bak/
# mkdocs documentation
/site
# mypy
.mypy_cache/
# Sublime Text #
*.tmlanguage.cache
*.tmPreferences.cache
*.stTheme.cache
*.sublime-workspace
*.sublime-project
# sftp configuration file
sftp-config.json
# Package control specific files Package
Control.last-run
Control.ca-list
Control.ca-bundle
Control.system-ca-bundle
GitHub.sublime-settings
# Visual Studio Code #
.vscode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history
.DS_Store

13
Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM python:3.10
RUN mkdir /usr/src/app
WORKDIR /usr/src/app
COPY requirements.txt requirements.txt
RUN pip install -r requirements.txt
COPY . .
ENV PYTHONUNBUFFERED=1
ENTRYPOINT ["python", "main.py"]

69
main.py Normal file
View File

@@ -0,0 +1,69 @@
from uuid import UUID, uuid4
import fastapi
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
import uvicorn
app = fastapi.FastAPI(debug=True)
templates = Jinja2Templates(directory="templates")
calls = {}
class CallPostResponse(BaseModel):
call_id: UUID
@app.post("/call")
async def create_call() -> CallPostResponse:
call_id = uuid4()
calls[call_id] = {}
return CallPostResponse(call_id=call_id)
@app.get("/call", response_class=HTMLResponse)
async def call(request: fastapi.Request, call_id: UUID = fastapi.Query(), session_id: UUID | None = fastapi.Cookie(None)):
if not session_id:
session_id = uuid4()
response = RedirectResponse(f"/call?call_id={call_id}")
response.set_cookie("session_id", str(session_id))
return response
if call_id not in calls:
return RedirectResponse("/")
return templates.TemplateResponse(
"call.html",
context={"request": request}
)
@app.websocket("/connect")
async def connect(websocket: fastapi.WebSocket, call_id: UUID = fastapi.Query(), session_id: UUID | None = fastapi.Cookie(None)):
await websocket.accept()
calls[call_id][session_id] = websocket
another_websocket: fastapi.WebSocket | None = None
while True:
data = await websocket.receive_bytes()
print("RECEIVED")
if not another_websocket:
if len(calls[call_id]) == 1:
continue
for sess_id, ws in calls[call_id].items():
if sess_id != session_id:
another_websocket = ws
print("SENDING DATA")
await another_websocket.send_bytes(data)
@app.get("/", response_class=HTMLResponse)
async def home(request: fastapi.Request):
return templates.TemplateResponse(
"index.html",
context={"request": request}
)
if __name__ == '__main__':
uvicorn.run(app, host="0.0.0.0", port=1239)

18
requirements.txt Normal file
View File

@@ -0,0 +1,18 @@
annotated-doc==0.0.4
annotated-types==0.7.0
anyio==4.11.0
click==8.3.1
exceptiongroup==1.3.1
fastapi==0.121.3
h11==0.16.0
idna==3.11
Jinja2==3.1.6
MarkupSafe==3.0.3
pydantic==2.12.4
pydantic_core==2.41.5
sniffio==1.3.1
starlette==0.50.0
typing-inspection==0.4.2
typing_extensions==4.15.0
uvicorn==0.38.0
websockets==15.0.1

129
templates/call.html Normal file
View File

@@ -0,0 +1,129 @@
<html>
<head>
<script>
microOn = false;
function changeMicro() {
microOn = !microOn;
const micOn = document.getElementById("micOn");
const micOff = document.getElementById("micOff");
if (microOn) {
micOn.hidden = false;
micOff.hidden = true;
} else {
micOn.hidden = true;
micOff.hidden = false;
}
}
async function getMicrophoneStream() {
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: false
});
console.log("Microphone access granted, stream object:", stream);
return stream;
} catch (err) {
console.error(`Error accessing microphone: ${err}`);
// Handle cases where the user denies permission or no mic is available
}
}
async function connect() {
const urlParams = new URLSearchParams(window.location.search);
const callId = urlParams.get('call_id');
const socket = new WebSocket("/connect?call_id=" + callId);
socket.addEventListener("open", async () => {
console.log("WebSocket открыт");
// Запрашиваем доступ к микрофону
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
// Создаём AudioContext
const audioContext = new AudioContext();
// Создаём источник из микрофона
const source = audioContext.createMediaStreamSource(stream);
// ScriptProcessorNode является устаревшим, но всё ещё работает
const processor = audioContext.createScriptProcessor(4096, 1, 1);
source.connect(processor);
processor.connect(audioContext.destination); // необязательно, но нужно для запуска
processor.onaudioprocess = (audioEvent) => {
const input = audioEvent.inputBuffer.getChannelData(0); // Float32Array
// Конвертируем в 16-bit PCM (часто требуется серверам)
const pcm = floatTo16BitPCM(input);
// Отправляем через WebSocket
if (socket.readyState === WebSocket.OPEN) {
socket.send(pcm);
}
};
});
socket.binaryType = "arraybuffer";
socket.onmessage = (msg) => {
const int16 = new Int16Array(msg.data);
playPCM(int16);
};
// Функция конвертации Float32 → Int16
function floatTo16BitPCM(float32Array) {
const buffer = new ArrayBuffer(float32Array.length * 2);
const view = new DataView(buffer);
for (let i = 0; i < float32Array.length; i++) {
let s = Math.max(-1, Math.min(1, float32Array[i]));
view.setInt16(i * 2, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
}
return buffer;
}
const audioContext = new AudioContext();
// Очередь буферов, чтобы звук шел плавно
const playQueue = [];
// Функция для воспроизведения PCM
function playPCM(int16Array) {
const float32 = new Float32Array(int16Array.length);
for (let i = 0; i < int16Array.length; i++) {
float32[i] = int16Array[i] / 0x7fff;
}
const audioBuffer = audioContext.createBuffer(1, float32.length, audioContext.sampleRate);
audioBuffer.getChannelData(0).set(float32);
playQueue.push(audioBuffer);
if (playQueue.length === 1) {
playNext();
}
}
function playNext() {
if (playQueue.length === 0) return;
const buffer = playQueue[0];
const source = audioContext.createBufferSource();
source.buffer = buffer;
source.connect(audioContext.destination);
source.onended = () => {
playQueue.shift();
playNext();
};
source.start();
}
}
connect();
</script>
</head>
<body>
<p id="micOff">Micro is off</p> <p hidden id="micOn">Micro is on</p> <button onclick="changeMicro();">change</button>
</body>
</html>

25
templates/index.html Normal file
View File

@@ -0,0 +1,25 @@
<html>
<head>
<script>
function startCall() {
fetch("/call", {
method: "POST",
}).then(response => response.json()).then(
(data) => {
const element = document.getElementById("call");
element.href = "/call?call_id=" + data.call_id;
element.hidden = false;
}
)
}
</script>
</head>
<body>
<button onclick="startCall();">
Start call
</button>
<a hidden id="call">
Go to call
</a>
</body>
</html>