Files
chatballs/packages/ui/src/call/AudioCallWaveform.tsx
T
Andrey e78eb9f17d ✨ feat(calls): add audio calls alongside video calls
Introduce a call kind (AUDIO default, VIDEO) so dialogs support both audio
and video calls. Video path is unchanged; audio is a separate full-screen
component built 1:1 on the «Аудиозвонок.dc.html» baseline.

Backend
- CallSession.kind (CallKind AUDIO|VIDEO, default AUDIO) + migration 0005
- create_call_request(kind), CallCreateView validates kind from request body
- serializers expose kind in call_payload, public_invite_payload,
  public_call_state_payload (drop stale hardcoded capabilities)
- timeline system message reflects kind («аудио-/видеозвонок»)
- tests updated for the kind field and new message wording

@edevs/ui
- useCallRtcSession gains videoEnabled (default true; video unchanged);
  audio requests audio-only getUserMedia, no camera fallback
- AudioCallView, AudioCallWaveform (live AnalyserNode oscilloscope),
  AudioCallIcons (mic/speaker/phone/status), audioCallStates, audio-call.css
- exported via index.ts

internal-ui
- two call buttons in the conversation header (audio + video)
- CallOverlay becomes a kind dispatcher: VideoCallOverlay (unchanged) /
  AudioCallOverlay; kind threaded through model/hook/workspace

web-chat
- CallInfo.kind; invite banner shows audio vs video title and icon
- CallApp dispatches to AudioCallStage for audio; audio resolve/build in model

Infra
- compose.yaml: widen Coturn relay UDP range to 49160-49999 (~420 relay
  calls) with explanatory comment
2026-07-31 01:35:29 +03:00

97 lines
3.6 KiB
TypeScript

// Живая осциллограмма аудиозвонка (baseline «Аудиозвонок.dc.html»).
// Белая линия rgba(255,255,255,.92), lineWidth 3, конусное затухание по краям.
// Амплитуда берётся из AnalyserNode удалённого потока — волна реагирует на
// голос собеседника. Нет потока/тишина → мягкая анимированная idle-волна.
import { useEffect, useRef } from "react";
type Props = {
stream?: MediaStream | null;
micOn?: boolean;
active: boolean;
};
const IDLE_AMP = 0.12;
const SPEAK_AMP = 0.5;
export function AudioCallWaveform({ stream, micOn = true, active }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const streamRef = useRef<MediaStream | null>(stream);
streamRef.current = stream;
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const W = canvas.width;
const H = canvas.height;
const mid = H / 2;
// AnalyserNode на удалённом аудио, чтобы измерять громкость собеседника.
let audioCtx: AudioContext | null = null;
let analyser: AnalyserNode | null = null;
let source: MediaStreamAudioSourceNode | null = null;
let levels: Uint8Array<ArrayBuffer> | null = null;
try {
const target = streamRef.current;
if (target && target.getAudioTracks().length > 0) {
audioCtx = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)();
analyser = audioCtx.createAnalyser();
analyser.fftSize = 1024;
source = audioCtx.createMediaStreamSource(target);
source.connect(analyser);
levels = new Uint8Array(new ArrayBuffer(analyser.frequencyBinCount));
}
} catch {
// AudioContext недоступен (нет аудио/нет прав) — работаем idle-волной.
}
let raf = 0;
const draw = (ts: number) => {
raf = requestAnimationFrame(draw);
ctx.clearRect(0, 0, W, H);
// Уровень громкости собеседника 0..1 (RMS по bins).
let level = 0;
if (active && micOn && analyser && levels) {
analyser.getByteTimeDomainData(levels);
let sum = 0;
for (let i = 0; i < levels.length; i++) {
const v = (levels[i] - 128) / 128;
sum += v * v;
}
level = Math.min(1, Math.sqrt(sum / levels.length) * 2.4);
}
const speaking = active && micOn;
const t = ts / 1000;
const base = speaking ? SPEAK_AMP : IDLE_AMP;
const ampScale = speaking ? (0.35 + 0.65 * level) : 1;
ctx.lineWidth = 3;
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.92)";
ctx.beginPath();
for (let x = 0; x <= W; x += 4) {
const p = x / W;
const env = Math.sin(p * Math.PI); // taper ends
const amp = env * H * 0.36 * base * ampScale * (0.6 + 0.4 * Math.sin(t * 2.1 + p * 3));
const y = mid + Math.sin(p * 22 + t * 6) * amp + Math.sin(p * 9 - t * 3.3) * amp * 0.5;
if (x === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
};
raf = requestAnimationFrame(draw);
return () => {
cancelAnimationFrame(raf);
source?.disconnect();
analyser?.disconnect();
void audioCtx?.close();
};
}, [active, micOn]);
return <canvas ref={canvasRef} width={520} height={112} className="hub-audio-wave" />;
}