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