intiial
This commit is contained in:
20
.deploy/deploy-dev.yaml
Normal file
20
.deploy/deploy-dev.yaml
Normal 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
|
||||||
41
.gitea/workflows/deploy-dev.yaml
Normal file
41
.gitea/workflows/deploy-dev.yaml
Normal 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
120
.gitignore
vendored
Normal 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
13
Dockerfile
Normal 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
69
main.py
Normal 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
18
requirements.txt
Normal 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
129
templates/call.html
Normal 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
25
templates/index.html
Normal 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>
|
||||||
Reference in New Issue
Block a user