Browse Source
- src/call/: сигналинг (call.c, call_proto.h, ETCP_RT_ID_CALL 0x36) + релей медиа + headless аудио-сокет (call_headless.c, call_audio_bind) + Opus/jitter/тоны - chat: события CHAT_EVT_CALL_* (34–40), CHAT_EVT_DM_MESSAGES (41), DM-трамплины - chatgui: call window, miniaudio I/O, audio_frame_ring, test_audio_jitter - chatgui-android: CallScreen/IncomingCallDialog, аудио-роутинг, JNI, рингтон - tests: test_call, test_call_headlessrouter-recv-conn-uaf
78 changed files with 7054 additions and 71 deletions
@ -0,0 +1,180 @@
|
||||
// audio_jitter.cpp — адаптивный джиттер-буфер с time-stretch (SoundTouch).
|
||||
//
|
||||
// PCM-кадры (int16, 960 сэмплов @20мс) кладутся из uasync-потока в кольцо и
|
||||
// вытаскиваются аудио-потоком через SoundTouch с плавно меняющимся темпом:
|
||||
// глубина < target → 1.0x, глубина растёт → ускорение до 1.6x (догон live).
|
||||
// Кольцо защищено мьютексом (SPSC); SoundTouch трогает только аудио-поток.
|
||||
|
||||
#include "audio_jitter.h" |
||||
|
||||
#include "soundtouch/SoundTouch.h" |
||||
|
||||
#include <atomic> |
||||
#include <cmath> |
||||
#include <cstring> |
||||
#include <mutex> |
||||
#include <vector> |
||||
|
||||
static const double kTargetMs = 60.0; // целевая глубина буфера
|
||||
static const double kMaxTempo = 1.6; |
||||
static const double kMinTempo = 1.0; |
||||
static const double kMaxDepthMs = 1000.0; // при этой глубине tempo == kMaxTempo
|
||||
static const double kEmaAlpha = 0.2; // сглаживание глубины
|
||||
static const double kTempoStep = 0.01; // макс. изменение темпа за один pull (без кликов)
|
||||
|
||||
struct ajb { |
||||
int sr = 0; |
||||
int ch = 0; |
||||
int frame_samples = 0; |
||||
int frame_ms = 0; |
||||
int cap = 0; |
||||
|
||||
std::mutex mtx; |
||||
std::vector<int16_t> ring; // cap * frame_samples
|
||||
int head = 0; |
||||
int count = 0; |
||||
uint32_t dropped = 0; |
||||
|
||||
soundtouch::SoundTouch st; // только аудио-поток
|
||||
std::vector<int16_t> frame_buf; // scratch: вытащенный кадр
|
||||
std::vector<float> fin; // scratch: кадр в float
|
||||
std::vector<float> fout; // scratch: выход float
|
||||
|
||||
double ema_depth_ms = 0.0; |
||||
double tempo = 1.0; |
||||
std::atomic<int> tempo_x100{100}; |
||||
}; |
||||
|
||||
static double ajb_compute_tempo(double depth_ms) { |
||||
if (depth_ms <= kTargetMs) return kMinTempo; |
||||
double x = (depth_ms - kTargetMs) / (kMaxDepthMs - kTargetMs); |
||||
if (x > 1.0) x = 1.0; |
||||
return kMinTempo + (kMaxTempo - kMinTempo) * x; |
||||
} |
||||
|
||||
/* Вытащить один кадр из кольца (под мьютексом). Возвращает 0 если пусто. */ |
||||
static int ajb_pop_frame(struct ajb* j, int16_t* out) { |
||||
std::lock_guard<std::mutex> lk(j->mtx); |
||||
if (j->count == 0) return 0; |
||||
std::memcpy(out, &j->ring[(size_t)j->head * j->frame_samples], |
||||
(size_t)j->frame_samples * sizeof(int16_t)); |
||||
j->head = (j->head + 1) % j->cap; |
||||
j->count--; |
||||
return 1; |
||||
} |
||||
|
||||
extern "C" { |
||||
|
||||
struct ajb* ajb_create(int sample_rate, int channels, int frame_samples, int frame_ms, int max_frames) { |
||||
if (sample_rate <= 0 || channels <= 0 || frame_samples <= 0 || frame_ms <= 0 || max_frames <= 0) |
||||
return nullptr; |
||||
|
||||
ajb* j = new ajb(); |
||||
j->sr = sample_rate; |
||||
j->ch = channels; |
||||
j->frame_samples = frame_samples; |
||||
j->frame_ms = frame_ms; |
||||
j->cap = max_frames; |
||||
j->ring.assign((size_t)max_frames * frame_samples, 0); |
||||
j->frame_buf.assign(frame_samples, 0); |
||||
j->fin.assign(frame_samples, 0.0f); |
||||
|
||||
j->st.setSampleRate((uint)sample_rate); |
||||
j->st.setChannels((uint)channels); |
||||
j->st.setTempo(kMinTempo); |
||||
j->st.setSetting(SETTING_USE_QUICKSEEK, 1); |
||||
j->st.setSetting(SETTING_SEQUENCE_MS, 20); |
||||
j->st.setSetting(SETTING_SEEKWINDOW_MS, 10); |
||||
j->st.setSetting(SETTING_OVERLAP_MS, 4); |
||||
j->st.setSetting(SETTING_USE_AA_FILTER, 0); |
||||
return j; |
||||
} |
||||
|
||||
void ajb_destroy(struct ajb* j) { |
||||
if (!j) return; |
||||
delete j; |
||||
} |
||||
|
||||
void ajb_reset(struct ajb* j) { |
||||
if (!j) return; |
||||
{ |
||||
std::lock_guard<std::mutex> lk(j->mtx); |
||||
j->head = 0; |
||||
j->count = 0; |
||||
} |
||||
j->st.clear(); |
||||
j->st.setTempo(kMinTempo); |
||||
j->tempo = kMinTempo; |
||||
j->ema_depth_ms = 0.0; |
||||
j->tempo_x100.store(100); |
||||
} |
||||
|
||||
void ajb_push(struct ajb* j, const int16_t* pcm, int samples) { |
||||
if (!j || !pcm || samples != j->frame_samples) return; |
||||
std::lock_guard<std::mutex> lk(j->mtx); |
||||
if (j->count == j->cap) { |
||||
j->head = (j->head + 1) % j->cap; // дроп старейшего
|
||||
j->count--; |
||||
j->dropped++; |
||||
} |
||||
int idx = (j->head + j->count) % j->cap; |
||||
std::memcpy(&j->ring[(size_t)idx * j->frame_samples], pcm, (size_t)samples * sizeof(int16_t)); |
||||
j->count++; |
||||
} |
||||
|
||||
int ajb_pull(struct ajb* j, int16_t* out, int max_samples) { |
||||
if (!j || !out || max_samples <= 0) return 0; |
||||
|
||||
/* 1) глубина (сглаженная) → целевой темп (плавно) */ |
||||
int cnt; |
||||
{ |
||||
std::lock_guard<std::mutex> lk(j->mtx); |
||||
cnt = j->count; |
||||
} |
||||
double depth_ms = (double)cnt * j->frame_ms; |
||||
j->ema_depth_ms = kEmaAlpha * depth_ms + (1.0 - kEmaAlpha) * j->ema_depth_ms; |
||||
double target = ajb_compute_tempo(j->ema_depth_ms); |
||||
double d = target - j->tempo; |
||||
if (d > kTempoStep) d = kTempoStep; |
||||
else if (d < -kTempoStep) d = -kTempoStep; |
||||
j->tempo += d; |
||||
j->st.setTempo(j->tempo); |
||||
j->tempo_x100.store((int)std::lround(j->tempo * 100.0)); |
||||
|
||||
/* 2) кормим SoundTouch, пока он не сможет выдать max_samples (или кольцо пусто) */ |
||||
int guard = 0; |
||||
while (j->st.numSamples() < (uint)max_samples && guard++ < j->cap + 4) { |
||||
if (!ajb_pop_frame(j, j->frame_buf.data())) break; |
||||
for (int i = 0; i < j->frame_samples; i++) |
||||
j->fin[(size_t)i] = (float)j->frame_buf[(size_t)i] / 32768.0f; |
||||
j->st.putSamples(j->fin.data(), (uint)j->frame_samples); |
||||
} |
||||
|
||||
/* 3) забираем готовое */ |
||||
j->fout.resize((size_t)max_samples); |
||||
uint n = j->st.receiveSamples(j->fout.data(), (uint)max_samples); |
||||
for (uint i = 0; i < n; i++) { |
||||
float v = j->fout[i] * 32767.0f; |
||||
if (v > 32767.0f) v = 32767.0f; |
||||
else if (v < -32768.0f) v = -32768.0f; |
||||
out[i] = (int16_t)std::lrintf(v); |
||||
} |
||||
return (int)n; |
||||
} |
||||
|
||||
void ajb_get_stats(struct ajb* j, int* buffer_ms, int* tempo_x100) { |
||||
if (!j) return; |
||||
if (buffer_ms) { |
||||
std::lock_guard<std::mutex> lk(j->mtx); |
||||
*buffer_ms = j->count * j->frame_ms; |
||||
} |
||||
if (tempo_x100) *tempo_x100 = j->tempo_x100.load(); |
||||
} |
||||
|
||||
uint32_t ajb_dropped(struct ajb* j) { |
||||
if (!j) return 0; |
||||
std::lock_guard<std::mutex> lk(j->mtx); |
||||
return j->dropped; |
||||
} |
||||
|
||||
} // extern "C"
|
||||
@ -0,0 +1,51 @@
|
||||
#ifndef AUDIO_JITTER_H |
||||
#define AUDIO_JITTER_H |
||||
|
||||
// audio_jitter.h — адаптивный джиттер-буфер с time-stretch (SoundTouch), C API.
|
||||
//
|
||||
// Хранит декодированные PCM-кадры (по frame_samples сэмплов). На pull применяет
|
||||
// tempo: при росте глубины буфера (сетевая пауза → burst) воспроизведение плавно
|
||||
// ускоряется (до 1.6x) и догоняет live, не теряя данные (до max_frames кадров).
|
||||
//
|
||||
// Потоки:
|
||||
// - ajb_push — uasync-поток (producer);
|
||||
// - ajb_pull — аудио-поток (consumer, единственный владелец SoundTouch);
|
||||
// - ajb_get_stats — любой поток.
|
||||
// Кольцо кадров защищено мьютексом (SPSC, дёшево: ~50 кадров/с).
|
||||
|
||||
#include <stdint.h> |
||||
|
||||
#ifdef __cplusplus |
||||
extern "C" { |
||||
#endif |
||||
|
||||
struct ajb; |
||||
|
||||
/* Создать буфер. frame_samples — сэмплов в кадре (960 @20мс/48k), frame_ms —
|
||||
* длительность кадра в мс, max_frames — ёмкость (50 = 1000мс). */ |
||||
struct ajb* ajb_create(int sample_rate, int channels, int frame_samples, int frame_ms, int max_frames); |
||||
|
||||
void ajb_destroy(struct ajb* j); |
||||
|
||||
/* Сброс: очистить кольцо и внутреннее состояние SoundTouch (темп → 1.0). */ |
||||
void ajb_reset(struct ajb* j); |
||||
|
||||
/* producer: положить декодированный PCM-кадр (ровно frame_samples сэмплов).
|
||||
* При переполнении дропает старейший кадр (счётчик внутри). */ |
||||
void ajb_push(struct ajb* j, const int16_t* pcm, int samples); |
||||
|
||||
/* consumer: выдать до max_samples PCM с учётом текущего темпа. Возвращает число
|
||||
* выданных сэмплов; может быть < max_samples при нехватке данных (андерфлоу). */ |
||||
int ajb_pull(struct ajb* j, int16_t* out, int max_samples); |
||||
|
||||
/* Диагностика: текущая глубина буфера (мс) и темп (×100, 100 = 1.0x). */ |
||||
void ajb_get_stats(struct ajb* j, int* buffer_ms, int* tempo_x100); |
||||
|
||||
/* Число дропнутых кадров при переполнении (для диагностики/тестов). */ |
||||
uint32_t ajb_dropped(struct ajb* j); |
||||
|
||||
#ifdef __cplusplus |
||||
} |
||||
#endif |
||||
|
||||
#endif /* AUDIO_JITTER_H */ |
||||
@ -0,0 +1,877 @@
|
||||
// call.c — P2P аудио-звонок: сигналинг + медиа (per-instance)
|
||||
//
|
||||
// Полный цикл: INVITE → RINGING → ACCEPT → conn_mgr (прямой линк) → ACTIVE →
|
||||
// медиа (CALL_MEDIA) → HANGUP/ENDED. Звонок из CHAT-группы или DM — модуль
|
||||
// оперирует (group_id, peer_node_id), маршрутизация через etcp_router.
|
||||
//
|
||||
// Сигналинг идёт с ROUTE_CRYPTO_SIGN|ROUTE_CRYPTO_ENCRYPT, медиа — без подписи.
|
||||
// Реестр сессий: ll_queue с hash-индексом по call_id (8 байт).
|
||||
//
|
||||
// Thread-модель: всё в uasync-потоке (single-threaded). Освобождение сессии
|
||||
// отложено через uasync_call_soon, чтобы не было UAF при завершении из
|
||||
// коллбэков conn_mgr/таймеров.
|
||||
|
||||
#include "call.h" |
||||
#include "call_proto.h" |
||||
#include "../utun_instance.h" |
||||
#include "../chat/chat_event.h" |
||||
#include "../routing_layer/etcp_router.h" |
||||
#include "../routing_layer/route_crypto.h" |
||||
#include "../routing_layer/conn_mgr.h" |
||||
#include "../routing_layer/topo_group.h" |
||||
#include "../routing_layer/topo_node.h" |
||||
#include "../transport_layer/etcp_api.h" |
||||
#include "../transport_layer/etcp.h" |
||||
#include "../../lib/mem.h" |
||||
#include "../../lib/ll_queue.h" |
||||
#include "../../lib/debug_config.h" |
||||
#include "../../lib/u_async.h" |
||||
#include "../../lib/platform_compat.h" |
||||
|
||||
#include <string.h> |
||||
#include <stdlib.h> |
||||
#include <stdio.h> |
||||
|
||||
#define CALL_ID "call" |
||||
|
||||
enum call_state { |
||||
CALL_ST_IDLE = 0, |
||||
CALL_ST_OUTGOING, /* caller: INVITE отправлен */ |
||||
CALL_ST_RINGING, /* caller: получил RINGING; callee: отправил RINGING */ |
||||
CALL_ST_CONNECTING, /* обе стороны: conn_mgr_open в процессе */ |
||||
CALL_ST_ACTIVE, /* обе стороны: соединение поднято */ |
||||
CALL_ST_ENDED, |
||||
}; |
||||
|
||||
enum call_role { CALL_ROLE_CALLER = 0, CALL_ROLE_CALLEE }; |
||||
|
||||
struct call_session { |
||||
struct ll_entry ll; /* dummy-заголовок; структура хранится в entry->data */ |
||||
uint64_t call_id; /* hash-индекс (8 байт) */ |
||||
uint64_t group_id; |
||||
uint64_t peer_node_id; |
||||
uint8_t role; /* CALL_ROLE_* */ |
||||
uint8_t state; /* CALL_ST_* */ |
||||
uint8_t reason; /* CALL_REASON_* (при ENDED) */ |
||||
struct call_ctx* ctx; /* обратная ссылка (для коллбэков conn_mgr/таймеров) */ |
||||
struct CONN_MGR_HANDLE* cm_handle; |
||||
void* ring_timer; /* caller: 45с ожидания ответа */ |
||||
void* traffic_timer; /* ACTIVE: 20с без медиа (запускается по первому медиа) */ |
||||
void* teardown_timer; /* отложенный разрыв conn_mgr (500мс после завершения) */ |
||||
void* stats_timer; /* ACTIVE: раз в 1с — CALL_STATS (rtt/buffer/tempo) */ |
||||
uint64_t last_media_tb; /* время последнего CALL_MEDIA (0.1ms) */ |
||||
uint16_t tx_media_seq; /* seq следующего исходящего CALL_MEDIA */ |
||||
struct ll_queue* tx_q; /* FIFO закодированных CALL_MEDIA (backpressure) */ |
||||
struct queue_waiter_handle send_waiter; /* waiter на send_q роутера */ |
||||
uint32_t c_tx_frames; /* отправлено медиа-кадров */ |
||||
uint32_t c_rx_frames; /* принято медиа-кадров */ |
||||
uint32_t c_tx_dropped; /* дропнуто при переполнении tx_q */ |
||||
uint32_t c_rx_dropped; /* дропнуто при переполнении jitter-буфера */ |
||||
}; |
||||
|
||||
struct call_ctx { |
||||
struct UTUN_INSTANCE* inst; |
||||
int initialized; |
||||
struct ll_queue* sessions; /* struct call_session, hash по call_id */ |
||||
uint32_t c_calls_started; /* суммарно начато звонков */ |
||||
uint32_t c_calls_ended; /* суммарно завершено звонков */ |
||||
call_media_recv_fn media_recv_fn; /* приём Opus-кадров пира */ |
||||
void* media_recv_arg; |
||||
const struct call_audio_ops* audio_ops; /* аудио-движок (GUI-сборки), NULL в демоне */ |
||||
}; |
||||
|
||||
/* ───────────────────────────────────────────────────────────────────────── */ |
||||
|
||||
static struct call_ctx* call_of(struct UTUN_INSTANCE* inst) { |
||||
return inst ? inst->call : NULL; |
||||
} |
||||
|
||||
static const char* call_state_name(uint8_t st) { |
||||
switch (st) { |
||||
case CALL_ST_IDLE: return "IDLE"; |
||||
case CALL_ST_OUTGOING: return "OUTGOING"; |
||||
case CALL_ST_RINGING: return "RINGING"; |
||||
case CALL_ST_CONNECTING: return "CONNECTING"; |
||||
case CALL_ST_ACTIVE: return "ACTIVE"; |
||||
case CALL_ST_ENDED: return "ENDED"; |
||||
default: return "?"; |
||||
} |
||||
} |
||||
|
||||
static const char* call_subcmd_name(uint8_t sc) { |
||||
switch (sc) { |
||||
case CALL_SUBCMD_INVITE: return "INVITE"; |
||||
case CALL_SUBCMD_RINGING: return "RINGING"; |
||||
case CALL_SUBCMD_ACCEPT: return "ACCEPT"; |
||||
case CALL_SUBCMD_DECLINE: return "DECLINE"; |
||||
case CALL_SUBCMD_BUSY: return "BUSY"; |
||||
case CALL_SUBCMD_CANCEL: return "CANCEL"; |
||||
case CALL_SUBCMD_HANGUP: return "HANGUP"; |
||||
case CALL_SUBCMD_MEDIA: return "MEDIA"; |
||||
default: return "?"; |
||||
} |
||||
} |
||||
|
||||
/* ─────────────────── отправка ─────────────────── */ |
||||
|
||||
static void call_stats_timer_cb(void* arg); |
||||
|
||||
/* data[0] = subcmd. etcp_route_send забирает владение entry. */ |
||||
static int call_send(struct call_ctx* ctx, uint64_t group_id, uint64_t dst, |
||||
const uint8_t* data, size_t len, int mode) { |
||||
struct ll_entry* e = queue_entry_new(0); |
||||
if (!e) return -1; |
||||
e->dgram = u_malloc(1 + len); |
||||
if (!e->dgram) { queue_entry_free(e); return -1; } |
||||
e->dgram[0] = ETCP_RT_ID_CALL; |
||||
if (len) memcpy(e->dgram + 1, data, len); |
||||
e->len = (uint16_t)(1 + len); |
||||
int rc = etcp_route_send(ctx->inst, group_id, dst, e, 1, mode); |
||||
if (rc != 0) { |
||||
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: send FAIL rc=%d subcmd=%s(0x%02x) dst=0x%016llx group=0x%016llx", |
||||
CALL_ID, rc, call_subcmd_name(len ? data[0] : 0), len ? data[0] : 0, |
||||
(unsigned long long)dst, (unsigned long long)group_id); |
||||
} |
||||
return rc; |
||||
} |
||||
|
||||
/* ─────────────────── реестр сессий ─────────────────── */ |
||||
|
||||
static struct call_session* call_session_find(struct call_ctx* ctx, uint64_t call_id) { |
||||
if (!ctx || !ctx->sessions) return NULL; |
||||
struct ll_entry* e = queue_find_data_by_index(ctx->sessions, &call_id); |
||||
return e ? (struct call_session*)e->data : NULL; |
||||
} |
||||
|
||||
static struct call_session* call_session_new(struct call_ctx* ctx, uint64_t call_id, |
||||
uint64_t group_id, uint64_t peer, uint8_t role) { |
||||
if (!ctx || !ctx->sessions) return NULL; |
||||
struct ll_entry* qe = queue_entry_new(sizeof(struct call_session)); |
||||
if (!qe) return NULL; |
||||
struct call_session* s = (struct call_session*)qe->data; |
||||
memset(s, 0, sizeof(*s)); |
||||
s->call_id = call_id; |
||||
s->group_id = group_id; |
||||
s->peer_node_id = peer; |
||||
s->role = role; |
||||
s->state = CALL_ST_IDLE; |
||||
s->ctx = ctx; |
||||
s->tx_q = queue_new(ctx->inst->ua, 0, 0, 0, "call_txq"); |
||||
if (!s->tx_q) { queue_entry_free(qe); return NULL; } |
||||
if (queue_data_put_with_index(ctx->sessions, qe) != 0) { |
||||
queue_free(s->tx_q); |
||||
queue_entry_free(qe); |
||||
return NULL; |
||||
} |
||||
return s; |
||||
} |
||||
|
||||
static void call_session_free_cb(void* arg) { |
||||
struct ll_entry* e = (struct ll_entry*)arg; |
||||
queue_entry_free(e); |
||||
} |
||||
|
||||
/* Освободить медиа-объекты сессии: отменить waiter на send_q роутера и
|
||||
* дренировать tx_q (без отправки). Безопасно вызывать из uasync-потока. */ |
||||
static void call_session_cleanup(struct call_ctx* ctx, struct call_session* s) { |
||||
etcp_router_cancel_send_ready(ctx->inst, s->group_id, s->peer_node_id, ETCP_RT_ID_CALL, &s->send_waiter); |
||||
if (s->tx_q) { |
||||
struct ll_entry* e; |
||||
while ((e = queue_data_get(s->tx_q)) != NULL) { |
||||
queue_dgram_free(e); |
||||
queue_entry_free(e); |
||||
} |
||||
queue_free(s->tx_q); |
||||
s->tx_q = NULL; |
||||
} |
||||
} |
||||
|
||||
/* Убрать сессию из реестра и освободить отложенно (не из коллбэков conn_mgr/таймеров). */ |
||||
static void call_session_remove_and_free(struct call_ctx* ctx, struct call_session* s) { |
||||
call_session_cleanup(ctx, s); |
||||
struct ll_entry* e = queue_find_data_by_index(ctx->sessions, &s->call_id); |
||||
if (e) { |
||||
queue_remove_data(ctx->sessions, e); |
||||
uasync_call_soon(ctx->inst->ua, e, call_session_free_cb); |
||||
} |
||||
} |
||||
|
||||
/* ─────────────────── события в GUI ─────────────────── */ |
||||
|
||||
static void call_event_id(struct call_ctx* ctx, int type, uint64_t call_id, uint8_t reason) { |
||||
uint8_t buf[16]; |
||||
size_t off = 0; |
||||
memcpy(buf + off, &call_id, 8); off += 8; |
||||
buf[off++] = reason; |
||||
chat_event_post(ctx->inst, type, buf, (int)off); |
||||
} |
||||
|
||||
static void call_event_incoming(struct call_ctx* ctx, uint64_t group_id, uint64_t caller, uint64_t call_id) { |
||||
char ch[32]; |
||||
snprintf(ch, sizeof(ch), "%llu", (unsigned long long)group_id); |
||||
size_t ch_len = strlen(ch); |
||||
uint8_t buf[1 + 32 + 8 + 8]; |
||||
size_t off = 0; |
||||
buf[off++] = (uint8_t)ch_len; |
||||
memcpy(buf + off, ch, ch_len); off += ch_len; |
||||
memcpy(buf + off, &caller, 8); off += 8; |
||||
memcpy(buf + off, &call_id, 8); off += 8; |
||||
chat_event_post(ctx->inst, CHAT_EVT_CALL_INCOMING, buf, (int)off); |
||||
} |
||||
|
||||
/* ─────────────────── завершение ─────────────────── */ |
||||
|
||||
/* Отложенный разрыв: единственная точка, где закрывается conn_mgr (после того,
|
||||
* как финальный сигналинг — HANGUP/DECLINE — гарантированно долетел). */ |
||||
static void call_teardown_timer_cb(void* arg) { |
||||
struct call_session* s = (struct call_session*)arg; |
||||
if (!s || !s->ctx) return; |
||||
struct call_ctx* ctx = s->ctx; |
||||
s->teardown_timer = NULL; |
||||
if (s->cm_handle) { conn_mgr_close(s->cm_handle); s->cm_handle = NULL; } |
||||
call_session_remove_and_free(ctx, s); |
||||
} |
||||
|
||||
static void call_end(struct call_ctx* ctx, struct call_session* s, uint8_t reason) { |
||||
if (!ctx || !s || s->state == CALL_ST_ENDED) return; |
||||
|
||||
s->state = CALL_ST_ENDED; |
||||
s->reason = reason; |
||||
ctx->c_calls_ended++; |
||||
|
||||
if (s->ring_timer) { uasync_cancel_timeout(ctx->inst->ua, s->ring_timer); s->ring_timer = NULL; } |
||||
if (s->traffic_timer) { uasync_cancel_timeout(ctx->inst->ua, s->traffic_timer); s->traffic_timer = NULL; } |
||||
if (s->stats_timer) { uasync_cancel_timeout(ctx->inst->ua, s->stats_timer); s->stats_timer = NULL; } |
||||
|
||||
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: call ENDED id=%016llx reason=%d role=%s rx=%u tx=%u tx_dropped=%u", |
||||
CALL_ID, (unsigned long long)s->call_id, reason, |
||||
s->role == CALL_ROLE_CALLER ? "caller" : "callee", |
||||
s->c_rx_frames, s->c_tx_frames, s->c_tx_dropped); |
||||
|
||||
call_event_id(ctx, CHAT_EVT_CALL_ENDED, s->call_id, reason); |
||||
|
||||
/* Разрыв conn_mgr — отложенно (500мс), чтобы HANGUP/DECLINE успел долететь.
|
||||
* Без cm_handle рвать нечего — освобождаем сразу (отложенно). */ |
||||
if (s->cm_handle) { |
||||
s->teardown_timer = uasync_set_timeout(ctx->inst->ua, CALL_TEARDOWN_DELAY_TB, s, call_teardown_timer_cb, "call_teardown"); |
||||
} else { |
||||
call_session_remove_and_free(ctx, s); |
||||
} |
||||
} |
||||
|
||||
/* ─────────────────── conn_mgr ─────────────────── */ |
||||
|
||||
static void call_conn_cb(struct CONN_MGR_HANDLE* h, uint64_t node_id, uint64_t group_id, |
||||
enum conn_mgr_event event, void* arg) { |
||||
struct call_session* s = (struct call_session*)arg; |
||||
if (!s || !s->ctx) return; |
||||
struct call_ctx* ctx = s->ctx; |
||||
if (s->state == CALL_ST_ENDED) { |
||||
/* сессия завершена, ждёт teardown: conn_mgr сообщает об обрыве — handle больше
|
||||
* не трогаем (entry чистится самим conn_mgr / удалённой стороной) */ |
||||
if (event == CONN_EVENT_DOWN || event == CONN_EVENT_TIMEOUT) s->cm_handle = NULL; |
||||
return; |
||||
} |
||||
|
||||
if (event == CONN_EVENT_UP) { |
||||
s->cm_handle = h; |
||||
s->state = CALL_ST_ACTIVE; |
||||
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: conn UP id=%016llx peer=0x%016llx -> ACTIVE", |
||||
CALL_ID, (unsigned long long)s->call_id, (unsigned long long)node_id); |
||||
call_event_id(ctx, CHAT_EVT_CALL_ACCEPTED, s->call_id, 0); |
||||
if (!s->stats_timer) |
||||
s->stats_timer = uasync_set_timeout(ctx->inst->ua, 10000, s, call_stats_timer_cb, "call_stats"); |
||||
} else if (event == CONN_EVENT_DOWN) { |
||||
DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: conn DOWN id=%016llx peer=0x%016llx (temporary, keep alive)", |
||||
CALL_ID, (unsigned long long)s->call_id, (unsigned long long)node_id); |
||||
} else { /* CONN_EVENT_TIMEOUT */ |
||||
DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: conn TIMEOUT id=%016llx peer=0x%016llx -> fail", |
||||
CALL_ID, (unsigned long long)s->call_id, (unsigned long long)node_id); |
||||
/* entry уже чистится вызывающим (monitor) — handle не закрываем */ |
||||
s->cm_handle = NULL; |
||||
call_end(ctx, s, CALL_REASON_CONNECT_FAIL); |
||||
} |
||||
} |
||||
|
||||
/* ─────────────────── таймеры ─────────────────── */ |
||||
|
||||
static void call_ring_timeout_cb(void* arg) { |
||||
struct call_session* s = (struct call_session*)arg; |
||||
if (!s || !s->ctx) return; |
||||
struct call_ctx* ctx = s->ctx; |
||||
s->ring_timer = NULL; |
||||
if (s->state == CALL_ST_ENDED) return; |
||||
|
||||
DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: ring timeout id=%016llx -> CANCEL", |
||||
CALL_ID, (unsigned long long)s->call_id); |
||||
uint8_t pkt[sizeof(struct call_pkt_cancel)]; |
||||
struct call_pkt_cancel* c = (struct call_pkt_cancel*)pkt; |
||||
c->subcmd = CALL_SUBCMD_CANCEL; |
||||
c->call_id = s->call_id; |
||||
call_send(ctx, s->group_id, s->peer_node_id, pkt, sizeof(pkt), ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT); |
||||
call_end(ctx, s, CALL_REASON_RING_TIMEOUT); |
||||
} |
||||
|
||||
static void call_traffic_timeout_cb(void* arg) { |
||||
struct call_session* s = (struct call_session*)arg; |
||||
if (!s || !s->ctx) return; |
||||
struct call_ctx* ctx = s->ctx; |
||||
s->traffic_timer = NULL; |
||||
if (s->state == CALL_ST_ENDED) return; |
||||
|
||||
DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: no traffic 20s id=%016llx -> HANGUP", |
||||
CALL_ID, (unsigned long long)s->call_id); |
||||
uint8_t pkt[sizeof(struct call_pkt_hangup)]; |
||||
struct call_pkt_hangup* hu = (struct call_pkt_hangup*)pkt; |
||||
hu->subcmd = CALL_SUBCMD_HANGUP; |
||||
hu->call_id = s->call_id; |
||||
hu->reason = CALL_REASON_NO_TRAFFIC; |
||||
call_send(ctx, s->group_id, s->peer_node_id, pkt, sizeof(pkt), ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT); |
||||
call_end(ctx, s, CALL_REASON_NO_TRAFFIC); |
||||
} |
||||
|
||||
/* Периодическое событие CALL_STATS (раз в 1с): rtt_ms, buffer_ms, tempo_x100. */ |
||||
static void call_stats_timer_cb(void* arg) { |
||||
struct call_session* s = (struct call_session*)arg; |
||||
if (!s || !s->ctx) return; |
||||
struct call_ctx* ctx = s->ctx; |
||||
s->stats_timer = NULL; |
||||
if (s->state != CALL_ST_ACTIVE) return; |
||||
|
||||
int buffer_ms = 0, tempo_x100 = 100; |
||||
if (ctx->audio_ops && ctx->audio_ops->get_stats) |
||||
ctx->audio_ops->get_stats(s->call_id, &buffer_ms, &tempo_x100); |
||||
|
||||
uint16_t rtt = 0; |
||||
struct ETCP_ROUTER_CONN* rconn = |
||||
etcp_router_conn_get(ctx->inst, s->group_id, s->peer_node_id, ETCP_RT_ID_CALL); |
||||
if (rconn && rconn->recv_conn) rtt = rconn->recv_conn->rtt_last; |
||||
|
||||
uint8_t buf[14]; |
||||
size_t off = 0; |
||||
memcpy(buf + off, &s->call_id, 8); off += 8; |
||||
buf[off++] = (uint8_t)(rtt >> 8); buf[off++] = (uint8_t)(rtt & 0xFF); |
||||
uint16_t bms = (uint16_t)(buffer_ms < 0 ? 0 : (buffer_ms > 0xFFFF ? 0xFFFF : buffer_ms)); |
||||
buf[off++] = (uint8_t)(bms >> 8); buf[off++] = (uint8_t)(bms & 0xFF); |
||||
uint16_t t = (uint16_t)(tempo_x100 < 0 ? 0 : (tempo_x100 > 0xFFFF ? 0xFFFF : tempo_x100)); |
||||
buf[off++] = (uint8_t)(t >> 8); buf[off++] = (uint8_t)(t & 0xFF); |
||||
chat_event_post(ctx->inst, CHAT_EVT_CALL_STATS, buf, (int)off); |
||||
|
||||
s->stats_timer = uasync_set_timeout(ctx->inst->ua, 10000, s, call_stats_timer_cb, "call_stats"); |
||||
} |
||||
|
||||
/* ─────────────────── потеря соединения ─────────────────── */ |
||||
|
||||
/* На обрыв ETCP-соединения с пиром завершаем ACTIVE-звонки (robust fallback:
|
||||
* HANGUP может не успеть долететь, если закрывающая сторона рвёт conn_mgr). */ |
||||
static void call_on_conn_status(struct ETCP_CONN* conn, int status, void* arg) { |
||||
struct UTUN_INSTANCE* inst = (struct UTUN_INSTANCE*)arg; |
||||
struct call_ctx* ctx = call_of(inst); |
||||
if (!ctx || !ctx->initialized || !conn) return; |
||||
if (status != ETCP_CONN_STATUS_DOWN && status != ETCP_CONN_STATUS_DELETE) return; |
||||
uint64_t peer = conn->peer_node_id; |
||||
if (!peer) return; |
||||
|
||||
struct ll_entry* e = ctx->sessions ? ctx->sessions->head : NULL; |
||||
while (e) { |
||||
struct ll_entry* next = e->next; |
||||
struct call_session* s = (struct call_session*)e->data; |
||||
if (s->peer_node_id == peer) { |
||||
if (s->state == CALL_ST_ACTIVE) { |
||||
DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: peer conn lost id=%016llx peer=0x%016llx -> end", |
||||
CALL_ID, (unsigned long long)s->call_id, (unsigned long long)peer); |
||||
call_end(ctx, s, CALL_REASON_REMOTE_HANGUP); |
||||
} else if (s->state == CALL_ST_ENDED) { |
||||
/* удалённая сторона уже порвала (DISCONNECT чистит entry без коллбэка) */ |
||||
s->cm_handle = NULL; |
||||
} |
||||
} |
||||
e = next; |
||||
} |
||||
} |
||||
|
||||
/* ─────────────────── обработчики приёма ─────────────────── */ |
||||
|
||||
static void call_handle_invite(struct call_ctx* ctx, uint64_t from, uint64_t group, |
||||
const uint8_t* b, size_t n) { |
||||
if (n < sizeof(struct call_pkt_invite) - sizeof(struct call_pkt_track)) { |
||||
DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: INVITE too short n=%zu", CALL_ID, n); |
||||
return; |
||||
} |
||||
uint64_t call_id, inv_group; |
||||
memcpy(&call_id, b + 1, 8); |
||||
memcpy(&inv_group, b + 9, 8); |
||||
|
||||
/* повторная доставка INVITE (тот же call_id) — идемпотентно RINGING */ |
||||
struct call_session* s = call_session_find(ctx, call_id); |
||||
if (s && s->state != CALL_ST_ENDED) { |
||||
DEBUG_DEBUG(DEBUG_CATEGORY_CALL, "%s: INVITE retransmit id=%016llx -> re-RINGING", CALL_ID, (unsigned long long)call_id); |
||||
uint8_t pkt[sizeof(struct call_pkt_ringing)]; |
||||
struct call_pkt_ringing* r = (struct call_pkt_ringing*)pkt; |
||||
r->subcmd = CALL_SUBCMD_RINGING; |
||||
r->call_id = call_id; |
||||
call_send(ctx, s->group_id, from, pkt, sizeof(pkt), ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT); |
||||
return; |
||||
} |
||||
|
||||
/* занятость: есть другая активная сессия */ |
||||
struct ll_entry* e = ctx->sessions ? ctx->sessions->head : NULL; |
||||
while (e) { |
||||
struct call_session* other = (struct call_session*)e->data; |
||||
if (other->state != CALL_ST_ENDED) { |
||||
DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: busy — active call id=%016llx, reply BUSY to id=%016llx", |
||||
CALL_ID, (unsigned long long)other->call_id, (unsigned long long)call_id); |
||||
uint8_t pkt[sizeof(struct call_pkt_busy)]; |
||||
struct call_pkt_busy* bz = (struct call_pkt_busy*)pkt; |
||||
bz->subcmd = CALL_SUBCMD_BUSY; |
||||
bz->call_id = call_id; |
||||
call_send(ctx, group, from, pkt, sizeof(pkt), ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT); |
||||
return; |
||||
} |
||||
e = e->next; |
||||
} |
||||
|
||||
s = call_session_new(ctx, call_id, group, from, CALL_ROLE_CALLEE); |
||||
if (!s) { |
||||
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: session alloc failed id=%016llx", CALL_ID, (unsigned long long)call_id); |
||||
return; |
||||
} |
||||
s->state = CALL_ST_RINGING; |
||||
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: INVITE id=%016llx from=0x%016llx group=0x%016llx (declared group=0x%016llx)", |
||||
CALL_ID, (unsigned long long)call_id, (unsigned long long)from, |
||||
(unsigned long long)group, (unsigned long long)inv_group); |
||||
|
||||
call_event_incoming(ctx, group, from, call_id); |
||||
|
||||
uint8_t pkt[sizeof(struct call_pkt_ringing)]; |
||||
struct call_pkt_ringing* r = (struct call_pkt_ringing*)pkt; |
||||
r->subcmd = CALL_SUBCMD_RINGING; |
||||
r->call_id = call_id; |
||||
call_send(ctx, group, from, pkt, sizeof(pkt), ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT); |
||||
} |
||||
|
||||
static void call_handle_ringing(struct call_ctx* ctx, uint64_t from, const uint8_t* b, size_t n) { |
||||
if (n < 9) return; |
||||
uint64_t call_id; memcpy(&call_id, b + 1, 8); |
||||
struct call_session* s = call_session_find(ctx, call_id); |
||||
if (!s || s->role != CALL_ROLE_CALLER || s->state == CALL_ST_ENDED) return; |
||||
if (s->state == CALL_ST_OUTGOING) { |
||||
s->state = CALL_ST_RINGING; |
||||
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: RINGING id=%016llx", CALL_ID, (unsigned long long)call_id); |
||||
call_event_id(ctx, CHAT_EVT_CALL_RINGING, call_id, 0); |
||||
} |
||||
(void)from; |
||||
} |
||||
|
||||
static void call_handle_accept(struct call_ctx* ctx, uint64_t from, const uint8_t* b, size_t n) { |
||||
if (n < 9) return; |
||||
uint64_t call_id; memcpy(&call_id, b + 1, 8); |
||||
struct call_session* s = call_session_find(ctx, call_id); |
||||
if (!s || s->role != CALL_ROLE_CALLER || s->state == CALL_ST_ENDED) return; |
||||
|
||||
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: ACCEPT id=%016llx -> conn_mgr_open", CALL_ID, (unsigned long long)call_id); |
||||
s->state = CALL_ST_CONNECTING; |
||||
if (s->ring_timer) { uasync_cancel_timeout(ctx->inst->ua, s->ring_timer); s->ring_timer = NULL; } |
||||
|
||||
int rc = conn_mgr_open(ctx->inst, s->group_id, s->peer_node_id, call_conn_cb, s, &s->cm_handle); |
||||
if (rc != 0) { |
||||
/* либо уже получили TIMEOUT (сессия ENDED), либо ошибка без коллбэка */ |
||||
if (s->state != CALL_ST_ENDED) call_end(ctx, s, CALL_REASON_CONNECT_FAIL); |
||||
} |
||||
} |
||||
|
||||
static void call_handle_decline(struct call_ctx* ctx, uint64_t from, const uint8_t* b, size_t n) { |
||||
if (n < 10) return; |
||||
uint64_t call_id; memcpy(&call_id, b + 1, 8); |
||||
uint8_t reason = b[9]; |
||||
struct call_session* s = call_session_find(ctx, call_id); |
||||
if (!s || s->state == CALL_ST_ENDED) return; |
||||
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: DECLINE id=%016llx reason=%d", CALL_ID, (unsigned long long)call_id, reason); |
||||
call_event_id(ctx, CHAT_EVT_CALL_DECLINED, call_id, reason); |
||||
call_end(ctx, s, CALL_REASON_DECLINE); |
||||
(void)from; |
||||
} |
||||
|
||||
static void call_handle_busy(struct call_ctx* ctx, uint64_t from, const uint8_t* b, size_t n) { |
||||
if (n < 9) return; |
||||
uint64_t call_id; memcpy(&call_id, b + 1, 8); |
||||
struct call_session* s = call_session_find(ctx, call_id); |
||||
if (!s || s->state == CALL_ST_ENDED) return; |
||||
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: BUSY id=%016llx", CALL_ID, (unsigned long long)call_id); |
||||
call_end(ctx, s, CALL_REASON_BUSY); |
||||
(void)from; |
||||
} |
||||
|
||||
static void call_handle_cancel(struct call_ctx* ctx, uint64_t from, const uint8_t* b, size_t n) { |
||||
if (n < 9) return; |
||||
uint64_t call_id; memcpy(&call_id, b + 1, 8); |
||||
struct call_session* s = call_session_find(ctx, call_id); |
||||
if (!s || s->state == CALL_ST_ENDED) return; |
||||
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: CANCEL id=%016llx", CALL_ID, (unsigned long long)call_id); |
||||
call_end(ctx, s, CALL_REASON_CANCEL); |
||||
(void)from; |
||||
} |
||||
|
||||
static void call_handle_hangup(struct call_ctx* ctx, uint64_t from, const uint8_t* b, size_t n) { |
||||
if (n < 10) return; |
||||
uint64_t call_id; memcpy(&call_id, b + 1, 8); |
||||
uint8_t reason = b[9]; |
||||
struct call_session* s = call_session_find(ctx, call_id); |
||||
if (!s || s->state == CALL_ST_ENDED) return; |
||||
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: HANGUP id=%016llx reason=%d", CALL_ID, (unsigned long long)call_id, reason); |
||||
call_end(ctx, s, CALL_REASON_REMOTE_HANGUP); |
||||
(void)from; |
||||
} |
||||
|
||||
static void call_handle_media(struct call_ctx* ctx, uint64_t from, const uint8_t* b, size_t n) { |
||||
if (n < CALL_MEDIA_HDR_SIZE) return; |
||||
uint64_t call_id; memcpy(&call_id, b + 1, 8); |
||||
struct call_session* s = call_session_find(ctx, call_id); |
||||
if (!s || s->state != CALL_ST_ACTIVE) { |
||||
/* медиа вне ACTIVE — игнорируем (гонка с завершением) */ |
||||
return; |
||||
} |
||||
s->c_rx_frames++; |
||||
s->last_media_tb = get_time_tb(); |
||||
|
||||
uint16_t seq = 0; |
||||
uint32_t ts_ms = 0; |
||||
memcpy(&seq, b + 10, 2); |
||||
memcpy(&ts_ms, b + 12, 4); |
||||
|
||||
/* аудио-движок (декод + джиттер-буфер) — если зарегистрирован */ |
||||
if (ctx->audio_ops && ctx->audio_ops->on_media) |
||||
ctx->audio_ops->on_media(ctx->inst, call_id, seq, ts_ms, |
||||
b + CALL_MEDIA_HDR_SIZE, (int)(n - CALL_MEDIA_HDR_SIZE)); |
||||
|
||||
/* релей Opus-кадра потребителю (headless-аудио-сокет) */ |
||||
if (ctx->media_recv_fn) |
||||
ctx->media_recv_fn(ctx->inst, call_id, b + CALL_MEDIA_HDR_SIZE, |
||||
(int)(n - CALL_MEDIA_HDR_SIZE), ctx->media_recv_arg); |
||||
|
||||
/* watchdog: пере-взводим таймер no-traffic на каждый принятый кадр */ |
||||
if (s->traffic_timer) { uasync_cancel_timeout(ctx->inst->ua, s->traffic_timer); s->traffic_timer = NULL; } |
||||
s->traffic_timer = uasync_set_timeout(ctx->inst->ua, CALL_NO_TRAFFIC_TB, s, call_traffic_timeout_cb, "call_traffic"); |
||||
(void)from; |
||||
} |
||||
|
||||
/* ─────────────────── приём (etcp_router) ─────────────────── */ |
||||
|
||||
static void call_etcp_recv(struct ETCP_CONN* conn, struct ll_entry* entry) { |
||||
if (!entry || !conn || !entry->dgram || entry->len < ROUTER_SVC_PAYLOAD_OFF + 1) { |
||||
if (entry) { queue_dgram_free(entry); queue_entry_free(entry); } |
||||
return; |
||||
} |
||||
struct call_ctx* ctx = call_of(conn->instance); |
||||
if (!ctx || !ctx->initialized) { queue_dgram_free(entry); queue_entry_free(entry); return; } |
||||
|
||||
const uint8_t* d = entry->dgram; |
||||
uint64_t from; memcpy(&from, d + ROUTER_SVC_SRC_OFF, 8); |
||||
uint64_t group; memcpy(&group, d + ROUTER_SVC_GROUP_OFF, 8); |
||||
uint8_t subcmd = d[ROUTER_SVC_PAYLOAD_OFF]; |
||||
const uint8_t* body = d + ROUTER_SVC_PAYLOAD_OFF; |
||||
size_t blen = entry->len - ROUTER_SVC_PAYLOAD_OFF; |
||||
|
||||
switch (subcmd) { |
||||
case CALL_SUBCMD_INVITE: call_handle_invite(ctx, from, group, body, blen); break; |
||||
case CALL_SUBCMD_RINGING: call_handle_ringing(ctx, from, body, blen); break; |
||||
case CALL_SUBCMD_ACCEPT: call_handle_accept(ctx, from, body, blen); break; |
||||
case CALL_SUBCMD_DECLINE: call_handle_decline(ctx, from, body, blen); break; |
||||
case CALL_SUBCMD_BUSY: call_handle_busy(ctx, from, body, blen); break; |
||||
case CALL_SUBCMD_CANCEL: call_handle_cancel(ctx, from, body, blen); break; |
||||
case CALL_SUBCMD_HANGUP: call_handle_hangup(ctx, from, body, blen); break; |
||||
case CALL_SUBCMD_MEDIA: call_handle_media(ctx, from, body, blen); break; |
||||
default: |
||||
DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: unknown subcmd=0x%02x from=0x%016llx len=%zu", |
||||
CALL_ID, subcmd, (unsigned long long)from, blen); |
||||
break; |
||||
} |
||||
queue_dgram_free(entry); |
||||
queue_entry_free(entry); |
||||
} |
||||
|
||||
/* ─────────────────── публичный API ─────────────────── */ |
||||
|
||||
int call_init(struct UTUN_INSTANCE* inst) { |
||||
if (!inst) return -1; |
||||
if (inst->call && inst->call->initialized) return 0; |
||||
|
||||
struct call_ctx* ctx = u_calloc(1, sizeof(*ctx)); |
||||
if (!ctx) return -1; |
||||
ctx->inst = inst; |
||||
inst->call = ctx; |
||||
|
||||
ctx->sessions = queue_new(inst->ua, 256, offsetof(struct call_session, call_id), 8, "call_sessions"); |
||||
if (!ctx->sessions) { |
||||
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: queue_new(sessions) failed", CALL_ID); |
||||
u_free(ctx); inst->call = NULL; |
||||
return -1; |
||||
} |
||||
if (etcp_router_bind(inst, ETCP_RT_ID_CALL, call_etcp_recv) != 0) { |
||||
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: etcp_router_bind(0x%02x) failed", CALL_ID, ETCP_RT_ID_CALL); |
||||
queue_free(ctx->sessions); |
||||
u_free(ctx); inst->call = NULL; |
||||
return -1; |
||||
} |
||||
etcp_add_conn_status_cbk(inst, call_on_conn_status, inst); |
||||
|
||||
ctx->initialized = 1; |
||||
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: initialized (svc=0x%02x)", CALL_ID, ETCP_RT_ID_CALL); |
||||
return 0; |
||||
} |
||||
|
||||
void call_destroy(struct UTUN_INSTANCE* inst) { |
||||
struct call_ctx* ctx = call_of(inst); |
||||
if (!ctx || !ctx->initialized) return; |
||||
|
||||
/* завершить все активные сессии (без отправки — мы выключаемся) */ |
||||
while (ctx->sessions && ctx->sessions->head) { |
||||
struct ll_entry* e = ctx->sessions->head; |
||||
struct call_session* s = (struct call_session*)e->data; |
||||
s->state = CALL_ST_ENDED; |
||||
if (s->ring_timer) { uasync_cancel_timeout(inst->ua, s->ring_timer); s->ring_timer = NULL; } |
||||
if (s->traffic_timer) { uasync_cancel_timeout(inst->ua, s->traffic_timer); s->traffic_timer = NULL; } |
||||
if (s->stats_timer) { uasync_cancel_timeout(inst->ua, s->stats_timer); s->stats_timer = NULL; } |
||||
if (s->teardown_timer) { uasync_cancel_timeout(inst->ua, s->teardown_timer); s->teardown_timer = NULL; } |
||||
if (s->cm_handle) { conn_mgr_close(s->cm_handle); s->cm_handle = NULL; } |
||||
call_session_cleanup(ctx, s); |
||||
queue_remove_data(ctx->sessions, e); |
||||
queue_entry_free(e); |
||||
} |
||||
queue_free(ctx->sessions); |
||||
etcp_router_unbind(inst, ETCP_RT_ID_CALL); |
||||
etcp_remove_conn_status_cbk(inst, call_on_conn_status, inst); |
||||
inst->call = NULL; |
||||
uint32_t started = ctx->c_calls_started, ended = ctx->c_calls_ended; |
||||
u_free(ctx); |
||||
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: destroyed (started=%u ended=%u)", CALL_ID, started, ended); |
||||
} |
||||
|
||||
int call_is_online(struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t peer_node_id) { |
||||
if (!inst || !ch_id || !ch_id[0] || !inst->topo_groups) return 0; |
||||
if (peer_node_id == inst->node_id) return 1; |
||||
uint64_t group_id = strtoull(ch_id, NULL, 10); |
||||
struct TOPO_GROUP* g = topo_groups_find(inst->topo_groups, group_id); |
||||
if (!g) return 0; |
||||
return topo_node_find_by_id(g, peer_node_id) != NULL; |
||||
} |
||||
|
||||
void call_set_media_recv_cb(struct UTUN_INSTANCE* inst, call_media_recv_fn fn, void* arg) { |
||||
struct call_ctx* ctx = call_of(inst); |
||||
if (!ctx) return; |
||||
ctx->media_recv_fn = fn; |
||||
ctx->media_recv_arg = arg; |
||||
} |
||||
|
||||
void call_set_audio_ops(struct UTUN_INSTANCE* inst, const struct call_audio_ops* ops) { |
||||
struct call_ctx* ctx = call_of(inst); |
||||
if (!ctx) return; |
||||
ctx->audio_ops = ops; |
||||
} |
||||
|
||||
/* ─────────────────── TX медиа: tx_q + backpressure ─────────────────── */ |
||||
|
||||
static void call_tx_flush(struct call_session* s); |
||||
|
||||
static void call_tx_drain_cb(struct ll_queue* q, void* arg) { |
||||
(void)q; |
||||
struct call_session* s = (struct call_session*)arg; |
||||
if (!s || !s->ctx) return; |
||||
if (s->state == CALL_ST_ENDED) return; |
||||
call_tx_flush(s); |
||||
} |
||||
|
||||
/* Дренируем tx_q, пока роутер готов; иначе — одноразовый waiter (re-arm в drain_cb). */ |
||||
static void call_tx_flush(struct call_session* s) { |
||||
struct call_ctx* ctx = s->ctx; |
||||
if (s->state == CALL_ST_ENDED) return; |
||||
/* гарантируем router conn — иначе has_room/on_send_ready не видят его */ |
||||
etcp_router_conn_get(ctx->inst, s->group_id, s->peer_node_id, ETCP_RT_ID_CALL); |
||||
while (s->tx_q && s->tx_q->head) { |
||||
if (!etcp_router_send_q_has_room(ctx->inst, s->group_id, s->peer_node_id, ETCP_RT_ID_CALL)) { |
||||
etcp_router_on_send_ready(ctx->inst, s->group_id, s->peer_node_id, ETCP_RT_ID_CALL, |
||||
&s->send_waiter, call_tx_drain_cb, s); |
||||
return; |
||||
} |
||||
struct ll_entry* e = queue_data_get(s->tx_q); |
||||
if (etcp_route_send(ctx->inst, s->group_id, s->peer_node_id, e, 1, 0) == 0) |
||||
s->c_tx_frames++; |
||||
} |
||||
} |
||||
|
||||
int call_send_media(struct UTUN_INSTANCE* inst, uint64_t call_id, const uint8_t* opus, int len) { |
||||
struct call_ctx* ctx = call_of(inst); |
||||
if (!ctx || !ctx->initialized || !opus || len <= 0) return -1; |
||||
struct call_session* s = call_session_find(ctx, call_id); |
||||
if (!s || s->state != CALL_ST_ACTIVE) return -1; |
||||
if (CALL_MEDIA_HDR_SIZE + (size_t)len > 1024) return -1; |
||||
|
||||
uint8_t buf[1024]; |
||||
struct call_pkt_media* m = (struct call_pkt_media*)buf; |
||||
m->subcmd = CALL_SUBCMD_MEDIA; |
||||
m->call_id = call_id; |
||||
m->track_id = CALL_TRACK_AUDIO; |
||||
m->seq = s->tx_media_seq++; |
||||
m->ts_ms = (uint32_t)(get_time_tb() / 10); |
||||
memcpy(buf + CALL_MEDIA_HDR_SIZE, opus, (size_t)len); |
||||
size_t mlen = CALL_MEDIA_HDR_SIZE + (size_t)len; |
||||
|
||||
/* entry: [svc_id][media-пакет] — кладём в tx_q (переполнение → дроп старейшего) */ |
||||
struct ll_entry* e = queue_entry_new(0); |
||||
if (!e) return -1; |
||||
e->dgram = u_malloc(1 + mlen); |
||||
if (!e->dgram) { queue_entry_free(e); return -1; } |
||||
e->dgram[0] = ETCP_RT_ID_CALL; |
||||
memcpy(e->dgram + 1, buf, mlen); |
||||
e->len = (uint16_t)(1 + mlen); |
||||
|
||||
if (queue_entry_count(s->tx_q) >= CALL_MAX_TX_FRAMES) { |
||||
struct ll_entry* old = queue_data_get(s->tx_q); |
||||
if (old) { queue_dgram_free(old); queue_entry_free(old); s->c_tx_dropped++; } |
||||
} |
||||
queue_data_put(s->tx_q, e); |
||||
|
||||
call_tx_flush(s); |
||||
return 0; |
||||
} |
||||
|
||||
void call_start(struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t peer_node_id, uint64_t out_call_id) { |
||||
struct call_ctx* ctx = call_of(inst); |
||||
if (!ctx || !ctx->initialized || !ch_id || !ch_id[0]) { |
||||
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: call_start bad args (ctx=%p ch_id=%s)", CALL_ID, (void*)ctx, ch_id ? ch_id : "(null)"); |
||||
return; |
||||
} |
||||
if (peer_node_id == inst->node_id) { |
||||
DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: call_start self-call rejected", CALL_ID); |
||||
return; |
||||
} |
||||
|
||||
uint64_t group_id = strtoull(ch_id, NULL, 10); |
||||
if (!call_is_online(inst, ch_id, peer_node_id)) { |
||||
DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: peer 0x%016llx offline in group 0x%016llx — call not started", |
||||
CALL_ID, (unsigned long long)peer_node_id, (unsigned long long)group_id); |
||||
uint8_t buf[9]; |
||||
memcpy(buf, &out_call_id, 8); |
||||
buf[8] = 1; /* err: peer offline */ |
||||
chat_event_post(inst, CHAT_EVT_CALL_ERROR, buf, sizeof(buf)); |
||||
return; |
||||
} |
||||
|
||||
struct call_session* s = call_session_new(ctx, out_call_id, group_id, peer_node_id, CALL_ROLE_CALLER); |
||||
if (!s) { |
||||
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: session alloc failed id=%016llx", CALL_ID, (unsigned long long)out_call_id); |
||||
return; |
||||
} |
||||
s->state = CALL_ST_OUTGOING; |
||||
ctx->c_calls_started++; |
||||
|
||||
uint8_t buf[sizeof(struct call_pkt_invite)]; |
||||
struct call_pkt_invite* inv = (struct call_pkt_invite*)buf; |
||||
inv->subcmd = CALL_SUBCMD_INVITE; |
||||
inv->call_id = out_call_id; |
||||
inv->group_id = group_id; |
||||
inv->num_tracks = 1; |
||||
inv->tracks[0].type = CALL_TRACK_AUDIO; |
||||
inv->tracks[0].codec = CALL_CODEC_OPUS; |
||||
inv->tracks[0].sample_rate = CALL_OPUS_SAMPLE_RATE; |
||||
inv->tracks[0].channels = 1; |
||||
inv->tracks[0].bitrate = 32000; |
||||
|
||||
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: call_start id=%016llx peer=0x%016llx group=0x%016llx", |
||||
CALL_ID, (unsigned long long)out_call_id, (unsigned long long)peer_node_id, (unsigned long long)group_id); |
||||
|
||||
if (call_send(ctx, group_id, peer_node_id, buf, sizeof(buf), ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT) != 0) { |
||||
call_end(ctx, s, CALL_REASON_CONNECT_FAIL); |
||||
return; |
||||
} |
||||
s->ring_timer = uasync_set_timeout(inst->ua, CALL_RING_TIMEOUT_TB, s, call_ring_timeout_cb, "call_ring"); |
||||
} |
||||
|
||||
void call_accept(struct UTUN_INSTANCE* inst, uint64_t call_id) { |
||||
struct call_ctx* ctx = call_of(inst); |
||||
if (!ctx || !ctx->initialized) return; |
||||
struct call_session* s = call_session_find(ctx, call_id); |
||||
if (!s || s->role != CALL_ROLE_CALLEE || s->state == CALL_ST_ENDED) return; |
||||
|
||||
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: call_accept id=%016llx -> ACCEPT + conn_mgr_open", |
||||
CALL_ID, (unsigned long long)call_id); |
||||
s->state = CALL_ST_CONNECTING; |
||||
|
||||
uint8_t pkt[sizeof(struct call_pkt_accept)]; |
||||
struct call_pkt_accept* a = (struct call_pkt_accept*)pkt; |
||||
a->subcmd = CALL_SUBCMD_ACCEPT; |
||||
a->call_id = call_id; |
||||
call_send(ctx, s->group_id, s->peer_node_id, pkt, sizeof(pkt), ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT); |
||||
|
||||
int rc = conn_mgr_open(inst, s->group_id, s->peer_node_id, call_conn_cb, s, &s->cm_handle); |
||||
if (rc != 0 && s->state != CALL_ST_ENDED) { |
||||
call_end(ctx, s, CALL_REASON_CONNECT_FAIL); |
||||
} |
||||
} |
||||
|
||||
void call_decline(struct UTUN_INSTANCE* inst, uint64_t call_id) { |
||||
struct call_ctx* ctx = call_of(inst); |
||||
if (!ctx || !ctx->initialized) return; |
||||
struct call_session* s = call_session_find(ctx, call_id); |
||||
if (!s || s->role != CALL_ROLE_CALLEE || s->state == CALL_ST_ENDED) return; |
||||
|
||||
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: call_decline id=%016llx", CALL_ID, (unsigned long long)call_id); |
||||
uint8_t pkt[sizeof(struct call_pkt_decline)]; |
||||
struct call_pkt_decline* d = (struct call_pkt_decline*)pkt; |
||||
d->subcmd = CALL_SUBCMD_DECLINE; |
||||
d->call_id = call_id; |
||||
d->reason = CALL_REASON_DECLINE; |
||||
call_send(ctx, s->group_id, s->peer_node_id, pkt, sizeof(pkt), ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT); |
||||
|
||||
call_event_id(ctx, CHAT_EVT_CALL_DECLINED, call_id, CALL_REASON_DECLINE); |
||||
call_end(ctx, s, CALL_REASON_DECLINE); |
||||
} |
||||
|
||||
void call_hangup(struct UTUN_INSTANCE* inst, uint64_t call_id) { |
||||
struct call_ctx* ctx = call_of(inst); |
||||
if (!ctx || !ctx->initialized) return; |
||||
struct call_session* s = call_session_find(ctx, call_id); |
||||
if (!s || s->state == CALL_ST_ENDED) return; |
||||
|
||||
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: call_hangup id=%016llx", CALL_ID, (unsigned long long)call_id); |
||||
uint8_t pkt[sizeof(struct call_pkt_hangup)]; |
||||
struct call_pkt_hangup* hu = (struct call_pkt_hangup*)pkt; |
||||
hu->subcmd = CALL_SUBCMD_HANGUP; |
||||
hu->call_id = call_id; |
||||
hu->reason = CALL_REASON_LOCAL_HANGUP; |
||||
call_send(ctx, s->group_id, s->peer_node_id, pkt, sizeof(pkt), ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT); |
||||
|
||||
call_end(ctx, s, CALL_REASON_LOCAL_HANGUP); |
||||
} |
||||
|
||||
/* ─────────────────── trampoline-функции (GUI → uasync) ─────────────────── */ |
||||
|
||||
void call_start_trampoline(void* arg) { |
||||
struct call_start_arg* a = (struct call_start_arg*)arg; |
||||
call_start(a->inst, a->ch_id, a->peer_node_id, a->call_id); |
||||
u_free(a); |
||||
} |
||||
|
||||
void call_accept_trampoline(void* arg) { |
||||
struct call_id_arg* a = (struct call_id_arg*)arg; |
||||
call_accept(a->inst, a->call_id); |
||||
u_free(a); |
||||
} |
||||
|
||||
void call_decline_trampoline(void* arg) { |
||||
struct call_id_arg* a = (struct call_id_arg*)arg; |
||||
call_decline(a->inst, a->call_id); |
||||
u_free(a); |
||||
} |
||||
|
||||
void call_hangup_trampoline(void* arg) { |
||||
struct call_id_arg* a = (struct call_id_arg*)arg; |
||||
call_hangup(a->inst, a->call_id); |
||||
u_free(a); |
||||
} |
||||
|
||||
void call_send_media_trampoline(void* arg) { |
||||
struct call_media_arg* a = (struct call_media_arg*)arg; |
||||
call_send_media(a->inst, a->call_id, a->opus, a->len); |
||||
u_free(a); |
||||
} |
||||
@ -0,0 +1,96 @@
|
||||
// call.h — P2P аудио-звонок (сигналинг + медиа), per-instance
|
||||
//
|
||||
// Модуль оперирует (group_id, peer_node_id): звонок можно инициировать из
|
||||
// CHAT-группы или из DM (group_id — группа маршрутизации, как в dm_core).
|
||||
// Сигналинг идёт через etcp_router (ROUTE_CRYPTO_SIGN|ENCRYPT), соединение
|
||||
// устанавливается через conn_mgr (3-фазное DIRECT→REVERSE→INDIRECT).
|
||||
//
|
||||
// Все функции — в uasync-потоке. GUI/headless вызывают через trampoline
|
||||
// (gui_bridge_post_uasync_fn / uasync_post).
|
||||
#ifndef CALL_H |
||||
#define CALL_H |
||||
|
||||
#include <stdint.h> |
||||
|
||||
struct UTUN_INSTANCE; |
||||
|
||||
#ifdef __cplusplus |
||||
extern "C" { |
||||
#endif |
||||
|
||||
/* ── жизненный цикл ── */ |
||||
|
||||
int call_init(struct UTUN_INSTANCE* inst); /* из utun_instance.c после dm_core_init */ |
||||
void call_destroy(struct UTUN_INSTANCE* inst); /* перед dm_core_destroy */ |
||||
|
||||
/* ── сигналинг (uasync-поток) ── */ |
||||
|
||||
/* Caller: начать звонок. out_call_id — случайный uint64, генерит вызывающий. */ |
||||
void call_start (struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t peer_node_id, uint64_t out_call_id); |
||||
|
||||
/* Callee: ответить/отклонить. hangup — любая сторона в любой момент. */ |
||||
void call_accept (struct UTUN_INSTANCE* inst, uint64_t call_id); |
||||
void call_decline(struct UTUN_INSTANCE* inst, uint64_t call_id); |
||||
void call_hangup (struct UTUN_INSTANCE* inst, uint64_t call_id); |
||||
|
||||
/* Read-only онлайн-проверка (GUI может звать из любого потока). */ |
||||
int call_is_online(struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t peer_node_id); |
||||
|
||||
/* ── медиа (релей Opus-кадров, uasync-поток) ── */ |
||||
|
||||
/* Отправить закодированный Opus-кадр пиру (оборачивает в CALL_MEDIA).
|
||||
* Только когда звонок ACTIVE. seq/ts выставляются автоматически. */ |
||||
int call_send_media(struct UTUN_INSTANCE* inst, uint64_t call_id, const uint8_t* opus, int len); |
||||
|
||||
/* Коллбэк приёма медиа: вызывается в uasync-потоке на каждый Opus-кадр пира.
|
||||
* Используется headless-аудио-сокетом (и в фазе 3 — декодером GUI). */ |
||||
typedef void (*call_media_recv_fn)(struct UTUN_INSTANCE* inst, uint64_t call_id, |
||||
const uint8_t* opus, int len, void* arg); |
||||
void call_set_media_recv_cb(struct UTUN_INSTANCE* inst, call_media_recv_fn fn, void* arg); |
||||
|
||||
/* ── аудио-движок (опционально, GUI-сборки) ──
|
||||
* Сервисный аудио-контур (codec + джиттер-буфер + тоны) регистрируется через |
||||
* call_set_audio_ops. call.c зовёт on_media на каждый принятый медиа-кадр (с seq/ts) |
||||
* и get_stats для события CALL_STATS. Демон без аудио (headless) — ops не ставит. */ |
||||
|
||||
struct call_audio_ops { |
||||
/* uasync-поток: принятый Opus-кадр пира (декод + jitter-буфер). */ |
||||
void (*on_media)(struct UTUN_INSTANCE* inst, uint64_t call_id, |
||||
uint16_t seq, uint32_t ts_ms, const uint8_t* opus, int len); |
||||
/* uasync-поток: текущие метрики джиттер-буфера (buffer_ms, tempo_x100). */ |
||||
int (*get_stats)(uint64_t call_id, int* buffer_ms, int* tempo_x100); |
||||
}; |
||||
void call_set_audio_ops(struct UTUN_INSTANCE* inst, const struct call_audio_ops* ops); |
||||
|
||||
/* Отправить Opus-кадр из ДРУГОГО потока (GUI-аудио): post в uasync через trampoline. */ |
||||
struct call_media_arg { |
||||
struct UTUN_INSTANCE* inst; |
||||
uint64_t call_id; |
||||
uint16_t len; |
||||
uint8_t opus[256]; |
||||
}; |
||||
void call_send_media_trampoline(void* arg); |
||||
|
||||
/* ── trampoline-структуры для gui_bridge_post_uasync_fn / uasync_post ── */ |
||||
|
||||
struct call_start_arg { |
||||
struct UTUN_INSTANCE* inst; |
||||
char ch_id[64]; |
||||
uint64_t peer_node_id; |
||||
uint64_t call_id; |
||||
}; |
||||
|
||||
struct call_id_arg { |
||||
struct UTUN_INSTANCE* inst; |
||||
uint64_t call_id; |
||||
}; |
||||
|
||||
void call_start_trampoline (void* arg); |
||||
void call_accept_trampoline (void* arg); |
||||
void call_decline_trampoline(void* arg); |
||||
void call_hangup_trampoline (void* arg); |
||||
|
||||
#ifdef __cplusplus |
||||
} |
||||
#endif |
||||
#endif /* CALL_H */ |
||||
@ -0,0 +1,283 @@
|
||||
// call_audio.c — сервисный аудио-движок P2P-звонка (Opus + джиттер + тоны).
|
||||
//
|
||||
// Один активный звонок: encoder (аудио-поток), decoder (uasync-поток),
|
||||
// адаптивный джиттер-буфер ajb (SoundTouch) и генератор тонов.
|
||||
//
|
||||
// RX: call.c → call_audio_on_media (uasync) → decode → ajb_push;
|
||||
// аудио-поток call_audio_pull_pcm → ajb_pull (+ глитч/завершение).
|
||||
// TX: аудио-поток call_audio_feed_pcm → encode → uasync_post(call_send_media).
|
||||
|
||||
#include "call_audio.h" |
||||
#include "call.h" |
||||
#include "audio_jitter.h" |
||||
#include "call_tones.h" |
||||
#include "../utun_instance.h" |
||||
#include "../../lib/opus_codec.h" |
||||
#include "../../lib/debug_config.h" |
||||
#include "../../lib/mem.h" |
||||
#include "../../lib/u_async.h" |
||||
|
||||
#include <pthread.h> |
||||
#include <string.h> |
||||
|
||||
#define CALL_AUDIO_ID "call_audio" |
||||
#define CALL_AUDIO_BITRATE 32000 |
||||
#define CALL_AJ_MAX_FRAMES 50 /* ~1000мс @20мс */ |
||||
#define CALL_STALL_TB 5000 /* 500мс без медиа → глитч-тон */ |
||||
|
||||
static pthread_mutex_t g_mtx = PTHREAD_MUTEX_INITIALIZER; |
||||
static struct UTUN_INSTANCE* g_inst = NULL; |
||||
static int g_active = 0; |
||||
static int g_ending = 0; |
||||
static int g_ended_started = 0; /* только аудио-поток: тон завершения запущен */ |
||||
static uint64_t g_call_id = 0; |
||||
static uint64_t g_last_media_tb = 0; |
||||
static opus_codec_encoder_t* g_encoder = NULL; |
||||
static opus_codec_decoder_t* g_decoder = NULL; |
||||
static struct ajb* g_ajb = NULL; |
||||
static struct call_tones* g_tones = NULL; |
||||
|
||||
static const struct call_audio_ops g_ops = { |
||||
.on_media = call_audio_on_media, |
||||
.get_stats = call_audio_get_stats, |
||||
}; |
||||
|
||||
int call_audio_init(struct UTUN_INSTANCE* inst) { |
||||
if (!inst) return -1; |
||||
if (g_inst == inst) return 0; |
||||
g_inst = inst; |
||||
call_set_audio_ops(inst, &g_ops); |
||||
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: initialized", CALL_AUDIO_ID); |
||||
return 0; |
||||
} |
||||
|
||||
void call_audio_destroy(struct UTUN_INSTANCE* inst) { |
||||
if (!inst || g_inst != inst) return; |
||||
call_set_audio_ops(inst, NULL); |
||||
g_inst = NULL; |
||||
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: destroyed", CALL_AUDIO_ID); |
||||
} |
||||
|
||||
/* ── uasync-поток: приём медиа (декод + ajb_push) ── */ |
||||
|
||||
void call_audio_on_media(struct UTUN_INSTANCE* inst, uint64_t call_id, |
||||
uint16_t seq, uint32_t ts_ms, const uint8_t* opus, int len) { |
||||
(void)inst; (void)seq; (void)ts_ms; |
||||
int16_t pcm[CALL_AUDIO_FRAME_SAMPLES]; |
||||
int n = 0; |
||||
|
||||
pthread_mutex_lock(&g_mtx); |
||||
if (g_active && call_id == g_call_id && g_decoder && opus && len > 0) { |
||||
n = opus_codec_decode(g_decoder, opus, len, pcm, CALL_AUDIO_FRAME_SAMPLES); |
||||
if (n > 0 && g_ajb) { |
||||
ajb_push(g_ajb, pcm, n); |
||||
g_last_media_tb = get_time_tb(); |
||||
} |
||||
} |
||||
pthread_mutex_unlock(&g_mtx); |
||||
} |
||||
|
||||
/* ── GUI-поток: lifecycle ── */ |
||||
|
||||
int call_audio_start(uint64_t call_id) { |
||||
if (call_id == 0) { |
||||
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: start bad call_id=0", CALL_AUDIO_ID); |
||||
return -1; |
||||
} |
||||
|
||||
pthread_mutex_lock(&g_mtx); |
||||
if (g_active && g_call_id == call_id) { |
||||
pthread_mutex_unlock(&g_mtx); |
||||
return 0; |
||||
} |
||||
if (g_active) { |
||||
pthread_mutex_unlock(&g_mtx); |
||||
call_audio_release(g_call_id); |
||||
pthread_mutex_lock(&g_mtx); |
||||
} |
||||
|
||||
opus_codec_encoder_t* enc = opus_codec_encoder_create(CALL_AUDIO_SAMPLE_RATE, 1); |
||||
if (!enc) { |
||||
pthread_mutex_unlock(&g_mtx); |
||||
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: encoder create failed", CALL_AUDIO_ID); |
||||
return -1; |
||||
} |
||||
opus_codec_encoder_bitrate_set(enc, CALL_AUDIO_BITRATE); |
||||
|
||||
opus_codec_decoder_t* dec = opus_codec_decoder_create(CALL_AUDIO_SAMPLE_RATE, 1); |
||||
if (!dec) { |
||||
opus_codec_encoder_destroy(enc); |
||||
pthread_mutex_unlock(&g_mtx); |
||||
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: decoder create failed", CALL_AUDIO_ID); |
||||
return -1; |
||||
} |
||||
|
||||
struct ajb* j = ajb_create(CALL_AUDIO_SAMPLE_RATE, 1, CALL_AUDIO_FRAME_SAMPLES, CALL_AUDIO_FRAME_MS, CALL_AJ_MAX_FRAMES); |
||||
if (!j) { |
||||
opus_codec_decoder_destroy(dec); |
||||
opus_codec_encoder_destroy(enc); |
||||
pthread_mutex_unlock(&g_mtx); |
||||
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: ajb create failed", CALL_AUDIO_ID); |
||||
return -1; |
||||
} |
||||
|
||||
struct call_tones* tones = call_tones_create(); |
||||
if (!tones) { |
||||
ajb_destroy(j); |
||||
opus_codec_decoder_destroy(dec); |
||||
opus_codec_encoder_destroy(enc); |
||||
pthread_mutex_unlock(&g_mtx); |
||||
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: tones create failed", CALL_AUDIO_ID); |
||||
return -1; |
||||
} |
||||
|
||||
g_encoder = enc; |
||||
g_decoder = dec; |
||||
g_ajb = j; |
||||
g_tones = tones; |
||||
g_call_id = call_id; |
||||
g_active = 1; |
||||
g_ending = 0; |
||||
g_ended_started = 0; |
||||
g_last_media_tb = 0; |
||||
pthread_mutex_unlock(&g_mtx); |
||||
|
||||
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: started call=0x%016llx", CALL_AUDIO_ID, (unsigned long long)call_id); |
||||
return 0; |
||||
} |
||||
|
||||
void call_audio_begin_end(uint64_t call_id) { |
||||
pthread_mutex_lock(&g_mtx); |
||||
if (g_active && call_id == g_call_id && !g_ending) { |
||||
g_ending = 1; |
||||
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: begin_end call=0x%016llx (ended tone)", CALL_AUDIO_ID, (unsigned long long)call_id); |
||||
} |
||||
pthread_mutex_unlock(&g_mtx); |
||||
} |
||||
|
||||
void call_audio_release(uint64_t call_id) { |
||||
opus_codec_encoder_t* enc = NULL; |
||||
opus_codec_decoder_t* dec = NULL; |
||||
struct ajb* j = NULL; |
||||
struct call_tones* tones = NULL; |
||||
int was_active = 0; |
||||
|
||||
pthread_mutex_lock(&g_mtx); |
||||
if (g_active && g_call_id == call_id) { |
||||
was_active = 1; |
||||
g_active = 0; |
||||
g_ending = 0; |
||||
g_ended_started = 0; |
||||
g_call_id = 0; |
||||
g_last_media_tb = 0; |
||||
enc = g_encoder; g_encoder = NULL; |
||||
dec = g_decoder; g_decoder = NULL; |
||||
j = g_ajb; g_ajb = NULL; |
||||
tones = g_tones; g_tones = NULL; |
||||
} |
||||
pthread_mutex_unlock(&g_mtx); |
||||
|
||||
if (!was_active) return; |
||||
|
||||
/* дальше on_media не тронет объекты (active=0, указатели NULL) */ |
||||
if (enc) opus_codec_encoder_destroy(enc); |
||||
if (dec) opus_codec_decoder_destroy(dec); |
||||
if (j) ajb_destroy(j); |
||||
if (tones) call_tones_destroy(tones); |
||||
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: released call=0x%016llx", CALL_AUDIO_ID, (unsigned long long)call_id); |
||||
} |
||||
|
||||
/* ── аудио-поток: TX / RX ── */ |
||||
|
||||
int call_audio_feed_pcm(uint64_t call_id, const int16_t* pcm, int count) { |
||||
if (!pcm || count != CALL_AUDIO_FRAME_SAMPLES) return -1; |
||||
|
||||
uint8_t opus[256]; |
||||
struct UASYNC* ua = NULL; |
||||
struct UTUN_INSTANCE* inst = NULL; |
||||
int len = -1; |
||||
|
||||
pthread_mutex_lock(&g_mtx); |
||||
if (!g_active || call_id != g_call_id || !g_encoder) { |
||||
pthread_mutex_unlock(&g_mtx); |
||||
return -1; |
||||
} |
||||
len = opus_codec_encode(g_encoder, pcm, count, opus, (int)sizeof(opus)); |
||||
ua = g_inst ? g_inst->ua : NULL; |
||||
inst = g_inst; |
||||
pthread_mutex_unlock(&g_mtx); |
||||
|
||||
if (len <= 0 || !ua || !inst) return -1; |
||||
|
||||
struct call_media_arg* a = (struct call_media_arg*)u_malloc(sizeof(*a)); |
||||
if (!a) return -1; |
||||
a->inst = inst; |
||||
a->call_id = call_id; |
||||
a->len = (uint16_t)len; |
||||
memcpy(a->opus, opus, (size_t)len); |
||||
uasync_post(ua, call_send_media_trampoline, a); |
||||
return 0; |
||||
} |
||||
|
||||
int call_audio_pull_pcm(uint64_t call_id, int16_t* out, int max_samples) { |
||||
if (!out || max_samples <= 0) return 0; |
||||
|
||||
int active = 0, ending = 0; |
||||
struct ajb* j = NULL; |
||||
struct call_tones* tones = NULL; |
||||
uint64_t last_media = 0; |
||||
|
||||
pthread_mutex_lock(&g_mtx); |
||||
if (g_active && call_id == g_call_id) { |
||||
active = 1; |
||||
ending = g_ending; |
||||
j = g_ajb; |
||||
tones = g_tones; |
||||
last_media = g_last_media_tb; |
||||
} |
||||
pthread_mutex_unlock(&g_mtx); |
||||
|
||||
if (!active || !j || !tones) return 0; |
||||
|
||||
/* завершение: доигрываем тон (450мс), затем тишина. Тон стартует лениво
|
||||
* здесь (аудио-поток) — begin_end лишь выставляет флаг, чтобы не гонять |
||||
* генератор тонов с GUI-потоком. */ |
||||
if (ending) { |
||||
if (!g_ended_started) { |
||||
call_tones_ended_start(tones); |
||||
g_ended_started = 1; |
||||
} |
||||
if (call_tones_ended_pending(tones)) |
||||
return call_tones_render(tones, out, max_samples); |
||||
return 0; |
||||
} |
||||
|
||||
int n = ajb_pull(j, out, max_samples); |
||||
if (n >= max_samples) return n; |
||||
|
||||
/* недобор: либо сталь (глитч-тон), либо тишина */ |
||||
uint64_t now = get_time_tb(); |
||||
int stalled = (last_media != 0) && (now - last_media > CALL_STALL_TB); |
||||
if (stalled) { |
||||
call_tones_glitch_start(tones); |
||||
int m = call_tones_render(tones, out + n, max_samples - n); |
||||
n += m; |
||||
} else { |
||||
call_tones_glitch_stop(tones); |
||||
} |
||||
for (int i = n; i < max_samples; i++) out[i] = 0; |
||||
return max_samples; |
||||
} |
||||
|
||||
/* ── uasync-поток: метрики ── */ |
||||
|
||||
int call_audio_get_stats(uint64_t call_id, int* buffer_ms, int* tempo_x100) { |
||||
int ok = 0; |
||||
pthread_mutex_lock(&g_mtx); |
||||
if (g_active && call_id == g_call_id && g_ajb) { |
||||
ajb_get_stats(g_ajb, buffer_ms, tempo_x100); |
||||
ok = 1; |
||||
} |
||||
pthread_mutex_unlock(&g_mtx); |
||||
return ok ? 0 : -1; |
||||
} |
||||
@ -0,0 +1,61 @@
|
||||
#ifndef CALL_AUDIO_H |
||||
#define CALL_AUDIO_H |
||||
|
||||
// call_audio.h — сервисный аудио-движок P2P-звонка (GUI-сборки: desktop/Android).
|
||||
//
|
||||
// Владеет Opus codec, адаптивным джиттер-буфером (SoundTouch time-stretch) и
|
||||
// тонами (глитч при сталле / завершение). Потребитель (desktop miniaudio или
|
||||
// Android AudioTrack) получает финальный PCM через call_audio_pull_pcm и отдаёт
|
||||
// захваченный PCM через call_audio_feed_pcm.
|
||||
//
|
||||
// Потоки:
|
||||
// - call_audio_start / begin_end / release — GUI-поток (по ACCEPTED/ENDED);
|
||||
// - call_audio_feed_pcm / pull_pcm — аудио-поток (single-writer);
|
||||
// - call_audio_on_media / get_stats — uasync-поток (из call.c).
|
||||
// Общие указатели (encoder/decoder/ajb/tones) защищены мьютексом; encoder —
|
||||
// только аудио-поток, decoder+ajb_push — только uasync-поток.
|
||||
|
||||
#include <stdint.h> |
||||
|
||||
struct UTUN_INSTANCE; |
||||
|
||||
#ifdef __cplusplus |
||||
extern "C" { |
||||
#endif |
||||
|
||||
#define CALL_AUDIO_SAMPLE_RATE 48000 |
||||
#define CALL_AUDIO_FRAME_MS 20 |
||||
#define CALL_AUDIO_FRAME_SAMPLES (CALL_AUDIO_SAMPLE_RATE * CALL_AUDIO_FRAME_MS / 1000) /* 960 */ |
||||
|
||||
/* Зарегистрировать аудио-движок в call.c (идемпотентно). */ |
||||
int call_audio_init(struct UTUN_INSTANCE* inst); |
||||
void call_audio_destroy(struct UTUN_INSTANCE* inst); |
||||
|
||||
/* GUI-поток: создать codec + jitter + tones для звонка. 0 = успех. */ |
||||
int call_audio_start(uint64_t call_id); |
||||
|
||||
/* GUI-поток: по CALL_ENDED — начать тон завершения (pull_pcm выдаст 450мс тона). */ |
||||
void call_audio_begin_end(uint64_t call_id); |
||||
|
||||
/* GUI-поток (после остановки аудиоустройства): освободить все объекты. */ |
||||
void call_audio_release(uint64_t call_id); |
||||
|
||||
/* аудио-поток: закодировать PCM-кадр (ровно CALL_AUDIO_FRAME_SAMPLES) и отправить пиру. */ |
||||
int call_audio_feed_pcm(uint64_t call_id, const int16_t* pcm, int count); |
||||
|
||||
/* аудио-поток: выдать до max_samples финального PCM (jitter + stretch + тоны).
|
||||
* Возвращает число сэмплов (может быть < max — андерфлоу/конец тона). */ |
||||
int call_audio_pull_pcm(uint64_t call_id, int16_t* out, int max_samples); |
||||
|
||||
/* uasync-поток (из call.c): принятый Opus-кадр — декод → ajb_push. */ |
||||
void call_audio_on_media(struct UTUN_INSTANCE* inst, uint64_t call_id, |
||||
uint16_t seq, uint32_t ts_ms, const uint8_t* opus, int len); |
||||
|
||||
/* uasync-поток: метрики джиттер-буфера для CALL_STATS. */ |
||||
int call_audio_get_stats(uint64_t call_id, int* buffer_ms, int* tempo_x100); |
||||
|
||||
#ifdef __cplusplus |
||||
} |
||||
#endif |
||||
|
||||
#endif /* CALL_AUDIO_H */ |
||||
@ -0,0 +1,278 @@
|
||||
// call_headless.c — headless-аудио-сокет для P2P-звонков
|
||||
//
|
||||
// TCP-сокет с бинарным фреймингом [type:1][call_id:8][len:2][data:len].
|
||||
// Клиент привязывается к звонку (HELLO) и шлёт/принимает Opus-кадры (FRAME).
|
||||
// Сигналинг — на control-сокете (chat_headless_control), медиа — здесь.
|
||||
#include "call_headless.h" |
||||
#include "call.h" |
||||
#include "../utun_instance.h" |
||||
#include "../../lib/u_async.h" |
||||
#include "../../lib/mem.h" |
||||
#include "../../lib/socket_compat.h" |
||||
#include "../../lib/debug_config.h" |
||||
#include "../../lib/platform_compat.h" |
||||
|
||||
#include <string.h> |
||||
#include <stdio.h> |
||||
#include <stdlib.h> |
||||
#include <errno.h> |
||||
|
||||
#define CH_ID "call_audio" |
||||
|
||||
#define AUDIO_RECV_BUF_SIZE 65536 |
||||
#define AUDIO_SEND_BUF_SIZE 65536 |
||||
#define AUDIO_MAX_CLIENTS 16 |
||||
#define AUDIO_MAX_FRAME 2048 |
||||
|
||||
struct call_audio_client { |
||||
socket_t fd; |
||||
void* socket_id; |
||||
uint64_t call_id; /* 0 = не привязан */ |
||||
uint8_t recv_buf[AUDIO_RECV_BUF_SIZE]; |
||||
int recv_len; |
||||
uint8_t send_buf[AUDIO_SEND_BUF_SIZE]; |
||||
int send_len; |
||||
int send_offset; |
||||
int closing; |
||||
struct call_audio* ca; |
||||
struct call_audio_client* next; |
||||
}; |
||||
|
||||
struct call_audio { |
||||
struct UASYNC* ua; |
||||
struct UTUN_INSTANCE* inst; |
||||
struct call_audio_client* clients; |
||||
int client_count; |
||||
socket_t listen_fd; |
||||
void* listen_sock_id; |
||||
int running; |
||||
}; |
||||
|
||||
static struct call_audio* ca_of(struct UTUN_INSTANCE* inst) { |
||||
return inst ? (struct call_audio*)inst->call_audio : NULL; |
||||
} |
||||
|
||||
/* ── отправка фрейма клиенту (с буферизацией как в control-сокете) ── */ |
||||
|
||||
static void ca_send_raw(struct call_audio_client* cli, const uint8_t* data, size_t len) { |
||||
struct call_audio* ca = cli->ca; |
||||
if (!cli || cli->closing || cli->fd == SOCKET_INVALID || !data || len == 0) return; |
||||
if (cli->send_len > 0) { |
||||
size_t space = sizeof(cli->send_buf) - (size_t)cli->send_len; |
||||
if (len > space) return; |
||||
memcpy(cli->send_buf + cli->send_len, data, len); |
||||
cli->send_len += (int)len; |
||||
return; |
||||
} |
||||
ssize_t r = send(cli->fd, data, (int)len, 0); |
||||
if (r < 0 && (socket_get_error() == ERR_AGAIN || socket_get_error() == ERR_WOULDBLOCK)) { |
||||
if (len <= sizeof(cli->send_buf)) { memcpy(cli->send_buf, data, len); cli->send_len = (int)len; cli->send_offset = 0; } |
||||
if (ca && ca->ua) uasync_set_socket_write(ca->ua, cli->socket_id, 1); |
||||
return; |
||||
} |
||||
if (r >= 0 && (size_t)r < len) { |
||||
size_t remain = len - (size_t)r; |
||||
if (remain <= sizeof(cli->send_buf)) { memcpy(cli->send_buf, data + r, remain); cli->send_len = (int)remain; cli->send_offset = 0; } |
||||
if (ca && ca->ua) uasync_set_socket_write(ca->ua, cli->socket_id, 1); |
||||
} |
||||
} |
||||
|
||||
static void ca_write_frame(struct call_audio_client* cli, uint8_t type, uint64_t call_id, |
||||
const uint8_t* data, uint16_t len) { |
||||
uint8_t hdr[CALL_AUDIO_HDR_SIZE]; |
||||
hdr[0] = type; |
||||
memcpy(hdr + 1, &call_id, 8); |
||||
hdr[9] = (uint8_t)(len >> 8); |
||||
hdr[10] = (uint8_t)(len & 0xFF); |
||||
ca_send_raw(cli, hdr, sizeof(hdr)); |
||||
if (len) ca_send_raw(cli, data, len); |
||||
} |
||||
|
||||
/* ── приём медиа от пира → клиенту ── */ |
||||
|
||||
static void ca_media_recv(struct UTUN_INSTANCE* inst, uint64_t call_id, |
||||
const uint8_t* opus, int len, void* arg) { |
||||
struct call_audio* ca = (struct call_audio*)arg; |
||||
(void)inst; |
||||
if (!ca) return; |
||||
for (struct call_audio_client* c = ca->clients; c; c = c->next) { |
||||
if (c->call_id == call_id && !c->closing) { |
||||
ca_write_frame(c, CALL_AUDIO_FRAME, call_id, opus, (uint16_t)len); |
||||
break; |
||||
} |
||||
} |
||||
} |
||||
|
||||
/* ── разбор входящих фреймов ── */ |
||||
|
||||
static void ca_close_client(struct call_audio_client* cli); |
||||
|
||||
static void ca_parse_frames(struct call_audio_client* cli) { |
||||
struct call_audio* ca = cli->ca; |
||||
uint8_t* p = cli->recv_buf; |
||||
int avail = cli->recv_len; |
||||
int consumed = 0; |
||||
while (avail - consumed >= CALL_AUDIO_HDR_SIZE) { |
||||
uint8_t type = p[consumed]; |
||||
uint64_t call_id; memcpy(&call_id, p + consumed + 1, 8); |
||||
uint16_t flen = (uint16_t)((p[consumed + 9] << 8) | p[consumed + 10]); |
||||
if (flen > AUDIO_MAX_FRAME) { |
||||
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: frame too big %u — closing", CH_ID, flen); |
||||
ca_close_client(cli); |
||||
return; |
||||
} |
||||
if (avail - consumed < (int)(CALL_AUDIO_HDR_SIZE + flen)) break; /* нужен ещё кусок */ |
||||
const uint8_t* data = p + consumed + CALL_AUDIO_HDR_SIZE; |
||||
if (type == CALL_AUDIO_HELLO) { |
||||
cli->call_id = call_id; |
||||
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: audio client bound to call_id=%016llx", CH_ID, (unsigned long long)call_id); |
||||
} else if (type == CALL_AUDIO_FRAME) { |
||||
if (call_send_media(ca->inst, call_id, data, flen) != 0) |
||||
DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: send_media failed call_id=%016llx len=%u", CH_ID, (unsigned long long)call_id, flen); |
||||
} else { |
||||
DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: unknown frame type=0x%02x — closing", CH_ID, type); |
||||
ca_close_client(cli); |
||||
return; |
||||
} |
||||
consumed += CALL_AUDIO_HDR_SIZE + flen; |
||||
} |
||||
if (consumed > 0) { |
||||
memmove(cli->recv_buf, cli->recv_buf + consumed, (size_t)(cli->recv_len - consumed)); |
||||
cli->recv_len -= consumed; |
||||
} |
||||
} |
||||
|
||||
/* ── socket callbacks ── */ |
||||
|
||||
static void ca_write_callback(socket_t fd, void* arg) { |
||||
struct call_audio_client* cli = (struct call_audio_client*)arg; |
||||
struct call_audio* ca = cli ? cli->ca : NULL; |
||||
if (!cli || cli->closing || fd != cli->fd) return; |
||||
if (cli->send_len <= 0) { if (ca && ca->ua) uasync_set_socket_write(ca->ua, cli->socket_id, 0); return; } |
||||
ssize_t r = send(cli->fd, cli->send_buf + cli->send_offset, cli->send_len - cli->send_offset, 0); |
||||
if (r < 0 && (socket_get_error() == ERR_AGAIN || socket_get_error() == ERR_WOULDBLOCK)) return; |
||||
if (r <= 0) { cli->send_len = 0; cli->send_offset = 0; if (ca && ca->ua) uasync_set_socket_write(ca->ua, cli->socket_id, 0); return; } |
||||
cli->send_offset += (int)r; |
||||
if (cli->send_offset >= cli->send_len) { cli->send_len = 0; cli->send_offset = 0; if (ca && ca->ua) uasync_set_socket_write(ca->ua, cli->socket_id, 0); } |
||||
} |
||||
|
||||
static void ca_close_client(struct call_audio_client* cli) { |
||||
struct call_audio* ca = cli ? cli->ca : NULL; |
||||
if (!cli || cli->closing) return; |
||||
cli->closing = 1; |
||||
if (ca && ca->ua && cli->socket_id) uasync_remove_socket_t(ca->ua, cli->fd); |
||||
if (cli->fd != SOCKET_INVALID) { socket_close_wrapper(cli->fd); cli->fd = SOCKET_INVALID; } |
||||
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: audio client closed call_id=%016llx", CH_ID, (unsigned long long)cli->call_id); |
||||
} |
||||
|
||||
static void ca_read_callback(socket_t fd, void* arg) { |
||||
struct call_audio_client* cli = (struct call_audio_client*)arg; |
||||
if (!cli || cli->closing) return; |
||||
uint8_t tmp[4096]; |
||||
ssize_t r = recv(fd, tmp, (int)sizeof(tmp), 0); |
||||
if (r <= 0) { ca_close_client(cli); return; } |
||||
if (cli->recv_len + (int)r > AUDIO_RECV_BUF_SIZE) { |
||||
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: recv buffer overflow — closing", CH_ID); |
||||
ca_close_client(cli); |
||||
return; |
||||
} |
||||
memcpy(cli->recv_buf + cli->recv_len, tmp, (size_t)r); |
||||
cli->recv_len += (int)r; |
||||
ca_parse_frames(cli); |
||||
} |
||||
|
||||
static void ca_accept_callback(socket_t fd, void* arg) { |
||||
struct call_audio* ca = (struct call_audio*)arg; |
||||
struct sockaddr_storage addr; socklen_t alen = sizeof(addr); |
||||
socket_t cfd = accept(fd, (struct sockaddr*)&addr, &alen); |
||||
if (cfd == SOCKET_INVALID) { |
||||
int e = socket_get_error(); |
||||
if (e != ERR_AGAIN && e != ERR_WOULDBLOCK) DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: accept failed: %s", CH_ID, socket_strerror(e)); |
||||
return; |
||||
} |
||||
socket_set_nonblocking(cfd); |
||||
if (ca->client_count >= AUDIO_MAX_CLIENTS) { socket_close_wrapper(cfd); DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: max clients reached", CH_ID); return; } |
||||
|
||||
struct call_audio_client* cli = u_calloc(1, sizeof(*cli)); |
||||
if (!cli) { socket_close_wrapper(cfd); return; } |
||||
cli->fd = cfd; |
||||
cli->ca = ca; |
||||
cli->socket_id = uasync_add_socket_t(ca->ua, cfd, ca_read_callback, ca_write_callback, NULL, cli); |
||||
if (!cli->socket_id) { u_free(cli); socket_close_wrapper(cfd); DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: failed to register client fd", CH_ID); return; } |
||||
|
||||
cli->next = ca->clients; ca->clients = cli; ca->client_count++; |
||||
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: audio client connected fd=%d total=%d", CH_ID, (int)cfd, ca->client_count); |
||||
} |
||||
|
||||
/* ── периодическая очистка закрытых клиентов ── */ |
||||
|
||||
static void ca_cleanup(void* arg) { |
||||
struct call_audio* ca = (struct call_audio*)arg; |
||||
struct call_audio_client** prev = &ca->clients; |
||||
while (*prev) { |
||||
if ((*prev)->closing) { |
||||
struct call_audio_client* dead = *prev; *prev = dead->next; |
||||
if (dead->fd != SOCKET_INVALID) socket_close_wrapper(dead->fd); |
||||
u_free(dead); ca->client_count--; |
||||
} else prev = &(*prev)->next; |
||||
} |
||||
if (ca->ua) uasync_set_timeout(ca->ua, 5000, ca, ca_cleanup, "ca_cleanup"); |
||||
} |
||||
|
||||
/* ── init / destroy ── */ |
||||
|
||||
int call_headless_init(struct UASYNC* ua, struct UTUN_INSTANCE* inst, |
||||
const char* bind_ip, int port) { |
||||
if (!ua || !inst || !bind_ip || port <= 0 || port > 65535) return -1; |
||||
struct call_audio* ca = ca_of(inst); |
||||
if (ca && ca->running) return 0; |
||||
|
||||
if (!ca) { |
||||
ca = u_calloc(1, sizeof(*ca)); |
||||
if (!ca) return -1; |
||||
inst->call_audio = ca; |
||||
} |
||||
ca->ua = ua; ca->inst = inst; |
||||
|
||||
ca->listen_fd = socket(AF_INET, SOCK_STREAM, 0); |
||||
if (ca->listen_fd == SOCKET_INVALID) { DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: socket() failed: %s", CH_ID, socket_strerror(socket_get_error())); return -1; } |
||||
socket_set_reuseaddr(ca->listen_fd, 1); |
||||
socket_set_nonblocking(ca->listen_fd); |
||||
|
||||
struct sockaddr_in sin; memset(&sin, 0, sizeof(sin)); |
||||
sin.sin_family = AF_INET; sin.sin_port = htons((uint16_t)port); |
||||
if (inet_pton(AF_INET, bind_ip, &sin.sin_addr) != 1) { socket_close_wrapper(ca->listen_fd); ca->listen_fd = SOCKET_INVALID; return -1; } |
||||
if (bind(ca->listen_fd, (struct sockaddr*)&sin, sizeof(sin)) < 0) { |
||||
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: bind(%s:%d) failed: %s", CH_ID, bind_ip, port, socket_strerror(socket_get_error())); |
||||
socket_close_wrapper(ca->listen_fd); ca->listen_fd = SOCKET_INVALID; return -1; |
||||
} |
||||
if (listen(ca->listen_fd, 5) < 0) { socket_close_wrapper(ca->listen_fd); ca->listen_fd = SOCKET_INVALID; return -1; } |
||||
|
||||
ca->listen_sock_id = uasync_add_socket_t(ua, ca->listen_fd, ca_accept_callback, NULL, NULL, ca); |
||||
if (!ca->listen_sock_id) { socket_close_wrapper(ca->listen_fd); ca->listen_fd = SOCKET_INVALID; return -1; } |
||||
|
||||
call_set_media_recv_cb(inst, ca_media_recv, ca); |
||||
uasync_set_timeout(ua, 5000, ca, ca_cleanup, "ca_cleanup"); |
||||
|
||||
ca->running = 1; |
||||
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: audio socket listening on %s:%d", CH_ID, bind_ip, port); |
||||
return 0; |
||||
} |
||||
|
||||
void call_headless_destroy(struct UTUN_INSTANCE* inst) { |
||||
struct call_audio* ca = ca_of(inst); |
||||
if (!ca || !ca->running) return; |
||||
ca->running = 0; |
||||
call_set_media_recv_cb(inst, NULL, NULL); |
||||
if (ca->listen_sock_id && ca->ua) uasync_remove_socket_t(ca->ua, ca->listen_fd); |
||||
if (ca->listen_fd != SOCKET_INVALID) { socket_close_wrapper(ca->listen_fd); ca->listen_fd = SOCKET_INVALID; } |
||||
struct call_audio_client* c = ca->clients; |
||||
while (c) { |
||||
struct call_audio_client* n = c->next; |
||||
if (c->fd != SOCKET_INVALID) socket_close_wrapper(c->fd); |
||||
u_free(c); |
||||
c = n; |
||||
} |
||||
ca->clients = NULL; ca->client_count = 0; |
||||
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: audio socket destroyed", CH_ID); |
||||
} |
||||
@ -0,0 +1,34 @@
|
||||
// call_headless.h — headless-аудио-сокет для P2P-звонков
|
||||
//
|
||||
// Отдельный TCP-сокет, через который headless-клиент (CLI/скрипт) обменивается
|
||||
// Opus-кадрами с ядром звонка. Сигналинг идёт по control-сокету (chat_headless_control),
|
||||
// аудио — по этому сокету.
|
||||
//
|
||||
// Бинарный фрейм (обе стороны):
|
||||
// [type:1][call_id:8][len:2][data:len]
|
||||
// type = 0x01 HELLO (клиент → ядро: привязать соединение к call_id, data пусто)
|
||||
// type = 0x02 FRAME (Opus-кадр: клиент → ядро = отправить пиру; ядро → клиент = кадр от пира)
|
||||
#ifndef CALL_HEADLESS_H |
||||
#define CALL_HEADLESS_H |
||||
|
||||
#include <stdint.h> |
||||
|
||||
struct UASYNC; |
||||
struct UTUN_INSTANCE; |
||||
|
||||
#define CALL_AUDIO_HELLO 0x01 |
||||
#define CALL_AUDIO_FRAME 0x02 |
||||
#define CALL_AUDIO_HDR_SIZE 11 /* type(1) + call_id(8) + len(2) */ |
||||
|
||||
#ifdef __cplusplus |
||||
extern "C" { |
||||
#endif |
||||
|
||||
int call_headless_init(struct UASYNC* ua, struct UTUN_INSTANCE* inst, |
||||
const char* bind_ip, int port); |
||||
void call_headless_destroy(struct UTUN_INSTANCE* inst); |
||||
|
||||
#ifdef __cplusplus |
||||
} |
||||
#endif |
||||
#endif /* CALL_HEADLESS_H */ |
||||
@ -0,0 +1,95 @@
|
||||
// call_proto.h — wire-формат и константы P2P аудио-звонка (svc_id ETCP_RT_ID_CALL 0x36)
|
||||
//
|
||||
// Приём/отправка через etcp_router. На проводе первый байт payload = subcmd.
|
||||
// Сигналинг идёт с ROUTE_CRYPTO_SIGN|ROUTE_CRYPTO_ENCRYPT, медиа — без подписи
|
||||
// (уже внутри шифрованного канала etcp_router).
|
||||
#ifndef CALL_PROTO_H |
||||
#define CALL_PROTO_H |
||||
|
||||
#include <stdint.h> |
||||
|
||||
#ifdef __cplusplus |
||||
extern "C" { |
||||
#endif |
||||
|
||||
/* ── подкоманды (первый байт payload после svc_id) ── */ |
||||
enum { |
||||
CALL_SUBCMD_INVITE = 0x01, /* {call_id:8, group_id:8, tracks:[]} */ |
||||
CALL_SUBCMD_RINGING = 0x02, /* {call_id:8} */ |
||||
CALL_SUBCMD_ACCEPT = 0x03, /* {call_id:8} */ |
||||
CALL_SUBCMD_DECLINE = 0x04, /* {call_id:8, reason:1} */ |
||||
CALL_SUBCMD_BUSY = 0x05, /* {call_id:8} */ |
||||
CALL_SUBCMD_CANCEL = 0x06, /* {call_id:8} */ |
||||
CALL_SUBCMD_HANGUP = 0x07, /* {call_id:8, reason:1} */ |
||||
CALL_SUBCMD_MEDIA = 0x10, /* {call_id:8, track_id:1, seq:2, ts_ms:4, payload[]} */ |
||||
}; |
||||
|
||||
/* ── треки и кодеки (модель расширяема под видео) ── */ |
||||
enum { CALL_TRACK_AUDIO = 0x01, CALL_TRACK_VIDEO = 0x02 }; |
||||
enum { CALL_CODEC_OPUS = 0x01 }; |
||||
|
||||
/* ── причины завершения (ENDED) ── */ |
||||
enum { |
||||
CALL_REASON_LOCAL_HANGUP = 0, |
||||
CALL_REASON_REMOTE_HANGUP = 1, |
||||
CALL_REASON_DECLINE = 2, |
||||
CALL_REASON_BUSY = 3, |
||||
CALL_REASON_CANCEL = 4, |
||||
CALL_REASON_NO_TRAFFIC = 5, |
||||
CALL_REASON_RING_TIMEOUT = 6, |
||||
CALL_REASON_CONNECT_FAIL = 7, |
||||
}; |
||||
|
||||
#pragma pack(push, 1) |
||||
|
||||
struct call_pkt_track { |
||||
uint8_t type; /* CALL_TRACK_AUDIO / CALL_TRACK_VIDEO */ |
||||
uint8_t codec; /* CALL_CODEC_OPUS */ |
||||
uint32_t sample_rate; |
||||
uint8_t channels; |
||||
uint32_t bitrate; |
||||
}; |
||||
|
||||
struct call_pkt_invite { |
||||
uint8_t subcmd; /* CALL_SUBCMD_INVITE */ |
||||
uint64_t call_id; |
||||
uint64_t group_id; |
||||
uint8_t num_tracks; |
||||
struct call_pkt_track tracks[1]; /* tracks[num_tracks] */ |
||||
}; |
||||
|
||||
struct call_pkt_ringing { uint8_t subcmd; uint64_t call_id; }; |
||||
struct call_pkt_accept { uint8_t subcmd; uint64_t call_id; }; |
||||
struct call_pkt_decline { uint8_t subcmd; uint64_t call_id; uint8_t reason; }; |
||||
struct call_pkt_busy { uint8_t subcmd; uint64_t call_id; }; |
||||
struct call_pkt_cancel { uint8_t subcmd; uint64_t call_id; }; |
||||
struct call_pkt_hangup { uint8_t subcmd; uint64_t call_id; uint8_t reason; }; |
||||
|
||||
struct call_pkt_media { |
||||
uint8_t subcmd; /* CALL_SUBCMD_MEDIA */ |
||||
uint64_t call_id; |
||||
uint8_t track_id; |
||||
uint16_t seq; |
||||
uint32_t ts_ms; |
||||
/* payload[] */ |
||||
}; |
||||
|
||||
#pragma pack(pop) |
||||
|
||||
#define CALL_MEDIA_HDR_SIZE sizeof(struct call_pkt_media) /* 16 */ |
||||
|
||||
/* ── параметры аудио (Opus 48kHz mono, 20ms) ── */ |
||||
#define CALL_OPUS_SAMPLE_RATE 48000 |
||||
#define CALL_OPUS_FRAME_MS 20 |
||||
#define CALL_OPUS_FRAME_SAMPLES (CALL_OPUS_SAMPLE_RATE * CALL_OPUS_FRAME_MS / 1000) /* 960 */ |
||||
|
||||
/* ── таймауты и ёмкости (timebase 0.1ms) ── */ |
||||
#define CALL_RING_TIMEOUT_TB 450000 /* 45с — нет ответа → CANCEL */ |
||||
#define CALL_NO_TRAFFIC_TB 200000 /* 20с — нет входящих медиа → HANGUP */ |
||||
#define CALL_TEARDOWN_DELAY_TB 5000 /* 500мс — задержка разрыва conn_mgr после завершения */ |
||||
#define CALL_MAX_TX_FRAMES 50 /* ~1с буфер отправки (50 кадров × 20мс) */ |
||||
|
||||
#ifdef __cplusplus |
||||
} |
||||
#endif |
||||
#endif /* CALL_PROTO_H */ |
||||
@ -0,0 +1,165 @@
|
||||
// call_tones.c — генератор тонов звонка (48kHz mono int16).
|
||||
//
|
||||
// Глитч (сталл >500мс): 1000Гц, паттерн из doc/chat_call_arch.md §8 —
|
||||
// пи-пи (2×150мс, gap 100мс) … тишина до 1.0с … пи(200мс) пауза(200мс) пи(200мс),
|
||||
// цикл 2с, повторяется пока длится сталл.
|
||||
// Завершение: 3 нисходящих тона 700/500/400 Гц × 150мс (один раз, 450мс).
|
||||
//
|
||||
// Генерация — синус с коротким fade in/out (без кликов). Только аудио-поток.
|
||||
|
||||
#include "call_tones.h" |
||||
|
||||
#include <math.h> |
||||
#include <string.h> |
||||
#include <stdlib.h> |
||||
|
||||
#define TONES_SAMPLE_RATE 48000 |
||||
#define TONES_AMPLITUDE 0.25f /* от полной шкалы int16 */ |
||||
#define TONES_FADE_SAMPLES (TONES_SAMPLE_RATE * 5 / 1000) /* 5мс */ |
||||
|
||||
enum tones_mode { TONES_NONE = 0, TONES_GLITCH, TONES_ENDED }; |
||||
|
||||
struct tone_seg { |
||||
int tone; /* 1 = звук, 0 = тишина */ |
||||
int dur_ms; |
||||
int freq_hz; |
||||
}; |
||||
|
||||
static const struct tone_seg GLITCH_SEGS[] = { |
||||
{1, 150, 1000}, {0, 100, 0}, {1, 150, 1000}, {0, 600, 0}, |
||||
{1, 200, 1000}, {0, 200, 0}, {1, 200, 1000}, {0, 400, 0}, |
||||
}; |
||||
#define GLITCH_SEG_COUNT ((int)(sizeof(GLITCH_SEGS) / sizeof(GLITCH_SEGS[0]))) |
||||
|
||||
static const struct tone_seg ENDED_SEGS[] = { |
||||
{1, 150, 700}, {1, 150, 500}, {1, 150, 400}, |
||||
}; |
||||
#define ENDED_SEG_COUNT ((int)(sizeof(ENDED_SEGS) / sizeof(ENDED_SEGS[0]))) |
||||
|
||||
struct call_tones { |
||||
enum tones_mode mode; |
||||
const struct tone_seg* segs; |
||||
int seg_count; |
||||
int seg_idx; |
||||
int seg_pos; /* сэмплов отыграно в текущем сегменте */ |
||||
int ended_done; |
||||
double phase; /* фаза синуса (радианы) */ |
||||
}; |
||||
|
||||
struct call_tones* call_tones_create(void) { |
||||
struct call_tones* t = (struct call_tones*)calloc(1, sizeof(*t)); |
||||
if (!t) return NULL; |
||||
t->mode = TONES_NONE; |
||||
t->segs = NULL; |
||||
t->seg_count = 0; |
||||
t->seg_idx = 0; |
||||
t->seg_pos = 0; |
||||
t->ended_done = 0; |
||||
t->phase = 0.0; |
||||
return t; |
||||
} |
||||
|
||||
void call_tones_destroy(struct call_tones* t) { |
||||
free(t); |
||||
} |
||||
|
||||
void call_tones_reset(struct call_tones* t) { |
||||
if (!t) return; |
||||
t->mode = TONES_NONE; |
||||
t->segs = NULL; |
||||
t->seg_count = 0; |
||||
t->seg_idx = 0; |
||||
t->seg_pos = 0; |
||||
t->ended_done = 0; |
||||
t->phase = 0.0; |
||||
} |
||||
|
||||
void call_tones_glitch_start(struct call_tones* t) { |
||||
if (!t || t->mode == TONES_GLITCH) return; |
||||
t->mode = TONES_GLITCH; |
||||
t->segs = GLITCH_SEGS; |
||||
t->seg_count = GLITCH_SEG_COUNT; |
||||
t->seg_idx = 0; |
||||
t->seg_pos = 0; |
||||
t->phase = 0.0; |
||||
} |
||||
|
||||
void call_tones_glitch_stop(struct call_tones* t) { |
||||
if (!t || t->mode != TONES_GLITCH) return; |
||||
t->mode = TONES_NONE; |
||||
t->segs = NULL; |
||||
t->seg_count = 0; |
||||
t->seg_idx = 0; |
||||
t->seg_pos = 0; |
||||
t->phase = 0.0; |
||||
} |
||||
|
||||
void call_tones_ended_start(struct call_tones* t) { |
||||
if (!t) return; |
||||
t->mode = TONES_ENDED; |
||||
t->segs = ENDED_SEGS; |
||||
t->seg_count = ENDED_SEG_COUNT; |
||||
t->seg_idx = 0; |
||||
t->seg_pos = 0; |
||||
t->ended_done = 0; |
||||
t->phase = 0.0; |
||||
} |
||||
|
||||
int call_tones_ended_pending(struct call_tones* t) { |
||||
return t && t->mode == TONES_ENDED && !t->ended_done; |
||||
} |
||||
|
||||
/* Перейти к следующему сегменту (или завершить тон). */ |
||||
static void tones_advance(struct call_tones* t) { |
||||
t->seg_idx++; |
||||
t->seg_pos = 0; |
||||
t->phase = 0.0; |
||||
if (t->seg_idx >= t->seg_count) { |
||||
if (t->mode == TONES_GLITCH) { |
||||
t->seg_idx = 0; /* цикл глитча */ |
||||
} else if (t->mode == TONES_ENDED) { |
||||
t->ended_done = 1; |
||||
t->mode = TONES_NONE; |
||||
t->segs = NULL; |
||||
} |
||||
} |
||||
} |
||||
|
||||
int call_tones_render(struct call_tones* t, int16_t* out, int max_samples) { |
||||
if (!t || !out || max_samples <= 0) return 0; |
||||
|
||||
int written = 0; |
||||
while (written < max_samples) { |
||||
if (t->mode == TONES_NONE) { |
||||
out[written++] = 0; /* тишина до конца запроса */ |
||||
continue; |
||||
} |
||||
|
||||
const struct tone_seg* s = &t->segs[t->seg_idx]; |
||||
int seg_len = s->dur_ms * TONES_SAMPLE_RATE / 1000; |
||||
int remain = seg_len - t->seg_pos; |
||||
int n = max_samples - written; |
||||
if (n > remain) n = remain; |
||||
|
||||
for (int i = 0; i < n; i++) { |
||||
int pos = t->seg_pos + i; |
||||
float v = 0.0f; |
||||
if (s->tone) { |
||||
v = (float)sin(t->phase) * TONES_AMPLITUDE * 32767.0f; |
||||
t->phase += 2.0 * M_PI * (double)s->freq_hz / TONES_SAMPLE_RATE; |
||||
/* fade in/out по краям сегмента (без кликов) */ |
||||
int fade = pos < TONES_FADE_SAMPLES ? pos |
||||
: (seg_len - pos - 1) < TONES_FADE_SAMPLES ? (seg_len - pos - 1) |
||||
: TONES_FADE_SAMPLES; |
||||
if (fade < TONES_FADE_SAMPLES) |
||||
v *= (float)fade / (float)TONES_FADE_SAMPLES; |
||||
} |
||||
int iv = (int)v; |
||||
out[written + i] = (int16_t)(iv > 32767 ? 32767 : (iv < -32768 ? -32768 : iv)); |
||||
} |
||||
written += n; |
||||
t->seg_pos += n; |
||||
if (t->seg_pos >= seg_len) tones_advance(t); |
||||
} |
||||
return written; |
||||
} |
||||
@ -0,0 +1,32 @@
|
||||
#ifndef CALL_TONES_H |
||||
#define CALL_TONES_H |
||||
|
||||
// call_tones.h — генератор тонов звонка (48kHz mono int16): глитч при сталле
|
||||
// и тон завершения. Используется только аудио-потоком (single-writer).
|
||||
|
||||
#include <stdint.h> |
||||
|
||||
#ifdef __cplusplus |
||||
extern "C" { |
||||
#endif |
||||
|
||||
struct call_tones; |
||||
|
||||
struct call_tones* call_tones_create(void); |
||||
void call_tones_destroy(struct call_tones* t); |
||||
void call_tones_reset(struct call_tones* t); /* в тишину */ |
||||
|
||||
void call_tones_glitch_start(struct call_tones* t); /* запустить/продолжить цикл глитча */ |
||||
void call_tones_glitch_stop(struct call_tones* t); /* прекратить глитч (тишина) */ |
||||
|
||||
void call_tones_ended_start(struct call_tones* t); /* 3 нисходящих тона, один раз */ |
||||
int call_tones_ended_pending(struct call_tones* t); /* 1 = тон завершения ещё звучит */ |
||||
|
||||
/* Выдать до max_samples сэмплов (тон или тишина). Возвращает записанное число. */ |
||||
int call_tones_render(struct call_tones* t, int16_t* out, int max_samples); |
||||
|
||||
#ifdef __cplusplus |
||||
} |
||||
#endif |
||||
|
||||
#endif /* CALL_TONES_H */ |
||||
@ -0,0 +1,465 @@
|
||||
// test_call.c — интеграционный тест P2P аудио-звонка (сигналинг).
|
||||
//
|
||||
// Однопоточный: 2 узла (A=caller, B=callee) в одном процессе, один UASYNC.
|
||||
// Прямое соединение A←B, оба в CHAT-группе. Master state-machine на таймере.
|
||||
//
|
||||
// Сценарий:
|
||||
// 1. happy path: A call_start → B INCOMING → B accept → обе ACTIVE (ACCEPTED)
|
||||
// → A hangup → обе ENDED.
|
||||
// 2. decline: A call_start → B INCOMING → B decline → обе ENDED(DECLINE).
|
||||
|
||||
#include "chat_core.h" |
||||
#include "chat_sync.h" |
||||
#include "chat_event.h" |
||||
#include "member_sync.h" |
||||
#include "../call/call.h" |
||||
#include "../call/call_proto.h" |
||||
#include "../routing_layer/topo_node_sqlite.h" |
||||
#include "../routing_layer/topo_group.h" |
||||
#include "../routing_layer/topo_node.h" |
||||
#include "../utun_instance.h" |
||||
#include "../transport_layer/etcp.h" |
||||
#include "../transport_layer/secure_channel.h" |
||||
#include "../ntp_time.h" |
||||
#include "../lib/u_async.h" |
||||
#include "../lib/debug_config.h" |
||||
#include "../lib/mem.h" |
||||
#include "../lib/platform_compat.h" |
||||
|
||||
#include <sqlite3.h> |
||||
#define OPENSSL_API_COMPAT 0x10100000L |
||||
#include <openssl/evp.h> |
||||
#include <stdio.h> |
||||
#include <stdlib.h> |
||||
#include <string.h> |
||||
#include <stdarg.h> |
||||
#include <time.h> |
||||
|
||||
#define CH_ID "4242424242424242" |
||||
|
||||
#define IDX_A 0 |
||||
#define IDX_B 1 |
||||
|
||||
#define TICK_TB 100 /* 10 ms на такт */ |
||||
#define MAX_TICKS 3000 /* глобальный таймаут ≈ 30 c */ |
||||
|
||||
enum call_phase { |
||||
P_WAIT_CONN, |
||||
P_SETUP, |
||||
P_WAIT_BGP, |
||||
P_CALL1_START, /* A: call_start */ |
||||
P_WAIT_INCOMING1, /* B: INCOMING */ |
||||
P_ACCEPT, /* B: call_accept */ |
||||
P_WAIT_ACCEPTED, /* обе ACTIVE */ |
||||
P_SEND_MEDIA, /* A: call_send_media (тестовый Opus-кадр) */ |
||||
P_WAIT_MEDIA, /* B: media_recv_cb получил кадр */ |
||||
P_HANGUP, /* A: call_hangup */ |
||||
P_WAIT_ENDED1, /* обе ENDED */ |
||||
P_CALL2_START, /* A: call_start (2-й звонок) */ |
||||
P_WAIT_INCOMING2, /* B: INCOMING */ |
||||
P_DECLINE, /* B: call_decline */ |
||||
P_WAIT_ENDED2, /* обе ENDED + A DECLINED */ |
||||
P_DONE, |
||||
}; |
||||
|
||||
struct call_ctx { |
||||
struct UASYNC* ua; |
||||
struct UTUN_INSTANCE* inst[2]; |
||||
enum call_phase phase; |
||||
int result; /* 0=running, 1=ok, 2=fail */ |
||||
int ticks; |
||||
void* timer; |
||||
uint64_t call_id; /* текущий call_id */ |
||||
}; |
||||
|
||||
/* записи событий CHAT_EVT_CALL_* на узел */ |
||||
struct call_evt { |
||||
int incoming, ringing, accepted, ended, declined; |
||||
uint64_t incoming_call_id; |
||||
uint64_t accepted_call_id; |
||||
uint64_t ended_call_id; |
||||
uint8_t ended_reason; |
||||
}; |
||||
|
||||
static struct call_evt g_ev[2]; /* [A], [B] */ |
||||
|
||||
/* записи принятых медиа-кадров (media_recv_cb) */ |
||||
struct media_evt { |
||||
int frames; |
||||
uint64_t last_call_id; |
||||
int last_len; |
||||
uint8_t last[64]; |
||||
}; |
||||
static struct media_evt g_media[2]; |
||||
|
||||
static void media_recv_cb(struct UTUN_INSTANCE* inst, uint64_t call_id, |
||||
const uint8_t* opus, int len, void* arg) { |
||||
(void)inst; |
||||
struct media_evt* m = (struct media_evt*)arg; |
||||
if (!m) return; |
||||
m->frames++; |
||||
m->last_call_id = call_id; |
||||
m->last_len = len; |
||||
if (len > 0) memcpy(m->last, opus, (size_t)(len < 64 ? len : 64)); |
||||
} |
||||
|
||||
struct call_shared { |
||||
uint8_t ch_x_pub[32], ch_ed_pub[32], ch_ed_priv[32], ch_sig[64]; |
||||
uint64_t nid[2]; |
||||
uint8_t x_pub[2][32], x_priv[2][32], ed_pub[2][32]; |
||||
int port[2]; |
||||
}; |
||||
static struct call_shared g_sh; |
||||
|
||||
static int G_PASSED = 0, G_FAILED = 0, G_TOTAL = 0; |
||||
#define TEST(n) do { G_TOTAL++; printf(" %-58s", n); fflush(stdout); } while(0) |
||||
#define OK() do { G_PASSED++; printf("OK\n"); } while(0) |
||||
#define FAIL(f,...) do { G_FAILED++; printf("FAIL: " f "\n", ##__VA_ARGS__); } while(0) |
||||
|
||||
/* ── крипто-хелперы ── */ |
||||
|
||||
static void gen_x25519(uint8_t pub[32], uint8_t priv[32]) { |
||||
EVP_PKEY_CTX* ctx = EVP_PKEY_CTX_new_id(EVP_PKEY_X25519, NULL); |
||||
EVP_PKEY* pkey = NULL; |
||||
EVP_PKEY_keygen_init(ctx); EVP_PKEY_keygen(ctx, &pkey); EVP_PKEY_CTX_free(ctx); |
||||
size_t l = 32; EVP_PKEY_get_raw_public_key(pkey, pub, &l); |
||||
l = 32; EVP_PKEY_get_raw_private_key(pkey, priv, &l); |
||||
EVP_PKEY_free(pkey); |
||||
} |
||||
|
||||
static void gen_ed25519(uint8_t pub[32], uint8_t priv[32]) { |
||||
EVP_PKEY_CTX* ctx = EVP_PKEY_CTX_new_id(EVP_PKEY_ED25519, NULL); |
||||
EVP_PKEY* pkey = NULL; |
||||
EVP_PKEY_keygen_init(ctx); EVP_PKEY_keygen(ctx, &pkey); EVP_PKEY_CTX_free(ctx); |
||||
size_t l = 32; EVP_PKEY_get_raw_public_key(pkey, pub, &l); |
||||
l = 32; EVP_PKEY_get_raw_private_key(pkey, priv, &l); |
||||
EVP_PKEY_free(pkey); |
||||
} |
||||
|
||||
static void to_hex(const uint8_t* bin, size_t n, char* out) { |
||||
for (size_t i = 0; i < n; i++) sprintf(out + i * 2, "%02x", bin[i]); |
||||
out[n * 2] = '\0'; |
||||
} |
||||
|
||||
static int wf(const char* p, const char* f, ...) { |
||||
va_list ap; FILE* fp = fopen(p, "w"); if (!fp) return -1; |
||||
va_start(ap, f); vfprintf(fp, f, ap); va_end(ap); fclose(fp); return 0; |
||||
} |
||||
|
||||
/* ── генерация данных сценария + конфигов ── */ |
||||
|
||||
static void fill_shared(int base_port) { |
||||
memset(&g_sh, 0, sizeof(g_sh)); |
||||
|
||||
for (int i = 0; i < 2; i++) { |
||||
gen_x25519(g_sh.x_pub[i], g_sh.x_priv[i]); |
||||
sc_derive_ed25519_pubkey(g_sh.x_priv[i], g_sh.ed_pub[i]); |
||||
g_sh.nid[i] = sc_derive_node_id_from_pubkey(g_sh.x_pub[i]); |
||||
g_sh.port[i] = base_port + i * 1000; |
||||
} |
||||
|
||||
{ uint8_t ch_x_priv[32]; gen_x25519(g_sh.ch_x_pub, ch_x_priv); } |
||||
gen_ed25519(g_sh.ch_ed_pub, g_sh.ch_ed_priv); |
||||
|
||||
{ |
||||
uint8_t msg[256]; size_t off = 0; |
||||
off += (size_t)snprintf((char*)msg + off, sizeof(msg) - off, "%s", CH_ID) + 1; |
||||
off += (size_t)snprintf((char*)msg + off, sizeof(msg) - off, "%s", "call-test") + 1; |
||||
memcpy(msg + off, &g_sh.nid[IDX_A], 8); off += 8; |
||||
memcpy(msg + off, g_sh.ch_x_pub, 32); off += 32; |
||||
memcpy(msg + off, g_sh.ch_ed_pub, 32); off += 32; |
||||
sc_ed25519_sign(g_sh.ch_ed_priv, msg, off, g_sh.ch_sig); |
||||
} |
||||
} |
||||
|
||||
static void write_configs(const char* dir) { |
||||
for (int i = 0; i < 2; i++) { |
||||
char priv[65], pub[65], path[512], dbd[512]; |
||||
to_hex(g_sh.x_priv[i], 32, priv); |
||||
to_hex(g_sh.x_pub[i], 32, pub); |
||||
snprintf(path, sizeof(path), "%s/%s.conf", dir, i == IDX_A ? "a" : "b"); |
||||
snprintf(dbd, sizeof(dbd), "%s/db%s", dir, i == IDX_A ? "a" : "b"); |
||||
utun_mkdir(dbd, 0755); |
||||
|
||||
char client[512] = ""; |
||||
if (i == IDX_B) { |
||||
/* B → A (клиент к A) */ |
||||
char apub[65]; to_hex(g_sh.x_pub[IDX_A], 32, apub); |
||||
snprintf(client, sizeof(client), |
||||
"[client: to_a]\npeer_public_key=%s\nlink=s1:127.0.0.1:%d\n", |
||||
apub, g_sh.port[IDX_A]); |
||||
} |
||||
|
||||
wf(path, |
||||
"[global]\ntun_ip=10.97.%d.1/24\ntun_ifname=tun%d0\ndb_path=%s/db%s\n" |
||||
"my_private_key=%s\nmy_public_key=%s\n" |
||||
"[server: s1]\naddr=127.0.0.1:%d\ntype=public\n" |
||||
"%s" |
||||
"[chatserver]\nstorage_autoload=0\n[allowed_keys]\nallow_all=1\n", |
||||
i, i, dir, i == IDX_A ? "a" : "b", |
||||
priv, pub, g_sh.port[i], client); |
||||
} |
||||
} |
||||
|
||||
/* ── событийный обработчик ── */ |
||||
|
||||
static void call_evt_handler(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, int len) { |
||||
struct call_evt* ev = (struct call_evt*)inst->test_user_ptr; |
||||
if (!ev) return; |
||||
switch (type) { |
||||
case CHAT_EVT_CALL_INCOMING: { |
||||
if (len < 1 + 8 + 8) return; |
||||
int ch_len = data[0]; |
||||
if (len < 1 + ch_len + 8 + 8) return; |
||||
ev->incoming = 1; |
||||
memcpy(&ev->incoming_call_id, data + 1 + ch_len + 8, 8); |
||||
break; |
||||
} |
||||
case CHAT_EVT_CALL_RINGING: |
||||
ev->ringing = 1; |
||||
break; |
||||
case CHAT_EVT_CALL_ACCEPTED: |
||||
ev->accepted = 1; |
||||
if (len >= 8) memcpy(&ev->accepted_call_id, data, 8); |
||||
break; |
||||
case CHAT_EVT_CALL_ENDED: |
||||
ev->ended = 1; |
||||
if (len >= 9) { memcpy(&ev->ended_call_id, data, 8); ev->ended_reason = data[8]; } |
||||
break; |
||||
case CHAT_EVT_CALL_DECLINED: |
||||
ev->declined = 1; |
||||
break; |
||||
default: |
||||
break; |
||||
} |
||||
} |
||||
|
||||
/* ── проверки ── */ |
||||
|
||||
static int conn_up(struct UTUN_INSTANCE* inst, uint64_t nid) { |
||||
struct ETCP_CONN* c = instance_find_conn(inst, nid); |
||||
return c && c->initialized && c->links_up; |
||||
} |
||||
|
||||
static int bgp_has(struct UTUN_INSTANCE* inst, uint64_t nid) { |
||||
uint64_t gid = strtoull(CH_ID, NULL, 10); |
||||
struct TOPO_GROUP* g = topo_groups_find(inst->topo_groups, gid); |
||||
if (!g) return 0; |
||||
struct TOPO_GROUP_NODE* nq = topo_node_find_by_id(g, nid); |
||||
return nq && nq->paths && nq->paths->head; |
||||
} |
||||
|
||||
/* ── настройка канала и мемберов ── */ |
||||
|
||||
static void channel_put_shared(struct UTUN_INSTANCE* inst, int has_priv) { |
||||
topo_node_sqlite_channel_put(inst->topo_sqlite_db, CH_ID, "call-test", g_sh.nid[IDX_A], |
||||
g_sh.ch_x_pub, NULL, g_sh.ch_ed_pub, has_priv ? g_sh.ch_ed_priv : NULL, g_sh.ch_sig); |
||||
chat_core_ensure_channel_ready(inst, CH_ID); |
||||
} |
||||
|
||||
static void member_put_self(struct UTUN_INSTANCE* inst, const char* name) { |
||||
uint64_t join_ts = (uint64_t)ntp_time_get_seconds(inst); |
||||
uint8_t jm[128]; |
||||
int jl = member_sync_build_join_msg(g_sh.ch_x_pub, g_sh.ch_ed_pub, inst->node_id, |
||||
inst->my_keys.public_key, join_ts, jm, (int)sizeof(jm)); |
||||
uint8_t join_sig[64]; sc_ed25519_sign(inst->my_ed25519_privkey, jm, (size_t)jl, join_sig); |
||||
char userinfo[256]; snprintf(userinfo, sizeof(userinfo), "{\"name\":\"%s\"}", name); |
||||
member_sync_put(inst, CH_ID, inst->node_id, inst->my_keys.public_key, inst->my_ed25519_pubkey, |
||||
join_sig, join_ts, NULL, 0, userinfo, NULL, NULL, 0, 0, NULL); |
||||
} |
||||
|
||||
static void member_put_placeholder(struct UTUN_INSTANCE* inst, int idx, const char* name) { |
||||
char userinfo[256]; snprintf(userinfo, sizeof(userinfo), "{\"name\":\"%s\"}", name); |
||||
member_sync_put(inst, CH_ID, g_sh.nid[idx], g_sh.x_pub[idx], g_sh.ed_pub[idx], |
||||
NULL, 0, NULL, 0, userinfo, NULL, NULL, 0, 0, NULL); |
||||
} |
||||
|
||||
static void do_setup(struct UTUN_INSTANCE* inst, int role) { |
||||
switch (role) { |
||||
case IDX_A: |
||||
channel_put_shared(inst, 1); |
||||
member_put_self(inst, "A"); |
||||
member_put_placeholder(inst, IDX_B, "B"); |
||||
member_sync_start(inst, g_sh.nid[IDX_B], CH_ID, NULL, NULL); |
||||
break; |
||||
case IDX_B: |
||||
channel_put_shared(inst, 0); |
||||
member_put_self(inst, "B"); |
||||
member_put_placeholder(inst, IDX_A, "A"); |
||||
member_sync_start(inst, g_sh.nid[IDX_A], CH_ID, NULL, NULL); |
||||
break; |
||||
} |
||||
} |
||||
|
||||
/* ── master state-machine ── */ |
||||
|
||||
static void call_tick(void* arg) { |
||||
struct call_ctx* t = arg; |
||||
t->timer = NULL; |
||||
if (t->result) return; |
||||
|
||||
struct UTUN_INSTANCE* A = t->inst[IDX_A]; |
||||
struct UTUN_INSTANCE* B = t->inst[IDX_B]; |
||||
uint64_t na = g_sh.nid[IDX_A], nb = g_sh.nid[IDX_B]; |
||||
struct call_evt* ea = &g_ev[IDX_A]; |
||||
struct call_evt* eb = &g_ev[IDX_B]; |
||||
|
||||
switch (t->phase) { |
||||
case P_WAIT_CONN: |
||||
if (conn_up(A, nb) && conn_up(B, na)) t->phase = P_SETUP; |
||||
break; |
||||
case P_SETUP: |
||||
do_setup(A, IDX_A); |
||||
do_setup(B, IDX_B); |
||||
t->phase = P_WAIT_BGP; |
||||
break; |
||||
case P_WAIT_BGP: |
||||
if (bgp_has(A, nb) && bgp_has(B, na)) t->phase = P_CALL1_START; |
||||
break; |
||||
case P_CALL1_START: |
||||
memset(ea, 0, sizeof(*ea)); |
||||
memset(eb, 0, sizeof(*eb)); |
||||
t->call_id = ((uint64_t)1 << 40) | (uint64_t)(rand() & 0xFFFFF); |
||||
call_start(A, CH_ID, nb, t->call_id); |
||||
t->phase = P_WAIT_INCOMING1; |
||||
break; |
||||
case P_WAIT_INCOMING1: |
||||
if (eb->incoming && eb->incoming_call_id == t->call_id) t->phase = P_ACCEPT; |
||||
break; |
||||
case P_ACCEPT: |
||||
call_accept(B, t->call_id); |
||||
t->phase = P_WAIT_ACCEPTED; |
||||
break; |
||||
case P_WAIT_ACCEPTED: |
||||
if (ea->accepted && eb->accepted && ea->accepted_call_id == t->call_id) t->phase = P_SEND_MEDIA; |
||||
break; |
||||
case P_SEND_MEDIA: { |
||||
/* тестовый «Opus-кадр» — релей произвольных байт, не валидируем кодек */ |
||||
static const uint8_t opus[16] = { 0xf8, 0xff, 0xfe, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d }; |
||||
if (call_send_media(A, t->call_id, opus, sizeof(opus)) != 0) { |
||||
fprintf(stderr, "A: call_send_media failed\n"); |
||||
t->result = 2; |
||||
} else { |
||||
t->phase = P_WAIT_MEDIA; |
||||
} |
||||
break; |
||||
} |
||||
case P_WAIT_MEDIA: |
||||
if (g_media[IDX_B].frames > 0 && g_media[IDX_B].last_call_id == t->call_id |
||||
&& g_media[IDX_B].last_len == 16) t->phase = P_HANGUP; |
||||
break; |
||||
case P_HANGUP: |
||||
call_hangup(A, t->call_id); |
||||
t->phase = P_WAIT_ENDED1; |
||||
break; |
||||
case P_WAIT_ENDED1: |
||||
if (ea->ended && eb->ended && ea->ended_call_id == t->call_id) t->phase = P_CALL2_START; |
||||
break; |
||||
case P_CALL2_START: |
||||
memset(ea, 0, sizeof(*ea)); |
||||
memset(eb, 0, sizeof(*eb)); |
||||
t->call_id = ((uint64_t)2 << 40) | (uint64_t)(rand() & 0xFFFFF); |
||||
call_start(A, CH_ID, nb, t->call_id); |
||||
t->phase = P_WAIT_INCOMING2; |
||||
break; |
||||
case P_WAIT_INCOMING2: |
||||
if (eb->incoming && eb->incoming_call_id == t->call_id) t->phase = P_DECLINE; |
||||
break; |
||||
case P_DECLINE: |
||||
call_decline(B, t->call_id); |
||||
t->phase = P_WAIT_ENDED2; |
||||
break; |
||||
case P_WAIT_ENDED2: |
||||
if (ea->ended && eb->ended && ea->declined) t->phase = P_DONE; |
||||
break; |
||||
case P_DONE: |
||||
t->result = 1; |
||||
uasync_stop(t->ua); |
||||
return; |
||||
} |
||||
|
||||
if (t->phase == P_DONE) { t->result = 1; uasync_stop(t->ua); return; } |
||||
|
||||
if (++t->ticks > MAX_TICKS) { |
||||
fprintf(stderr, "test: timeout in phase %d (A_conn=%d B_conn=%d evA:inc=%d acc=%d end=%d dec=%d evB:inc=%d acc=%d end=%d dec=%d)\n", |
||||
(int)t->phase, conn_up(A, nb), conn_up(B, na), |
||||
ea->incoming, ea->accepted, ea->ended, ea->declined, |
||||
eb->incoming, eb->accepted, eb->ended, eb->declined); |
||||
t->result = 2; |
||||
uasync_stop(t->ua); |
||||
return; |
||||
} |
||||
t->timer = uasync_set_timeout(t->ua, TICK_TB, t, call_tick, "call_tick"); |
||||
} |
||||
|
||||
/* ── main ── */ |
||||
|
||||
int main(int argc, char** argv) { |
||||
(void)argc; (void)argv; |
||||
debug_config_init(); debug_set_level(DEBUG_LEVEL_ERROR); |
||||
if (getenv("UTUN_TEST_DEBUG")) { |
||||
debug_set_category_level_by_name("call", "trace"); |
||||
debug_set_category_level_by_name("bgp", "info"); |
||||
debug_set_category_level_by_name("etcp_route", "info"); |
||||
debug_set_category_level_by_name("member_sync", "info"); |
||||
debug_set_category_level_by_name("chat_sync", "info"); |
||||
} |
||||
utun_instance_set_tun_init_enabled(0); |
||||
srand((unsigned)time(NULL)); |
||||
|
||||
char dir[512]; snprintf(dir, sizeof(dir), "/tmp/utun_call_XXXXXX"); |
||||
if (!mkdtemp(dir)) { fprintf(stderr, "mkdtemp failed\n"); return 1; } |
||||
setenv("UTUN_TEST_DIR", dir, 1); |
||||
|
||||
fill_shared(56000 + (getpid() % 2000)); |
||||
write_configs(dir); |
||||
|
||||
printf("=== test_call ===\n"); fflush(stdout); |
||||
|
||||
TEST("single-thread: call happy path + decline (2 nodes)"); { |
||||
struct UASYNC* ua = uasync_create(); |
||||
if (!ua) { FAIL("uasync_create failed"); return 1; } |
||||
|
||||
struct call_ctx t; |
||||
memset(&t, 0, sizeof(t)); |
||||
t.ua = ua; |
||||
t.phase = P_WAIT_CONN; |
||||
|
||||
char cfgA[512], cfgB[512]; |
||||
snprintf(cfgA, sizeof(cfgA), "%s/a.conf", dir); |
||||
snprintf(cfgB, sizeof(cfgB), "%s/b.conf", dir); |
||||
|
||||
t.inst[IDX_A] = utun_instance_create(ua, cfgA); |
||||
t.inst[IDX_B] = utun_instance_create(ua, cfgB); |
||||
if (!t.inst[IDX_A] || !t.inst[IDX_B]) { |
||||
FAIL("instance create failed A=%p B=%p", (void*)t.inst[IDX_A], (void*)t.inst[IDX_B]); |
||||
uasync_destroy(ua, 0); |
||||
return 1; |
||||
} |
||||
utun_instance_init(t.inst[IDX_A]); |
||||
utun_instance_init(t.inst[IDX_B]); |
||||
|
||||
t.inst[IDX_A]->test_user_ptr = &g_ev[IDX_A]; |
||||
t.inst[IDX_B]->test_user_ptr = &g_ev[IDX_B]; |
||||
chat_event_set_handler(t.inst[IDX_A], call_evt_handler); |
||||
chat_event_set_handler(t.inst[IDX_B], call_evt_handler); |
||||
call_set_media_recv_cb(t.inst[IDX_A], media_recv_cb, &g_media[IDX_A]); |
||||
call_set_media_recv_cb(t.inst[IDX_B], media_recv_cb, &g_media[IDX_B]); |
||||
|
||||
t.timer = uasync_set_timeout(ua, TICK_TB, &t, call_tick, "call_tick"); |
||||
uasync_mainloop(ua); |
||||
|
||||
int ok = (t.result == 1); |
||||
|
||||
if (t.inst[IDX_A]) utun_instance_destroy(t.inst[IDX_A]); |
||||
if (t.inst[IDX_B]) utun_instance_destroy(t.inst[IDX_B]); |
||||
uasync_destroy(ua, 0); |
||||
|
||||
if (ok) OK(); else FAIL("result=%d phase=%d", t.result, (int)t.phase); |
||||
} |
||||
|
||||
char cmd[512]; snprintf(cmd, sizeof(cmd), "rm -rf %s", dir); (void)!system(cmd); |
||||
|
||||
printf("\n%d/%d passed, %d failed\n", G_PASSED, G_TOTAL, G_FAILED); |
||||
return G_FAILED > 0 ? 1 : 0; |
||||
} |
||||
@ -0,0 +1,512 @@
|
||||
// test_call_headless.c — полноценный e2e-тест звонка через headless API.
|
||||
//
|
||||
// Два узла (A=caller, B=callee) в одном процессе, один UASYNC. Тест выступает
|
||||
// «headless-клиентом» обоих узлов: подключается к control-сокетам (JSON-команды
|
||||
// и события) и аудио-сокетам (бинарные Opus-фреймы) каждого узла.
|
||||
//
|
||||
// Сценарий (через реальные TCP-сокеты):
|
||||
// A: call_start → B: call_incoming → B: call_accept → обе: call_accepted
|
||||
// A: audio HELLO+FRAME → B: audio FRAME (релей медиа)
|
||||
// A: call_hangup → обе: call_ended
|
||||
|
||||
#include "chat_core.h" |
||||
#include "chat_sync.h" |
||||
#include "chat_event.h" |
||||
#include "member_sync.h" |
||||
#include "chat_headless_control.h" |
||||
#include "../call/call.h" |
||||
#include "../call/call_headless.h" |
||||
#include "../call/call_proto.h" |
||||
#include "../routing_layer/topo_node_sqlite.h" |
||||
#include "../routing_layer/topo_group.h" |
||||
#include "../routing_layer/topo_node.h" |
||||
#include "../utun_instance.h" |
||||
#include "../transport_layer/etcp.h" |
||||
#include "../transport_layer/secure_channel.h" |
||||
#include "../ntp_time.h" |
||||
#include "../lib/u_async.h" |
||||
#include "../lib/debug_config.h" |
||||
#include "../lib/mem.h" |
||||
#include "../lib/socket_compat.h" |
||||
#include "../lib/platform_compat.h" |
||||
|
||||
#include <sqlite3.h> |
||||
#define OPENSSL_API_COMPAT 0x10100000L |
||||
#include <openssl/evp.h> |
||||
#include <stdio.h> |
||||
#include <stdlib.h> |
||||
#include <string.h> |
||||
#include <stdarg.h> |
||||
#include <time.h> |
||||
#include <errno.h> |
||||
|
||||
#define CH_ID "4242424242424242" |
||||
|
||||
#define IDX_A 0 |
||||
#define IDX_B 1 |
||||
|
||||
#define TICK_TB 100 /* 10 ms на такт */ |
||||
#define MAX_TICKS 4000 /* глобальный таймаут ≈ 40 c */ |
||||
|
||||
enum hs_phase { |
||||
P_WAIT_CONN, |
||||
P_SETUP, /* канал/мемберы + headless control/audio на A и B */ |
||||
P_WAIT_BGP, |
||||
P_CONNECT_CLIENTS, /* подключить 4 клиентских сокета */ |
||||
P_SUBSCRIBE, /* subscribe на обоих control */ |
||||
P_CALL_START, /* A: call_start */ |
||||
P_WAIT_INCOMING, /* B: call_incoming */ |
||||
P_ACCEPT, /* B: call_accept */ |
||||
P_WAIT_ACCEPTED, /* обе: call_accepted */ |
||||
P_BIND_AUDIO, /* HELLO на обоих audio */ |
||||
P_SEND_MEDIA, /* A: audio FRAME */ |
||||
P_WAIT_MEDIA, /* B: audio FRAME получен */ |
||||
P_HANGUP, /* A: call_hangup */ |
||||
P_WAIT_ENDED, /* обе: call_ended */ |
||||
P_DONE, |
||||
}; |
||||
|
||||
/* клиентский сокет (к control или audio узла) */ |
||||
struct hs_conn { |
||||
socket_t fd; |
||||
void* socket_id; |
||||
char buf[16384]; |
||||
int len; |
||||
}; |
||||
|
||||
struct hs_ctx { |
||||
struct UASYNC* ua; |
||||
struct UTUN_INSTANCE* inst[2]; |
||||
enum hs_phase phase; |
||||
int result; |
||||
int ticks; |
||||
void* timer; |
||||
uint64_t call_id; |
||||
struct hs_conn ctl[2]; /* control-клиенты A, B */ |
||||
struct hs_conn aud[2]; /* audio-клиенты A, B */ |
||||
}; |
||||
|
||||
struct hs_shared { |
||||
uint8_t ch_x_pub[32], ch_ed_pub[32], ch_ed_priv[32], ch_sig[64]; |
||||
uint64_t nid[2]; |
||||
uint8_t x_pub[2][32], x_priv[2][32], ed_pub[2][32]; |
||||
int port[2]; /* etcp-порты */ |
||||
}; |
||||
static struct hs_shared g_sh; |
||||
|
||||
static int G_PASSED = 0, G_FAILED = 0, G_TOTAL = 0; |
||||
#define TEST(n) do { G_TOTAL++; printf(" %-58s", n); fflush(stdout); } while(0) |
||||
#define OK() do { G_PASSED++; printf("OK\n"); } while(0) |
||||
#define FAIL(f,...) do { G_FAILED++; printf("FAIL: " f "\n", ##__VA_ARGS__); } while(0) |
||||
|
||||
/* ── крипто-хелперы ── */ |
||||
|
||||
static void gen_x25519(uint8_t pub[32], uint8_t priv[32]) { |
||||
EVP_PKEY_CTX* ctx = EVP_PKEY_CTX_new_id(EVP_PKEY_X25519, NULL); |
||||
EVP_PKEY* pkey = NULL; |
||||
EVP_PKEY_keygen_init(ctx); EVP_PKEY_keygen(ctx, &pkey); EVP_PKEY_CTX_free(ctx); |
||||
size_t l = 32; EVP_PKEY_get_raw_public_key(pkey, pub, &l); |
||||
l = 32; EVP_PKEY_get_raw_private_key(pkey, priv, &l); |
||||
EVP_PKEY_free(pkey); |
||||
} |
||||
|
||||
static void gen_ed25519(uint8_t pub[32], uint8_t priv[32]) { |
||||
EVP_PKEY_CTX* ctx = EVP_PKEY_CTX_new_id(EVP_PKEY_ED25519, NULL); |
||||
EVP_PKEY* pkey = NULL; |
||||
EVP_PKEY_keygen_init(ctx); EVP_PKEY_keygen(ctx, &pkey); EVP_PKEY_CTX_free(ctx); |
||||
size_t l = 32; EVP_PKEY_get_raw_public_key(pkey, pub, &l); |
||||
l = 32; EVP_PKEY_get_raw_private_key(pkey, priv, &l); |
||||
EVP_PKEY_free(pkey); |
||||
} |
||||
|
||||
static void to_hex(const uint8_t* bin, size_t n, char* out) { |
||||
for (size_t i = 0; i < n; i++) sprintf(out + i * 2, "%02x", bin[i]); |
||||
out[n * 2] = '\0'; |
||||
} |
||||
|
||||
static int wf(const char* p, const char* f, ...) { |
||||
va_list ap; FILE* fp = fopen(p, "w"); if (!fp) return -1; |
||||
va_start(ap, f); vfprintf(fp, f, ap); va_end(ap); fclose(fp); return 0; |
||||
} |
||||
|
||||
static void fill_shared(int base_port) { |
||||
memset(&g_sh, 0, sizeof(g_sh)); |
||||
for (int i = 0; i < 2; i++) { |
||||
gen_x25519(g_sh.x_pub[i], g_sh.x_priv[i]); |
||||
sc_derive_ed25519_pubkey(g_sh.x_priv[i], g_sh.ed_pub[i]); |
||||
g_sh.nid[i] = sc_derive_node_id_from_pubkey(g_sh.x_pub[i]); |
||||
g_sh.port[i] = base_port + i * 1000; |
||||
} |
||||
{ uint8_t ch_x_priv[32]; gen_x25519(g_sh.ch_x_pub, ch_x_priv); } |
||||
gen_ed25519(g_sh.ch_ed_pub, g_sh.ch_ed_priv); |
||||
{ |
||||
uint8_t msg[256]; size_t off = 0; |
||||
off += (size_t)snprintf((char*)msg + off, sizeof(msg) - off, "%s", CH_ID) + 1; |
||||
off += (size_t)snprintf((char*)msg + off, sizeof(msg) - off, "%s", "hl-call") + 1; |
||||
memcpy(msg + off, &g_sh.nid[IDX_A], 8); off += 8; |
||||
memcpy(msg + off, g_sh.ch_x_pub, 32); off += 32; |
||||
memcpy(msg + off, g_sh.ch_ed_pub, 32); off += 32; |
||||
sc_ed25519_sign(g_sh.ch_ed_priv, msg, off, g_sh.ch_sig); |
||||
} |
||||
} |
||||
|
||||
static void write_configs(const char* dir) { |
||||
for (int i = 0; i < 2; i++) { |
||||
char priv[65], pub[65], path[512], dbd[512]; |
||||
to_hex(g_sh.x_priv[i], 32, priv); |
||||
to_hex(g_sh.x_pub[i], 32, pub); |
||||
snprintf(path, sizeof(path), "%s/%s.conf", dir, i == IDX_A ? "a" : "b"); |
||||
snprintf(dbd, sizeof(dbd), "%s/db%s", dir, i == IDX_A ? "a" : "b"); |
||||
utun_mkdir(dbd, 0755); |
||||
|
||||
char client[512] = ""; |
||||
if (i == IDX_B) { |
||||
char apub[65]; to_hex(g_sh.x_pub[IDX_A], 32, apub); |
||||
snprintf(client, sizeof(client), |
||||
"[client: to_a]\npeer_public_key=%s\nlink=s1:127.0.0.1:%d\n", |
||||
apub, g_sh.port[IDX_A]); |
||||
} |
||||
wf(path, |
||||
"[global]\ntun_ip=10.97.%d.1/24\ntun_ifname=tun%d0\ndb_path=%s/db%s\n" |
||||
"my_private_key=%s\nmy_public_key=%s\n" |
||||
"[server: s1]\naddr=127.0.0.1:%d\ntype=public\n" |
||||
"%s" |
||||
"[chatserver]\nstorage_autoload=0\n[allowed_keys]\nallow_all=1\n", |
||||
i, i, dir, i == IDX_A ? "a" : "b", |
||||
priv, pub, g_sh.port[i], client); |
||||
} |
||||
} |
||||
|
||||
/* ── проверки ── */ |
||||
|
||||
static int conn_up(struct UTUN_INSTANCE* inst, uint64_t nid) { |
||||
struct ETCP_CONN* c = instance_find_conn(inst, nid); |
||||
return c && c->initialized && c->links_up; |
||||
} |
||||
|
||||
static int bgp_has(struct UTUN_INSTANCE* inst, uint64_t nid) { |
||||
uint64_t gid = strtoull(CH_ID, NULL, 10); |
||||
struct TOPO_GROUP* g = topo_groups_find(inst->topo_groups, gid); |
||||
if (!g) return 0; |
||||
struct TOPO_GROUP_NODE* nq = topo_node_find_by_id(g, nid); |
||||
return nq && nq->paths && nq->paths->head; |
||||
} |
||||
|
||||
/* ── настройка канала и мемберов ── */ |
||||
|
||||
static void channel_put_shared(struct UTUN_INSTANCE* inst, int has_priv) { |
||||
topo_node_sqlite_channel_put(inst->topo_sqlite_db, CH_ID, "hl-call", g_sh.nid[IDX_A], |
||||
g_sh.ch_x_pub, NULL, g_sh.ch_ed_pub, has_priv ? g_sh.ch_ed_priv : NULL, g_sh.ch_sig); |
||||
chat_core_ensure_channel_ready(inst, CH_ID); |
||||
} |
||||
|
||||
static void member_put_self(struct UTUN_INSTANCE* inst, const char* name) { |
||||
uint64_t join_ts = (uint64_t)ntp_time_get_seconds(inst); |
||||
uint8_t jm[128]; |
||||
int jl = member_sync_build_join_msg(g_sh.ch_x_pub, g_sh.ch_ed_pub, inst->node_id, |
||||
inst->my_keys.public_key, join_ts, jm, (int)sizeof(jm)); |
||||
uint8_t join_sig[64]; sc_ed25519_sign(inst->my_ed25519_privkey, jm, (size_t)jl, join_sig); |
||||
char userinfo[256]; snprintf(userinfo, sizeof(userinfo), "{\"name\":\"%s\"}", name); |
||||
member_sync_put(inst, CH_ID, inst->node_id, inst->my_keys.public_key, inst->my_ed25519_pubkey, |
||||
join_sig, join_ts, NULL, 0, userinfo, NULL, NULL, 0, 0, NULL); |
||||
} |
||||
|
||||
static void member_put_placeholder(struct UTUN_INSTANCE* inst, int idx, const char* name) { |
||||
char userinfo[256]; snprintf(userinfo, sizeof(userinfo), "{\"name\":\"%s\"}", name); |
||||
member_sync_put(inst, CH_ID, g_sh.nid[idx], g_sh.x_pub[idx], g_sh.ed_pub[idx], |
||||
NULL, 0, NULL, 0, userinfo, NULL, NULL, 0, 0, NULL); |
||||
} |
||||
|
||||
static void do_setup(struct UTUN_INSTANCE* inst, int role) { |
||||
switch (role) { |
||||
case IDX_A: |
||||
channel_put_shared(inst, 1); |
||||
member_put_self(inst, "A"); |
||||
member_put_placeholder(inst, IDX_B, "B"); |
||||
member_sync_start(inst, g_sh.nid[IDX_B], CH_ID, NULL, NULL); |
||||
break; |
||||
case IDX_B: |
||||
channel_put_shared(inst, 0); |
||||
member_put_self(inst, "B"); |
||||
member_put_placeholder(inst, IDX_A, "A"); |
||||
member_sync_start(inst, g_sh.nid[IDX_A], CH_ID, NULL, NULL); |
||||
break; |
||||
} |
||||
} |
||||
|
||||
/* ── клиентские сокеты ── */ |
||||
|
||||
static void hs_recv_cb(socket_t fd, void* arg) { |
||||
struct hs_conn* c = (struct hs_conn*)arg; |
||||
(void)fd; |
||||
char tmp[4096]; |
||||
ssize_t r = recv(fd, tmp, sizeof(tmp) - 1, 0); |
||||
if (r <= 0) return; |
||||
if (c->len + (int)r < (int)sizeof(c->buf)) { |
||||
memcpy(c->buf + c->len, tmp, (size_t)r); |
||||
c->len += (int)r; |
||||
c->buf[c->len] = '\0'; |
||||
} |
||||
} |
||||
|
||||
static int hs_connect(struct hs_ctx* t, struct hs_conn* c, int port) { |
||||
c->fd = socket(AF_INET, SOCK_STREAM, 0); |
||||
if (c->fd == SOCKET_INVALID) return -1; |
||||
struct sockaddr_in sin; memset(&sin, 0, sizeof(sin)); |
||||
sin.sin_family = AF_INET; sin.sin_port = htons((uint16_t)port); |
||||
inet_pton(AF_INET, "127.0.0.1", &sin.sin_addr); |
||||
if (connect(c->fd, (struct sockaddr*)&sin, sizeof(sin)) < 0) { |
||||
socket_close_wrapper(c->fd); c->fd = SOCKET_INVALID; return -1; |
||||
} |
||||
socket_set_nonblocking(c->fd); |
||||
c->socket_id = uasync_add_socket_t(t->ua, c->fd, hs_recv_cb, NULL, NULL, c); |
||||
if (!c->socket_id) { socket_close_wrapper(c->fd); c->fd = SOCKET_INVALID; return -1; } |
||||
return 0; |
||||
} |
||||
|
||||
static void hs_send(struct hs_conn* c, const void* data, size_t len) { |
||||
if (!c || c->fd == SOCKET_INVALID) return; |
||||
ssize_t off = 0; |
||||
while (off < (ssize_t)len) { |
||||
ssize_t r = send(c->fd, (const char*)data + off, len - (size_t)off, 0); |
||||
if (r < 0 && (socket_get_error() == ERR_AGAIN || socket_get_error() == ERR_WOULDBLOCK)) { continue; } |
||||
if (r <= 0) break; |
||||
off += r; |
||||
} |
||||
} |
||||
|
||||
/* есть ли подстрока в буфере */ |
||||
static int hs_has(struct hs_conn* c, const char* substr) { |
||||
return c && strstr(c->buf, substr) != NULL; |
||||
} |
||||
|
||||
/* вытащить call_id из JSON-события (формат "call_id":"0x...") */ |
||||
static int hs_extract_call_id(struct hs_conn* c, const char* evname, uint64_t* out) { |
||||
if (!c) return -1; |
||||
const char* p = strstr(c->buf, evname); |
||||
if (!p) return -1; |
||||
p = strstr(p, "\"call_id\""); |
||||
if (!p) return -1; |
||||
p = strstr(p, "0x"); |
||||
if (!p) return -1; |
||||
*out = strtoull(p, NULL, 16); |
||||
return 0; |
||||
} |
||||
|
||||
/* ── master state-machine ── */ |
||||
|
||||
static void hs_tick(void* arg) { |
||||
struct hs_ctx* t = arg; |
||||
t->timer = NULL; |
||||
if (t->result) return; |
||||
|
||||
struct UTUN_INSTANCE* A = t->inst[IDX_A]; |
||||
struct UTUN_INSTANCE* B = t->inst[IDX_B]; |
||||
uint64_t na = g_sh.nid[IDX_A], nb = g_sh.nid[IDX_B]; |
||||
|
||||
switch (t->phase) { |
||||
case P_WAIT_CONN: |
||||
if (conn_up(A, nb) && conn_up(B, na)) t->phase = P_SETUP; |
||||
break; |
||||
case P_SETUP: |
||||
do_setup(A, IDX_A); |
||||
do_setup(B, IDX_B); |
||||
if (chat_headless_control_init(A->ua, A, "127.0.0.1", 9700 + (int)(getpid() % 100)) != 0 |
||||
|| chat_headless_control_init(B->ua, B, "127.0.0.1", 9800 + (int)(getpid() % 100)) != 0) { |
||||
fprintf(stderr, "headless control init failed\n"); t->result = 2; break; |
||||
} |
||||
if (call_headless_init(A->ua, A, "127.0.0.1", 9710 + (int)(getpid() % 100)) != 0 |
||||
|| call_headless_init(B->ua, B, "127.0.0.1", 9810 + (int)(getpid() % 100)) != 0) { |
||||
fprintf(stderr, "call headless audio init failed\n"); t->result = 2; break; |
||||
} |
||||
t->phase = P_WAIT_BGP; |
||||
break; |
||||
case P_WAIT_BGP: |
||||
if (bgp_has(A, nb) && bgp_has(B, na)) t->phase = P_CONNECT_CLIENTS; |
||||
break; |
||||
case P_CONNECT_CLIENTS: |
||||
if (hs_connect(t, &t->ctl[IDX_A], 9700 + (int)(getpid() % 100)) != 0 |
||||
|| hs_connect(t, &t->ctl[IDX_B], 9800 + (int)(getpid() % 100)) != 0 |
||||
|| hs_connect(t, &t->aud[IDX_A], 9710 + (int)(getpid() % 100)) != 0 |
||||
|| hs_connect(t, &t->aud[IDX_B], 9810 + (int)(getpid() % 100)) != 0) { |
||||
fprintf(stderr, "client connect failed\n"); t->result = 2; break; |
||||
} |
||||
t->phase = P_SUBSCRIBE; |
||||
break; |
||||
case P_SUBSCRIBE: { |
||||
const char* sub = "{\"id\":1,\"cmd\":\"subscribe\",\"enable\":1}\n"; |
||||
hs_send(&t->ctl[IDX_A], sub, strlen(sub)); |
||||
hs_send(&t->ctl[IDX_B], sub, strlen(sub)); |
||||
t->phase = P_CALL_START; |
||||
break; |
||||
} |
||||
case P_CALL_START: { |
||||
t->call_id = ((uint64_t)1 << 40) | (uint64_t)(rand() & 0xFFFFF); |
||||
char json[512]; |
||||
snprintf(json, sizeof(json), "{\"id\":2,\"cmd\":\"call_start\",\"ch\":\"%s\",\"node_id\":\"0x%016llx\"}\n", |
||||
CH_ID, (unsigned long long)nb); |
||||
hs_send(&t->ctl[IDX_A], json, strlen(json)); |
||||
t->phase = P_WAIT_INCOMING; |
||||
break; |
||||
} |
||||
case P_WAIT_INCOMING: { |
||||
uint64_t cid = 0; |
||||
if (hs_extract_call_id(&t->ctl[IDX_B], "call_incoming", &cid) == 0) { |
||||
t->call_id = cid; |
||||
t->phase = P_ACCEPT; |
||||
} |
||||
break; |
||||
} |
||||
case P_ACCEPT: { |
||||
char json[256]; |
||||
snprintf(json, sizeof(json), "{\"id\":3,\"cmd\":\"call_accept\",\"call_id\":\"0x%016llx\"}\n", |
||||
(unsigned long long)t->call_id); |
||||
hs_send(&t->ctl[IDX_B], json, strlen(json)); |
||||
t->phase = P_WAIT_ACCEPTED; |
||||
break; |
||||
} |
||||
case P_WAIT_ACCEPTED: |
||||
if (hs_has(&t->ctl[IDX_A], "call_accepted") && hs_has(&t->ctl[IDX_B], "call_accepted")) |
||||
t->phase = P_BIND_AUDIO; |
||||
break; |
||||
case P_BIND_AUDIO: { |
||||
uint8_t hdr[CALL_AUDIO_HDR_SIZE]; |
||||
hdr[0] = CALL_AUDIO_HELLO; |
||||
memcpy(hdr + 1, &t->call_id, 8); |
||||
hdr[9] = 0; hdr[10] = 0; |
||||
hs_send(&t->aud[IDX_A], hdr, sizeof(hdr)); |
||||
hs_send(&t->aud[IDX_B], hdr, sizeof(hdr)); |
||||
t->phase = P_SEND_MEDIA; |
||||
break; |
||||
} |
||||
case P_SEND_MEDIA: { |
||||
static const uint8_t opus[16] = { 0xf8,0xff,0xfe,1,2,3,4,5,6,7,8,9,0x0a,0x0b,0x0c,0x0d }; |
||||
uint8_t frm[CALL_AUDIO_HDR_SIZE + sizeof(opus)]; |
||||
frm[0] = CALL_AUDIO_FRAME; |
||||
memcpy(frm + 1, &t->call_id, 8); |
||||
frm[9] = 0; frm[10] = (uint8_t)sizeof(opus); |
||||
memcpy(frm + CALL_AUDIO_HDR_SIZE, opus, sizeof(opus)); |
||||
hs_send(&t->aud[IDX_A], frm, sizeof(frm)); |
||||
t->phase = P_WAIT_MEDIA; |
||||
break; |
||||
} |
||||
case P_WAIT_MEDIA: { |
||||
/* ищем FRAME от пира в буфере audio-клиента B: type=0x02 + call_id */ |
||||
const char* buf = t->aud[IDX_B].buf; |
||||
int len = t->aud[IDX_B].len; |
||||
if (len >= CALL_AUDIO_HDR_SIZE) { |
||||
for (int i = 0; i + CALL_AUDIO_HDR_SIZE <= len; i++) { |
||||
if ((uint8_t)buf[i] != CALL_AUDIO_FRAME) continue; |
||||
uint64_t cid; memcpy(&cid, buf + i + 1, 8); |
||||
uint16_t flen = (uint16_t)(((uint8_t)buf[i+9] << 8) | (uint8_t)buf[i+10]); |
||||
if (cid == t->call_id && flen == 16 && i + CALL_AUDIO_HDR_SIZE + flen <= len) { |
||||
t->phase = P_HANGUP; |
||||
break; |
||||
} |
||||
} |
||||
} |
||||
break; |
||||
} |
||||
case P_HANGUP: { |
||||
char json[256]; |
||||
snprintf(json, sizeof(json), "{\"id\":4,\"cmd\":\"call_hangup\",\"call_id\":\"0x%016llx\"}\n", |
||||
(unsigned long long)t->call_id); |
||||
hs_send(&t->ctl[IDX_A], json, strlen(json)); |
||||
t->phase = P_WAIT_ENDED; |
||||
break; |
||||
} |
||||
case P_WAIT_ENDED: |
||||
if (hs_has(&t->ctl[IDX_A], "call_ended") && hs_has(&t->ctl[IDX_B], "call_ended")) |
||||
t->phase = P_DONE; |
||||
break; |
||||
case P_DONE: |
||||
t->result = 1; |
||||
uasync_stop(t->ua); |
||||
return; |
||||
} |
||||
|
||||
if (t->phase == P_DONE) { t->result = 1; uasync_stop(t->ua); return; } |
||||
|
||||
if (++t->ticks > MAX_TICKS) { |
||||
fprintf(stderr, "test: timeout in phase %d (A_conn=%d B_conn=%d)\n", (int)t->phase, |
||||
conn_up(A, nb), conn_up(B, na)); |
||||
t->result = 2; |
||||
uasync_stop(t->ua); |
||||
return; |
||||
} |
||||
t->timer = uasync_set_timeout(t->ua, TICK_TB, t, hs_tick, "hs_tick"); |
||||
} |
||||
|
||||
/* ── main ── */ |
||||
|
||||
int main(int argc, char** argv) { |
||||
(void)argc; (void)argv; |
||||
debug_config_init(); debug_set_level(DEBUG_LEVEL_ERROR); |
||||
if (getenv("UTUN_TEST_DEBUG")) { |
||||
debug_set_category_level_by_name("call", "trace"); |
||||
debug_set_category_level_by_name("bgp", "info"); |
||||
debug_set_category_level_by_name("etcp_route", "info"); |
||||
debug_set_category_level_by_name("member_sync", "info"); |
||||
debug_set_category_level_by_name("chat_sync", "info"); |
||||
debug_set_category_level_by_name("general", "info"); |
||||
debug_set_category_level_by_name("chat", "info"); |
||||
} |
||||
utun_instance_set_tun_init_enabled(0); |
||||
srand((unsigned)time(NULL)); |
||||
|
||||
char dir[512]; snprintf(dir, sizeof(dir), "/tmp/utun_call_hl_XXXXXX"); |
||||
if (!mkdtemp(dir)) { fprintf(stderr, "mkdtemp failed\n"); return 1; } |
||||
setenv("UTUN_TEST_DIR", dir, 1); |
||||
|
||||
fill_shared(56000 + (getpid() % 2000)); |
||||
write_configs(dir); |
||||
|
||||
printf("=== test_call_headless ===\n"); fflush(stdout); |
||||
|
||||
TEST("headless: call_start/accept/hangup + audio stream через сокеты"); { |
||||
struct UASYNC* ua = uasync_create(); |
||||
if (!ua) { FAIL("uasync_create failed"); return 1; } |
||||
|
||||
struct hs_ctx t; |
||||
memset(&t, 0, sizeof(t)); |
||||
t.ua = ua; |
||||
t.phase = P_WAIT_CONN; |
||||
for (int i = 0; i < 2; i++) { t.ctl[i].fd = SOCKET_INVALID; t.aud[i].fd = SOCKET_INVALID; } |
||||
|
||||
char cfgA[512], cfgB[512]; |
||||
snprintf(cfgA, sizeof(cfgA), "%s/a.conf", dir); |
||||
snprintf(cfgB, sizeof(cfgB), "%s/b.conf", dir); |
||||
|
||||
t.inst[IDX_A] = utun_instance_create(ua, cfgA); |
||||
t.inst[IDX_B] = utun_instance_create(ua, cfgB); |
||||
if (!t.inst[IDX_A] || !t.inst[IDX_B]) { |
||||
FAIL("instance create failed A=%p B=%p", (void*)t.inst[IDX_A], (void*)t.inst[IDX_B]); |
||||
uasync_destroy(ua, 0); |
||||
return 1; |
||||
} |
||||
utun_instance_init(t.inst[IDX_A]); |
||||
utun_instance_init(t.inst[IDX_B]); |
||||
|
||||
t.timer = uasync_set_timeout(ua, TICK_TB, &t, hs_tick, "hs_tick"); |
||||
uasync_mainloop(ua); |
||||
|
||||
int ok = (t.result == 1); |
||||
|
||||
/* закрыть клиентские сокеты */ |
||||
for (int i = 0; i < 2; i++) { |
||||
if (t.ctl[i].fd != SOCKET_INVALID) { uasync_remove_socket_t(ua, t.ctl[i].fd); socket_close_wrapper(t.ctl[i].fd); } |
||||
if (t.aud[i].fd != SOCKET_INVALID) { uasync_remove_socket_t(ua, t.aud[i].fd); socket_close_wrapper(t.aud[i].fd); } |
||||
} |
||||
|
||||
if (t.inst[IDX_A]) utun_instance_destroy(t.inst[IDX_A]); |
||||
if (t.inst[IDX_B]) utun_instance_destroy(t.inst[IDX_B]); |
||||
uasync_destroy(ua, 0); |
||||
|
||||
if (ok) OK(); else FAIL("result=%d phase=%d", t.result, (int)t.phase); |
||||
} |
||||
|
||||
char cmd[512]; snprintf(cmd, sizeof(cmd), "rm -rf %s", dir); (void)!system(cmd); |
||||
|
||||
printf("\n%d/%d passed, %d failed\n", G_PASSED, G_TOTAL, G_FAILED); |
||||
return G_FAILED > 0 ? 1 : 0; |
||||
} |
||||
@ -0,0 +1,263 @@
|
||||
package com.utun.chat.data |
||||
|
||||
import android.content.Context |
||||
import android.media.AudioAttributes |
||||
import android.media.AudioDeviceInfo |
||||
import android.media.AudioFormat |
||||
import android.media.AudioManager |
||||
import android.media.AudioRecord |
||||
import android.media.AudioTrack |
||||
import android.media.MediaRecorder |
||||
import com.utun.chat.ChatApplication |
||||
|
||||
/** |
||||
* Полнодуплексный аудио-движок P2P-звонка (аналог десктопного CallAudioEngine). |
||||
* |
||||
* Потоки: |
||||
* - capture-поток: AudioRecord.read(960) → NativeLib.callAudioFeed (C: Opus encode → peer) |
||||
* - play-поток: jitter-буфер → AudioTrack.write (кадры декодированы в C, приходят через onCallPcm) |
||||
* - uasync-поток: onCallPcm(callId, PCM) ← C (call_audio media_recv) |
||||
* |
||||
* Jitter-буфер: очередь PCM-кадров (~50 кадров = 1с), pre-buffer 3 кадра (60мс), |
||||
* при опустошении — тишина (комфортный шум) + счётчик gap для диагностики. |
||||
*/ |
||||
class CallAudioEngine { |
||||
|
||||
companion object { |
||||
const val SAMPLE_RATE = 48000 |
||||
const val FRAME_MS = 20 |
||||
const val FRAME_SAMPLES = SAMPLE_RATE * FRAME_MS / 1000 // 960 |
||||
const val JITTER_TARGET_FRAMES = 3 // pre-buffer 60мс |
||||
const val JITTER_MAX_FRAMES = 50 // ~1с |
||||
} |
||||
|
||||
private var audioRecord: AudioRecord? = null |
||||
private var captureThread: Thread? = null |
||||
private var audioTrack: AudioTrack? = null |
||||
private var playThread: Thread? = null |
||||
private var prevMode: Int = AudioManager.MODE_NORMAL |
||||
private var prevSpeaker: Boolean = false |
||||
|
||||
/** Маршрутизация аудио-выхода (динамик/громкая/гарнитура). */ |
||||
val router = CallAudioRouter() |
||||
|
||||
private val jitter = ArrayDeque<ShortArray>() |
||||
private val lock = Object() |
||||
|
||||
@Volatile private var active = false |
||||
@Volatile private var callId = 0L |
||||
|
||||
@Volatile private var muted = false |
||||
|
||||
@Volatile private var callbackRegistered = false |
||||
|
||||
fun isActive(): Boolean = active |
||||
|
||||
/** Mute: пир слышит тишину (отправляем нулевые кадры вместо захваченного PCM). */ |
||||
fun setMuted(m: Boolean) { |
||||
if (muted == m) return |
||||
muted = m |
||||
LogManager.addLog("INFO", "CallAudio", "mute ${if (m) "on" else "off"}") |
||||
} |
||||
|
||||
fun isMuted(): Boolean = muted |
||||
|
||||
/** Запустить полнодуплекс для звонка. Возвращает true при успехе. */ |
||||
fun start(id: Long): Boolean { |
||||
stop() |
||||
|
||||
if (!callbackRegistered) { |
||||
NativeLib.setCallAudioCallback(object : CallAudioCallback { |
||||
override fun onCallPcm(callId: Long, samples: ShortArray) = onPcm(callId, samples) |
||||
}) |
||||
callbackRegistered = true |
||||
} |
||||
|
||||
if (!NativeLib.callAudioStart(id)) { |
||||
LogManager.addLog("ERROR", "CallAudio", "native callAudioStart failed id=$id") |
||||
return false |
||||
} |
||||
|
||||
callId = id |
||||
muted = false |
||||
|
||||
val ctx = ChatApplication.instance |
||||
val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager |
||||
prevMode = am.mode |
||||
prevSpeaker = am.isSpeakerphoneOn |
||||
try { |
||||
am.mode = AudioManager.MODE_IN_COMMUNICATION |
||||
am.isSpeakerphoneOn = true |
||||
} catch (e: Exception) { |
||||
LogManager.addLog("WARN", "CallAudio", "set audio mode failed: ${e.message}") |
||||
} |
||||
|
||||
router.start() |
||||
|
||||
// ── AudioRecord (захват) ── |
||||
val recMin = AudioRecord.getMinBufferSize( |
||||
SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT) |
||||
audioRecord = try { |
||||
AudioRecord( |
||||
MediaRecorder.AudioSource.VOICE_COMMUNICATION, |
||||
SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, |
||||
(recMin * 2).coerceAtLeast(FRAME_SAMPLES * 4)) |
||||
} catch (e: Exception) { |
||||
LogManager.addLog("ERROR", "CallAudio", "AudioRecord create failed: ${e.message}") |
||||
NativeLib.callAudioStop(); router.stop(); restoreAudioMode(); return false |
||||
} |
||||
if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) { |
||||
LogManager.addLog("ERROR", "CallAudio", "AudioRecord not initialized") |
||||
audioRecord?.release(); audioRecord = null |
||||
NativeLib.callAudioStop(); router.stop(); restoreAudioMode(); return false |
||||
} |
||||
|
||||
// ── AudioTrack (воспроизведение) ── |
||||
val trackMin = AudioTrack.getMinBufferSize( |
||||
SAMPLE_RATE, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_16BIT) |
||||
val trackBuf = (trackMin * 2).coerceAtLeast(FRAME_SAMPLES * 4) |
||||
audioTrack = try { |
||||
AudioTrack( |
||||
AudioAttributes.Builder() |
||||
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) |
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH).build(), |
||||
AudioFormat.Builder().setSampleRate(SAMPLE_RATE) |
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT) |
||||
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO).build(), |
||||
trackBuf, AudioTrack.MODE_STREAM, AudioManager.AUDIO_SESSION_ID_GENERATE) |
||||
} catch (e: Exception) { |
||||
LogManager.addLog("ERROR", "CallAudio", "AudioTrack create failed: ${e.message}") |
||||
audioRecord?.release(); audioRecord = null |
||||
audioTrack = null |
||||
NativeLib.callAudioStop(); router.stop(); restoreAudioMode(); return false |
||||
} |
||||
|
||||
synchronized(lock) { jitter.clear() } |
||||
|
||||
/* Прямое роутирование трека на выбранное устройство вывода (обход глючной |
||||
глобальной маршрутизации на ряде устройств). */ |
||||
router.onOutputDevice = { dev -> applyTrackOutputDevice(dev) } |
||||
applyTrackOutputDevice(router.currentOutputDevice) |
||||
|
||||
active = true |
||||
audioRecord?.startRecording() |
||||
audioTrack?.play() |
||||
|
||||
captureThread = Thread { captureLoop() }.apply { |
||||
priority = Thread.MAX_PRIORITY; start() |
||||
} |
||||
playThread = Thread { playLoop() }.apply { |
||||
priority = Thread.MAX_PRIORITY; start() |
||||
} |
||||
|
||||
LogManager.addLog("INFO", "CallAudio", "started id=$id recBuf=$recMin trackBuf=$trackBuf") |
||||
return true |
||||
} |
||||
|
||||
fun stop() { |
||||
if (!active) return |
||||
active = false |
||||
|
||||
captureThread?.join(1000); captureThread = null |
||||
playThread?.join(1000); playThread = null |
||||
|
||||
audioRecord?.apply { try { stop() } catch (_: Exception) {}; release() } |
||||
audioRecord = null |
||||
audioTrack?.apply { try { stop() } catch (_: Exception) {}; release() } |
||||
audioTrack = null |
||||
|
||||
synchronized(lock) { jitter.clear() } |
||||
|
||||
NativeLib.callAudioStop() |
||||
router.onOutputDevice = null |
||||
router.stop() |
||||
restoreAudioMode() |
||||
|
||||
callId = 0L |
||||
muted = false |
||||
LogManager.addLog("INFO", "CallAudio", "stopped") |
||||
} |
||||
|
||||
/** uasync-поток: принятый и декодированный в C PCM-кадр пира. */ |
||||
private fun onPcm(cid: Long, samples: ShortArray) { |
||||
if (!active || cid != callId) return |
||||
synchronized(lock) { |
||||
if (jitter.size >= JITTER_MAX_FRAMES) jitter.removeFirst() // дроп старейшего |
||||
jitter.addLast(samples) |
||||
} |
||||
} |
||||
|
||||
private fun captureLoop() { |
||||
val buf = ShortArray(FRAME_SAMPLES) |
||||
val silence = ShortArray(FRAME_SAMPLES) |
||||
while (active && audioRecord?.recordingState == AudioRecord.RECORDSTATE_RECORDING) { |
||||
var read = 0 |
||||
while (read < FRAME_SAMPLES) { |
||||
if (!active) return |
||||
val n = audioRecord?.read(buf, read, FRAME_SAMPLES - read) ?: return |
||||
if (n <= 0) return |
||||
read += n |
||||
} |
||||
val out = if (muted) silence else buf |
||||
if (!NativeLib.callAudioFeed(callId, out)) { |
||||
LogManager.addLog("WARN", "CallAudio", "callAudioFeed returned false") |
||||
} |
||||
} |
||||
LogManager.addLog("DEBUG", "CallAudio", "capture loop done") |
||||
} |
||||
|
||||
private fun playLoop() { |
||||
val silence = ShortArray(FRAME_SAMPLES) |
||||
var gaps = 0 |
||||
|
||||
// pre-buffer: ждём накопления jitter (до 250мс), чтобы сгладить стартовую задержку |
||||
val deadline = System.currentTimeMillis() + 250 |
||||
while (active && jitter.size < JITTER_TARGET_FRAMES && System.currentTimeMillis() < deadline) { |
||||
try { Thread.sleep(10) } catch (_: InterruptedException) { return } |
||||
} |
||||
|
||||
while (active) { |
||||
val t0 = System.nanoTime() |
||||
val frame = synchronized(lock) { if (jitter.isNotEmpty()) jitter.removeFirst() else null } |
||||
val track = audioTrack ?: return |
||||
if (frame != null) { |
||||
track.write(frame, 0, frame.size) |
||||
if (gaps > 0) { |
||||
LogManager.addLog("DEBUG", "CallAudio", "jitter recovered after $gaps gaps") |
||||
gaps = 0 |
||||
} |
||||
} else { |
||||
track.write(silence, 0, silence.size) |
||||
gaps++ |
||||
if (gaps == 1) LogManager.addLog("WARN", "CallAudio", "playback underrun (jitter empty)") |
||||
} |
||||
val elapsedMs = (System.nanoTime() - t0) / 1_000_000 |
||||
val sleepMs = FRAME_MS - elapsedMs |
||||
if (sleepMs > 0) { |
||||
try { Thread.sleep(sleepMs) } catch (_: InterruptedException) { return } |
||||
} |
||||
} |
||||
LogManager.addLog("DEBUG", "CallAudio", "play loop done") |
||||
} |
||||
|
||||
private fun restoreAudioMode() { |
||||
try { |
||||
val am = ChatApplication.instance.getSystemService(Context.AUDIO_SERVICE) as AudioManager |
||||
am.isSpeakerphoneOn = prevSpeaker |
||||
am.mode = prevMode |
||||
} catch (e: Exception) { |
||||
LogManager.addLog("WARN", "CallAudio", "restore audio mode failed: ${e.message}") |
||||
} |
||||
} |
||||
|
||||
private fun applyTrackOutputDevice(dev: AudioDeviceInfo?) { |
||||
val track = audioTrack ?: return |
||||
if (dev == null) return |
||||
try { |
||||
track.setPreferredDevice(dev) |
||||
LogManager.addLog("DEBUG", "CallAudio", "track preferred device -> type=${dev.type}") |
||||
} catch (e: Exception) { |
||||
LogManager.addLog("WARN", "CallAudio", "setPreferredDevice failed: ${e.message}") |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,305 @@
|
||||
package com.utun.chat.data |
||||
|
||||
import android.Manifest |
||||
import android.bluetooth.BluetoothAdapter |
||||
import android.bluetooth.BluetoothHeadset |
||||
import android.bluetooth.BluetoothProfile |
||||
import android.content.Context |
||||
import android.content.pm.PackageManager |
||||
import android.media.AudioDeviceCallback |
||||
import android.media.AudioDeviceInfo |
||||
import android.media.AudioManager |
||||
import android.os.Build |
||||
import android.os.Handler |
||||
import android.os.Looper |
||||
import com.utun.chat.ChatApplication |
||||
|
||||
/** Аудио-выход звонка: динамик (earpiece) ↔ громкая связь (speaker) ↔ гарнитура. */ |
||||
enum class AudioRoute { EARPIECE, SPEAKER, HEADSET } |
||||
|
||||
/** |
||||
* Маршрутизация аудио звонка поверх AudioManager + мониторинг гарнитуры. |
||||
* |
||||
* - EARPIECE: разговорный динамик (isSpeakerphoneOn=false). |
||||
* - SPEAKER: громкая связь (isSpeakerphoneOn=true). |
||||
* - HEADSET: проводная/USB гарнитура или Bluetooth (SCO) — если подключена. |
||||
* |
||||
* Гарнитура детектится через AudioDeviceCallback (проводная, USB, SCO) и |
||||
* BluetoothHeadset profile (классический BT до установки SCO). Без BLUETOOTH_CONNECT |
||||
* (Android 12+) BT-часть деградирует до «не видно» — остаётся проводная. |
||||
* |
||||
* Владелец — CallAudioEngine (start/stop в начале/конце звонка), т.к. именно он |
||||
* управляет режимом AudioManager (MODE_IN_COMMUNICATION). |
||||
*/ |
||||
class CallAudioRouter { |
||||
|
||||
@Volatile var route = AudioRoute.SPEAKER |
||||
private set |
||||
@Volatile var headsetAvailable = false |
||||
private set |
||||
@Volatile var hasEarpiece = true |
||||
private set |
||||
|
||||
/** Уведомление об изменении маршрута или доступности гарнитуры. */ |
||||
var onChanged: ((AudioRoute, Boolean) -> Unit)? = null |
||||
|
||||
/** Целевое устройство вывода (для прямого роутирования AudioTrack через setPreferredDevice). */ |
||||
var onOutputDevice: ((AudioDeviceInfo?) -> Unit)? = null |
||||
|
||||
@Volatile var currentOutputDevice: AudioDeviceInfo? = null |
||||
private set |
||||
|
||||
private val am: AudioManager = |
||||
ChatApplication.instance.getSystemService(Context.AUDIO_SERVICE) as AudioManager |
||||
|
||||
private var started = false |
||||
private var deviceCallback: AudioDeviceCallback? = null |
||||
private var btHeadset: BluetoothHeadset? = null |
||||
private var btProfileProxyBound = false |
||||
|
||||
fun start() { |
||||
if (started) return |
||||
started = true |
||||
|
||||
val cb = object : AudioDeviceCallback() { |
||||
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>?) = onDevicesChanged() |
||||
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>?) = onDevicesChanged() |
||||
} |
||||
am.registerAudioDeviceCallback(cb, Handler(Looper.getMainLooper())) |
||||
deviceCallback = cb |
||||
|
||||
bindBtHeadsetProfile() |
||||
|
||||
hasEarpiece = findBuiltinEarpiece() != null |
||||
route = AudioRoute.SPEAKER |
||||
applyRoute(route) |
||||
refresh() |
||||
LogManager.addLog("INFO", "CallAudioRouter", "started (hasEarpiece=$hasEarpiece)") |
||||
} |
||||
|
||||
fun stop() { |
||||
if (!started) return |
||||
started = false |
||||
|
||||
deviceCallback?.let { am.unregisterAudioDeviceCallback(it) } |
||||
deviceCallback = null |
||||
unbindBtHeadsetProfile() |
||||
|
||||
try { |
||||
stopBluetoothSco() |
||||
am.isBluetoothScoOn = false |
||||
clearCommunicationDevice() |
||||
} catch (e: Exception) { |
||||
LogManager.addLog("WARN", "CallAudioRouter", "stop cleanup failed: ${e.message}") |
||||
} |
||||
LogManager.addLog("INFO", "CallAudioRouter", "stopped") |
||||
} |
||||
|
||||
fun setRoute(r: AudioRoute) { |
||||
if (r == AudioRoute.HEADSET && !headsetAvailable) { |
||||
LogManager.addLog("WARN", "CallAudioRouter", "setRoute HEADSET rejected: no headset available") |
||||
return |
||||
} |
||||
if (route == r) return |
||||
route = r |
||||
applyRoute(r) |
||||
LogManager.addLog("INFO", "CallAudioRouter", "route -> $r (headsetAvailable=$headsetAvailable)") |
||||
onChanged?.invoke(route, headsetAvailable) |
||||
} |
||||
|
||||
/* ── применение маршрута ── */ |
||||
private fun applyRoute(r: AudioRoute) { |
||||
try { |
||||
var dev: AudioDeviceInfo? = null |
||||
when (r) { |
||||
AudioRoute.EARPIECE -> { |
||||
stopBluetoothSco() |
||||
am.isBluetoothScoOn = false |
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { |
||||
dev = findBuiltinEarpiece() ?: findBuiltinSpeaker() |
||||
if (dev != null) am.setCommunicationDevice(dev) |
||||
else am.isSpeakerphoneOn = false |
||||
} else { |
||||
am.isSpeakerphoneOn = false |
||||
} |
||||
} |
||||
AudioRoute.SPEAKER -> { |
||||
stopBluetoothSco() |
||||
am.isBluetoothScoOn = false |
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { |
||||
dev = findBuiltinSpeaker() |
||||
if (dev != null) am.setCommunicationDevice(dev) |
||||
else am.isSpeakerphoneOn = true |
||||
} else { |
||||
am.isSpeakerphoneOn = true |
||||
} |
||||
} |
||||
AudioRoute.HEADSET -> { |
||||
am.isSpeakerphoneOn = false |
||||
val bt = findBtScoDevice() |
||||
if (bt != null) { |
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { |
||||
am.setCommunicationDevice(bt) |
||||
} else { |
||||
startBluetoothSco() |
||||
} |
||||
dev = bt |
||||
} else if (isBtHeadsetProfileConnected()) { |
||||
/* классический BT: SCO ещё не поднят — запускаем и ждём |
||||
* AudioDeviceCallback, который пере-применит маршрут */ |
||||
startBluetoothSco() |
||||
} else { |
||||
val wired = findWiredDevice() |
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && wired != null) { |
||||
am.setCommunicationDevice(wired) |
||||
} else { |
||||
/* API<31: проводная гарнитура роутится автоматически при speakerphone=off */ |
||||
am.isSpeakerphoneOn = false |
||||
} |
||||
dev = wired |
||||
} |
||||
} |
||||
} |
||||
currentOutputDevice = dev |
||||
onOutputDevice?.invoke(dev) |
||||
LogManager.addLog("INFO", "CallAudioRouter", |
||||
"route -> $r dev=${dev?.type}/${dev?.productName ?: "-"} mode=${am.mode} speaker=${am.isSpeakerphoneOn}") |
||||
} catch (e: Exception) { |
||||
LogManager.addLog("ERROR", "CallAudioRouter", "applyRoute $r failed: ${e.message}") |
||||
} |
||||
} |
||||
|
||||
private fun findBuiltinSpeaker(): AudioDeviceInfo? = |
||||
am.getDevices(AudioManager.GET_DEVICES_OUTPUTS).firstOrNull { |
||||
it.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER |
||||
} |
||||
|
||||
private fun findBuiltinEarpiece(): AudioDeviceInfo? = |
||||
am.getDevices(AudioManager.GET_DEVICES_OUTPUTS).firstOrNull { |
||||
it.type == AudioDeviceInfo.TYPE_BUILTIN_EARPIECE |
||||
} |
||||
|
||||
/* ── мониторинг гарнитуры ── */ |
||||
private fun onDevicesChanged() { |
||||
/* если в режиме HEADSET появилось SCO-устройство — пере-применяем маршрут, |
||||
* иначе SCO не замаршрутизируется автоматически */ |
||||
if (route == AudioRoute.HEADSET) applyRoute(route) |
||||
refresh() |
||||
} |
||||
|
||||
private fun refresh() { |
||||
val now = isHeadsetConnected() |
||||
val prev = headsetAvailable |
||||
headsetAvailable = now |
||||
if (now != prev) { |
||||
LogManager.addLog("INFO", "CallAudioRouter", "headsetAvailable -> $now") |
||||
} |
||||
/* гарнитуру отключили — откатываемся на динамик */ |
||||
if (!now && route == AudioRoute.HEADSET) { |
||||
route = AudioRoute.EARPIECE |
||||
applyRoute(route) |
||||
LogManager.addLog("INFO", "CallAudioRouter", "headset lost, fallback route -> EARPIECE") |
||||
} |
||||
if (now != prev || route == AudioRoute.HEADSET) { |
||||
onChanged?.invoke(route, now) |
||||
} |
||||
} |
||||
|
||||
private fun isHeadsetConnected(): Boolean = |
||||
findWiredDevice() != null || findBtScoDevice() != null || isBtHeadsetProfileConnected() |
||||
|
||||
private fun findWiredDevice(): AudioDeviceInfo? = |
||||
am.getDevices(AudioManager.GET_DEVICES_OUTPUTS).firstOrNull { isWiredType(it.type) } |
||||
|
||||
private fun findBtScoDevice(): AudioDeviceInfo? = |
||||
am.getDevices(AudioManager.GET_DEVICES_OUTPUTS).firstOrNull { |
||||
it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO |
||||
} |
||||
|
||||
private fun isWiredType(type: Int): Boolean = when (type) { |
||||
AudioDeviceInfo.TYPE_WIRED_HEADSET, |
||||
AudioDeviceInfo.TYPE_WIRED_HEADPHONES, |
||||
AudioDeviceInfo.TYPE_USB_HEADSET, |
||||
AudioDeviceInfo.TYPE_USB_DEVICE, |
||||
AudioDeviceInfo.TYPE_USB_ACCESSORY -> true |
||||
else -> false |
||||
} |
||||
|
||||
/* ── Bluetooth headset profile ── */ |
||||
private fun bindBtHeadsetProfile() { |
||||
try { |
||||
val adapter = BluetoothAdapter.getDefaultAdapter() ?: return |
||||
adapter.getProfileProxy(ChatApplication.instance, object : BluetoothProfile.ServiceListener { |
||||
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { |
||||
if (profile == BluetoothProfile.HEADSET) { |
||||
btHeadset = proxy as BluetoothHeadset |
||||
btProfileProxyBound = true |
||||
LogManager.addLog("INFO", "CallAudioRouter", "bt headset profile connected") |
||||
onDevicesChanged() |
||||
} |
||||
} |
||||
|
||||
override fun onServiceDisconnected(profile: Int) { |
||||
if (profile == BluetoothProfile.HEADSET) { |
||||
btHeadset = null |
||||
btProfileProxyBound = false |
||||
LogManager.addLog("INFO", "CallAudioRouter", "bt headset profile disconnected") |
||||
onDevicesChanged() |
||||
} |
||||
} |
||||
}, BluetoothProfile.HEADSET) |
||||
} catch (e: Exception) { |
||||
LogManager.addLog("WARN", "CallAudioRouter", "bt profile proxy failed: ${e.message}") |
||||
} |
||||
} |
||||
|
||||
private fun unbindBtHeadsetProfile() { |
||||
try { |
||||
val adapter = BluetoothAdapter.getDefaultAdapter() |
||||
if (adapter != null && btProfileProxyBound) { |
||||
adapter.closeProfileProxy(BluetoothProfile.HEADSET, btHeadset) |
||||
} |
||||
} catch (e: Exception) { |
||||
LogManager.addLog("WARN", "CallAudioRouter", "unbind bt profile failed: ${e.message}") |
||||
} |
||||
btHeadset = null |
||||
btProfileProxyBound = false |
||||
} |
||||
|
||||
private fun hasBtConnectPermission(): Boolean = |
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || |
||||
ChatApplication.instance.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) == |
||||
PackageManager.PERMISSION_GRANTED |
||||
|
||||
private fun isBtHeadsetProfileConnected(): Boolean { |
||||
if (!hasBtConnectPermission()) return false |
||||
return try { |
||||
btHeadset?.connectedDevices?.isNotEmpty() == true |
||||
} catch (e: SecurityException) { |
||||
LogManager.addLog("WARN", "CallAudioRouter", "bt connectedDevices denied: ${e.message}") |
||||
false |
||||
} |
||||
} |
||||
|
||||
/* ── helpers ── */ |
||||
private fun clearCommunicationDevice() { |
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { |
||||
am.clearCommunicationDevice() |
||||
} |
||||
} |
||||
|
||||
private fun startBluetoothSco() { |
||||
try { |
||||
am.startBluetoothSco() |
||||
am.isBluetoothScoOn = true |
||||
} catch (e: Exception) { |
||||
LogManager.addLog("WARN", "CallAudioRouter", "startBluetoothSco failed: ${e.message}") |
||||
} |
||||
} |
||||
|
||||
private fun stopBluetoothSco() { |
||||
try { am.stopBluetoothSco() } catch (e: Exception) { |
||||
LogManager.addLog("WARN", "CallAudioRouter", "stopBluetoothSco failed: ${e.message}") |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,25 @@
|
||||
package com.utun.chat.data |
||||
|
||||
import android.content.BroadcastReceiver |
||||
import android.content.Context |
||||
import android.content.Intent |
||||
|
||||
/** Кнопка «Завершить» на уведомлении входящего звонка. */ |
||||
class IncomingCallActionReceiver : BroadcastReceiver() { |
||||
|
||||
companion object { |
||||
const val ACTION_DECLINE = "com.utun.chat.action.DECLINE_CALL" |
||||
} |
||||
|
||||
override fun onReceive(context: Context, intent: Intent) { |
||||
if (intent.action != ACTION_DECLINE) return |
||||
val callId = intent.getLongExtra(IncomingCallManager.EXTRA_CALL_ID, 0L) |
||||
if (callId == 0L) { |
||||
LogManager.addLog("ERROR", "InCall", "DECLINE action: bad call_id") |
||||
return |
||||
} |
||||
LogManager.addLog("INFO", "InCall", "DECLINE action callId=0x%016x".format(callId)) |
||||
NativeLib.callDecline(callId) |
||||
IncomingCallManager.stopRinging() |
||||
} |
||||
} |
||||
@ -0,0 +1,216 @@
|
||||
package com.utun.chat.data |
||||
|
||||
import android.app.NotificationChannel |
||||
import android.app.NotificationManager |
||||
import android.app.PendingIntent |
||||
import android.content.Context |
||||
import android.content.Intent |
||||
import android.os.Build |
||||
import android.os.PowerManager |
||||
import androidx.core.app.NotificationCompat |
||||
import androidx.core.app.NotificationManagerCompat |
||||
import com.utun.chat.AppEventHandler |
||||
import com.utun.chat.ChatApplication |
||||
import com.utun.chat.MainActivity |
||||
import kotlinx.coroutines.CoroutineScope |
||||
import kotlinx.coroutines.Dispatchers |
||||
import kotlinx.coroutines.SupervisorJob |
||||
import kotlinx.coroutines.launch |
||||
import java.nio.ByteBuffer |
||||
import java.nio.ByteOrder |
||||
|
||||
/** |
||||
* Системные эффекты входящего звонка, независимые от жизненного цикла Activity: |
||||
* пробуждение устройства, полноэкранное уведомление (с кнопкой «Завершить») и мелодия |
||||
* через профиль звонка. Подписывается на AppEventHandler.events в app-скопе — работает |
||||
* и при убитом Activity, пока жив foreground-сервис с C-ядром. |
||||
* |
||||
* UI-состоянием (accept/decline/отрисовка) по-прежнему владеет ChatViewModel; |
||||
* этот объект лишь будит устройство и хранит отложенный входящий звонок, |
||||
* который VM забирает через consumePendingIncoming() при (пере)создании. |
||||
*/ |
||||
object IncomingCallManager { |
||||
|
||||
const val CHANNEL_CALL = "incoming_call" |
||||
const val NOTIF_ID = 1001 |
||||
|
||||
const val EXTRA_CALL_ID = "call_id" |
||||
const val EXTRA_CHANNEL_ID = "channel_id" |
||||
const val EXTRA_PEER_NODE_ID = "peer_node_id" |
||||
|
||||
data class PendingCall(val callId: Long, val channelId: String, val peerNodeId: Long) |
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) |
||||
private val ringtone = RingtonePlayer() |
||||
|
||||
@Volatile private var initialized = false |
||||
@Volatile private var activeCallId = 0L |
||||
@Volatile private var pendingCall: PendingCall? = null |
||||
private var wakeLock: PowerManager.WakeLock? = null |
||||
|
||||
fun init() { |
||||
if (initialized) return |
||||
initialized = true |
||||
createChannel(ChatApplication.instance) |
||||
scope.launch { |
||||
AppEventHandler.events.collect { (type, data) -> handleEvent(type, data) } |
||||
} |
||||
LogManager.addLog("INFO", "InCall", "manager initialized") |
||||
} |
||||
|
||||
fun currentIncomingCallId(): Long = activeCallId |
||||
|
||||
/** Разрешены ли полноэкранные уведомления (Android 14+ требует ручного включения). */ |
||||
fun canFullScreenIntent(): Boolean { |
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return true |
||||
val nm = ChatApplication.instance.getSystemService(NotificationManager::class.java) |
||||
return nm.canUseFullScreenIntent() |
||||
} |
||||
|
||||
/** VM забирает отложенный входящий (сценарий «Activity убит, звонок пришёл»). */ |
||||
@Synchronized |
||||
fun consumePendingIncoming(): PendingCall? { |
||||
val p = pendingCall |
||||
pendingCall = null |
||||
return p |
||||
} |
||||
|
||||
/** Немедленная остановка мелодии/уведомления (accept/decline/hangup из VM). Идемпотентно. */ |
||||
fun stopRinging() { |
||||
val id = activeCallId |
||||
if (id == 0L) return |
||||
activeCallId = 0L |
||||
ringtone.stop() |
||||
releaseWakeLock() |
||||
cancelNotification() |
||||
pendingCall = null |
||||
LogManager.addLog("INFO", "InCall", "ringing stopped callId=0x%016x".format(id)) |
||||
} |
||||
|
||||
private fun handleEvent(type: Int, data: ByteArray?) { |
||||
when (type) { |
||||
34 -> onIncoming(data) /* CALL_INCOMING */ |
||||
36, 37, 38, 40 -> onCallStop(data) /* ACCEPTED/ENDED/DECLINED/ERROR */ |
||||
} |
||||
} |
||||
|
||||
private fun onIncoming(data: ByteArray?) { |
||||
if (data == null || data.size < 1 + 8 + 8) return |
||||
val cl = data[0].toInt() and 0xFF |
||||
if (data.size < 1 + cl + 16) return |
||||
val chId = String(data, 1, cl) |
||||
val buf = ByteBuffer.wrap(data, 1 + cl, 16).order(ByteOrder.LITTLE_ENDIAN) |
||||
val caller = buf.long |
||||
val callId = buf.long |
||||
if (activeCallId != 0L) { |
||||
LogManager.addLog("WARN", "InCall", "busy (0x%016x), skip incoming 0x%016x".format(activeCallId, callId)) |
||||
return |
||||
} |
||||
activeCallId = callId |
||||
pendingCall = PendingCall(callId, chId, caller) |
||||
LogManager.addLog("INFO", "InCall", "incoming call 0x%016x from 0x%016x ch=%s".format(callId, caller, chId)) |
||||
acquireWakeLock() |
||||
ringtone.play() |
||||
showNotification(callId, chId, caller) |
||||
} |
||||
|
||||
private fun onCallStop(data: ByteArray?) { |
||||
if (data == null || data.size < 8) return |
||||
val callId = ByteBuffer.wrap(data, 0, 8).order(ByteOrder.LITTLE_ENDIAN).long |
||||
if (activeCallId == 0L || callId != activeCallId) return |
||||
stopRinging() |
||||
} |
||||
|
||||
/* ── пробуждение устройства ── */ |
||||
|
||||
private fun acquireWakeLock() { |
||||
val pm = ChatApplication.instance.getSystemService(Context.POWER_SERVICE) as PowerManager |
||||
wakeLock = try { |
||||
pm.newWakeLock( |
||||
PowerManager.SCREEN_BRIGHT_WAKE_LOCK or PowerManager.ACQUIRE_CAUSES_WAKEUP |
||||
or PowerManager.ON_AFTER_RELEASE, |
||||
"utun:incoming_call" |
||||
).apply { acquire(30_000) } |
||||
} catch (e: Exception) { |
||||
LogManager.addLog("WARN", "InCall", "wake lock failed: ${e.message}") |
||||
null |
||||
} |
||||
} |
||||
|
||||
private fun releaseWakeLock() { |
||||
wakeLock?.let { |
||||
try { if (it.isHeld) it.release() } catch (_: Exception) {} |
||||
} |
||||
wakeLock = null |
||||
} |
||||
|
||||
/* ── уведомление ── */ |
||||
|
||||
private fun createChannel(ctx: Context) { |
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { |
||||
val ch = NotificationChannel(CHANNEL_CALL, "Входящие звонки", NotificationManager.IMPORTANCE_HIGH).apply { |
||||
description = "Уведомление о входящем звонке" |
||||
enableVibration(false) |
||||
setSound(null, null) |
||||
} |
||||
ctx.getSystemService(NotificationManager::class.java).createNotificationChannel(ch) |
||||
} |
||||
} |
||||
|
||||
private fun showNotification(callId: Long, channelId: String, peerNodeId: Long) { |
||||
val ctx = ChatApplication.instance |
||||
if (!NotificationManagerCompat.from(ctx).areNotificationsEnabled()) { |
||||
LogManager.addLog("WARN", "InCall", "notifications disabled — skip (still ringing)") |
||||
return |
||||
} |
||||
|
||||
val launchIntent = Intent(ctx, MainActivity::class.java).apply { |
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) |
||||
putExtra(EXTRA_CALL_ID, callId) |
||||
putExtra(EXTRA_CHANNEL_ID, channelId) |
||||
putExtra(EXTRA_PEER_NODE_ID, peerNodeId) |
||||
} |
||||
val contentPi = PendingIntent.getActivity(ctx, 0, launchIntent, |
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) |
||||
|
||||
val declinePi = PendingIntent.getBroadcast(ctx, 0, |
||||
Intent(ctx, IncomingCallActionReceiver::class.java).apply { |
||||
action = IncomingCallActionReceiver.ACTION_DECLINE |
||||
putExtra(EXTRA_CALL_ID, callId) |
||||
}, |
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) |
||||
|
||||
val builder = NotificationCompat.Builder(ctx, CHANNEL_CALL) |
||||
.setSmallIcon(android.R.drawable.ic_dialog_info) |
||||
.setContentTitle("Входящий звонок") |
||||
.setContentText("0x%016x".format(peerNodeId)) |
||||
.setCategory(NotificationCompat.CATEGORY_CALL) |
||||
.setPriority(NotificationCompat.PRIORITY_MAX) |
||||
.setOngoing(true) |
||||
.setAutoCancel(false) |
||||
.setContentIntent(contentPi) |
||||
.addAction(0, "Завершить", declinePi) |
||||
|
||||
val nm = ctx.getSystemService(NotificationManager::class.java) |
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE || nm.canUseFullScreenIntent()) { |
||||
builder.setFullScreenIntent(contentPi, true) |
||||
} else { |
||||
LogManager.addLog("WARN", "InCall", "full-screen intent not allowed — heads-up only") |
||||
} |
||||
|
||||
try { |
||||
NotificationManagerCompat.from(ctx).notify(NOTIF_ID, builder.build()) |
||||
LogManager.addLog("INFO", "InCall", "notification posted callId=0x%016x".format(callId)) |
||||
} catch (e: SecurityException) { |
||||
LogManager.addLog("WARN", "InCall", "notify denied: ${e.message}") |
||||
} |
||||
} |
||||
|
||||
private fun cancelNotification() { |
||||
try { |
||||
NotificationManagerCompat.from(ChatApplication.instance).cancel(NOTIF_ID) |
||||
} catch (e: Exception) { |
||||
LogManager.addLog("WARN", "InCall", "cancel notification failed: ${e.message}") |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,158 @@
|
||||
package com.utun.chat.data |
||||
|
||||
import android.content.Context |
||||
import android.media.AudioAttributes |
||||
import android.media.AudioFocusRequest |
||||
import android.media.AudioManager |
||||
import android.media.MediaPlayer |
||||
import android.media.Ringtone |
||||
import android.media.RingtoneManager |
||||
import android.net.Uri |
||||
import android.os.Build |
||||
import android.os.VibrationEffect |
||||
import android.os.Vibrator |
||||
import android.provider.Settings |
||||
import com.utun.chat.ChatApplication |
||||
import com.utun.chat.R |
||||
|
||||
/** |
||||
* Мелодия входящего звонка через профиль звонка (STREAM_RING): |
||||
* - громкость — настройка «громкость вызова»; |
||||
* - вибрация — по системной настройке «вибрировать при вызове» (VIBRATE_WHEN_RINGING). |
||||
* |
||||
* API 28+ : Ringtone (RingtoneManager) — громкость и вибрация автоматически. |
||||
* API 26–27: MediaPlayer(STREAM_RING, loop) + ручная вибрация по системной настройке. |
||||
*/ |
||||
class RingtonePlayer { |
||||
|
||||
private var ringtone: Ringtone? = null |
||||
private var mediaPlayer: MediaPlayer? = null |
||||
private var vibrator: Vibrator? = null |
||||
private var focusRequest: AudioFocusRequest? = null |
||||
|
||||
fun play() { |
||||
stop() |
||||
val ctx = ChatApplication.instance |
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { |
||||
val uri = Uri.parse("android.resource://${ctx.packageName}/${R.raw.ringtone}") |
||||
val r = try { |
||||
RingtoneManager.getRingtone(ctx, uri) |
||||
} catch (e: Exception) { |
||||
LogManager.addLog("ERROR", "Ringtone", "getRingtone failed: ${e.message}") |
||||
null |
||||
} ?: return |
||||
try { |
||||
r.isLooping = true |
||||
r.play() |
||||
} catch (e: Exception) { |
||||
LogManager.addLog("ERROR", "Ringtone", "play failed: ${e.message}") |
||||
return |
||||
} |
||||
ringtone = r |
||||
LogManager.addLog("INFO", "Ringtone", "started (Ringtone, STREAM_RING + system vibrate)") |
||||
} else { |
||||
val mp = try { |
||||
MediaPlayer.create(ctx, R.raw.ringtone) |
||||
} catch (e: Exception) { |
||||
LogManager.addLog("ERROR", "Ringtone", "MediaPlayer.create failed: ${e.message}") |
||||
null |
||||
} ?: return |
||||
try { |
||||
mp.setAudioAttributes( |
||||
AudioAttributes.Builder() |
||||
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE) |
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) |
||||
.setLegacyStreamType(AudioManager.STREAM_RING) |
||||
.build() |
||||
) |
||||
mp.isLooping = true |
||||
requestAudioFocus() |
||||
mp.start() |
||||
} catch (e: Exception) { |
||||
LogManager.addLog("ERROR", "Ringtone", "MediaPlayer start failed: ${e.message}") |
||||
try { mp.release() } catch (_: Exception) {} |
||||
abandonAudioFocus() |
||||
return |
||||
} |
||||
mediaPlayer = mp |
||||
startVibrate() |
||||
LogManager.addLog("INFO", "Ringtone", "started (MediaPlayer fallback, STREAM_RING)") |
||||
} |
||||
} |
||||
|
||||
fun stop() { |
||||
ringtone?.let { try { it.stop() } catch (_: Exception) {} } |
||||
ringtone = null |
||||
|
||||
mediaPlayer?.let { |
||||
try { it.stop() } catch (_: Exception) {} |
||||
try { it.release() } catch (_: Exception) {} |
||||
} |
||||
mediaPlayer = null |
||||
|
||||
stopVibrate() |
||||
abandonAudioFocus() |
||||
LogManager.addLog("INFO", "Ringtone", "stopped") |
||||
} |
||||
|
||||
/* ── вибрация (fallback API 26–27) по системной настройке ── */ |
||||
|
||||
private fun systemVibrateWhenRinging(): Boolean = try { |
||||
Settings.System.getInt( |
||||
ChatApplication.instance.contentResolver, Settings.System.VIBRATE_WHEN_RINGING, 0) != 0 |
||||
} catch (e: Exception) { |
||||
false |
||||
} |
||||
|
||||
private fun startVibrate() { |
||||
if (!systemVibrateWhenRinging()) return |
||||
val v = try { |
||||
ChatApplication.instance.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator |
||||
} catch (e: Exception) { |
||||
LogManager.addLog("WARN", "Ringtone", "vibrator unavailable: ${e.message}") |
||||
null |
||||
} ?: return |
||||
if (!v.hasVibrator()) return |
||||
try { |
||||
v.vibrate(VibrationEffect.createWaveform(longArrayOf(1000, 1000), 0)) |
||||
vibrator = v |
||||
} catch (e: Exception) { |
||||
LogManager.addLog("WARN", "Ringtone", "vibrate failed: ${e.message}") |
||||
} |
||||
} |
||||
|
||||
private fun stopVibrate() { |
||||
vibrator?.let { try { it.cancel() } catch (_: Exception) {} } |
||||
vibrator = null |
||||
} |
||||
|
||||
/* ── audio focus (fallback API 26–27) ── */ |
||||
|
||||
private fun requestAudioFocus() { |
||||
try { |
||||
val am = ChatApplication.instance.getSystemService(Context.AUDIO_SERVICE) as AudioManager |
||||
val attrs = AudioAttributes.Builder() |
||||
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE) |
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) |
||||
.build() |
||||
val req = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT) |
||||
.setAudioAttributes(attrs) |
||||
.build() |
||||
am.requestAudioFocus(req) |
||||
focusRequest = req |
||||
} catch (e: Exception) { |
||||
LogManager.addLog("WARN", "Ringtone", "audio focus failed: ${e.message}") |
||||
} |
||||
} |
||||
|
||||
private fun abandonAudioFocus() { |
||||
val req = focusRequest ?: return |
||||
focusRequest = null |
||||
try { |
||||
val am = ChatApplication.instance.getSystemService(Context.AUDIO_SERVICE) as AudioManager |
||||
am.abandonAudioFocusRequest(req) |
||||
} catch (e: Exception) { |
||||
LogManager.addLog("WARN", "Ringtone", "abandon focus failed: ${e.message}") |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,194 @@
|
||||
package com.utun.chat.ui.screens |
||||
|
||||
import android.Manifest |
||||
import android.content.pm.PackageManager |
||||
import android.os.Build |
||||
import androidx.activity.compose.rememberLauncherForActivityResult |
||||
import androidx.activity.result.contract.ActivityResultContracts |
||||
import androidx.compose.foundation.background |
||||
import androidx.compose.foundation.layout.* |
||||
import androidx.compose.foundation.shape.CircleShape |
||||
import androidx.compose.material.icons.Icons |
||||
import androidx.compose.material.icons.automirrored.filled.VolumeUp |
||||
import androidx.compose.material.icons.filled.CallEnd |
||||
import androidx.compose.material.icons.filled.Headset |
||||
import androidx.compose.material.icons.filled.Mic |
||||
import androidx.compose.material.icons.filled.MicOff |
||||
import androidx.compose.material.icons.filled.PhoneInTalk |
||||
import androidx.compose.material3.* |
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.runtime.collectAsState |
||||
import androidx.compose.runtime.getValue |
||||
import androidx.compose.ui.Alignment |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.graphics.Color |
||||
import androidx.compose.ui.graphics.vector.ImageVector |
||||
import androidx.compose.ui.platform.LocalContext |
||||
import androidx.compose.ui.text.font.FontWeight |
||||
import androidx.compose.ui.unit.dp |
||||
import androidx.compose.ui.unit.sp |
||||
import androidx.core.content.ContextCompat |
||||
import com.utun.chat.data.AudioRoute |
||||
import com.utun.chat.viewmodel.ChatViewModel |
||||
import com.utun.chat.viewmodel.ChatViewModel.CallPhase |
||||
|
||||
private fun fmtDuration(sec: Int): String { |
||||
val m = sec / 60 |
||||
val s = sec % 60 |
||||
return "%02d:%02d".format(m, s) |
||||
} |
||||
|
||||
private fun callStatusText(phase: CallPhase): String = when (phase) { |
||||
CallPhase.OUTGOING -> "Звоним…" |
||||
CallPhase.RINGING -> "Гудки…" |
||||
CallPhase.ACTIVE -> "Разговор" |
||||
CallPhase.ENDED -> "Завершён" |
||||
else -> "" |
||||
} |
||||
|
||||
@Composable |
||||
private fun CallControlButton( |
||||
icon: ImageVector, |
||||
contentDescription: String, |
||||
label: String, |
||||
active: Boolean, |
||||
activeColor: Color = Color(0xFF1F6FEB), |
||||
onClick: () -> Unit |
||||
) { |
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) { |
||||
Box( |
||||
modifier = Modifier.size(60.dp) |
||||
.background(if (active) activeColor else Color(0xFF24323D), CircleShape), |
||||
contentAlignment = Alignment.Center |
||||
) { |
||||
IconButton(onClick = onClick) { |
||||
Icon(icon, contentDescription, tint = Color.White, modifier = Modifier.size(28.dp)) |
||||
} |
||||
} |
||||
Spacer(Modifier.height(6.dp)) |
||||
Text(label, color = if (active) Color.White else Color(0xFF9BA6AD), fontSize = 11.sp) |
||||
} |
||||
} |
||||
|
||||
@Composable |
||||
fun CallScreen( |
||||
viewModel: ChatViewModel, |
||||
onClose: () -> Unit |
||||
) { |
||||
val st = viewModel.callState.collectAsState().value |
||||
val muted by viewModel.muted.collectAsState() |
||||
val route by viewModel.audioRoute.collectAsState() |
||||
val headsetAvailable by viewModel.headsetAvailable.collectAsState() |
||||
val earpieceAvailable by viewModel.earpieceAvailable.collectAsState() |
||||
|
||||
val phase = st?.phase ?: CallPhase.NONE |
||||
val duration = st?.durationMs ?: 0 |
||||
val peerNodeId = st?.peerNodeId ?: 0L |
||||
val name = st?.peerName?.ifEmpty { "0x%016x".format(peerNodeId) } ?: "" |
||||
|
||||
val context = LocalContext.current |
||||
val hasBtPermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || |
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == |
||||
PackageManager.PERMISSION_GRANTED |
||||
val btPermissionLauncher = rememberLauncherForActivityResult( |
||||
ActivityResultContracts.RequestPermission() |
||||
) { granted -> |
||||
if (granted) viewModel.setAudioRoute(AudioRoute.HEADSET) |
||||
} |
||||
|
||||
Box( |
||||
modifier = Modifier.fillMaxSize().background(Color(0xFF0F1B22)), |
||||
contentAlignment = Alignment.Center |
||||
) { |
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) { |
||||
Box( |
||||
modifier = Modifier.size(96.dp).background(Color(0xFF1F6FEB), CircleShape), |
||||
contentAlignment = Alignment.Center |
||||
) { |
||||
Text( |
||||
name.take(1).uppercase(), |
||||
fontSize = 40.sp, |
||||
color = Color.White, |
||||
fontWeight = FontWeight.Bold |
||||
) |
||||
} |
||||
Spacer(Modifier.height(24.dp)) |
||||
Text(name, color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.Medium) |
||||
Spacer(Modifier.height(8.dp)) |
||||
Text( |
||||
if (phase == CallPhase.ACTIVE) fmtDuration(duration) else callStatusText(phase), |
||||
color = Color(0xFF9BA6AD), |
||||
fontSize = 16.sp |
||||
) |
||||
Spacer(Modifier.height(40.dp)) |
||||
|
||||
if (phase == CallPhase.ENDED) { |
||||
Button(onClick = { viewModel.dismissCall(); onClose() }) { |
||||
Text("Закрыть") |
||||
} |
||||
} else { |
||||
if (phase == CallPhase.ACTIVE) { |
||||
Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) { |
||||
if (earpieceAvailable) { |
||||
CallControlButton( |
||||
icon = Icons.Filled.PhoneInTalk, |
||||
contentDescription = "Динамик", |
||||
label = "Динамик", |
||||
active = route == AudioRoute.EARPIECE, |
||||
onClick = { viewModel.setAudioRoute(AudioRoute.EARPIECE) } |
||||
) |
||||
} |
||||
CallControlButton( |
||||
icon = Icons.AutoMirrored.Filled.VolumeUp, |
||||
contentDescription = "Громкая связь", |
||||
label = "Громкая", |
||||
active = route == AudioRoute.SPEAKER, |
||||
onClick = { viewModel.setAudioRoute(AudioRoute.SPEAKER) } |
||||
) |
||||
if (headsetAvailable) { |
||||
CallControlButton( |
||||
icon = Icons.Filled.Headset, |
||||
contentDescription = "Гарнитура", |
||||
label = "Гарнитура", |
||||
active = route == AudioRoute.HEADSET, |
||||
onClick = { |
||||
if (hasBtPermission) { |
||||
viewModel.setAudioRoute(AudioRoute.HEADSET) |
||||
} else { |
||||
btPermissionLauncher.launch(Manifest.permission.BLUETOOTH_CONNECT) |
||||
} |
||||
} |
||||
) |
||||
} |
||||
} |
||||
Spacer(Modifier.height(28.dp)) |
||||
} |
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(48.dp), verticalAlignment = Alignment.CenterVertically) { |
||||
if (phase == CallPhase.ACTIVE) { |
||||
CallControlButton( |
||||
icon = if (muted) Icons.Filled.MicOff else Icons.Filled.Mic, |
||||
contentDescription = if (muted) "Включить микрофон" else "Выключить микрофон", |
||||
label = if (muted) "Вкл. микрофон" else "Mute", |
||||
active = muted, |
||||
activeColor = Color(0xFFE53935), |
||||
onClick = { viewModel.toggleMute() } |
||||
) |
||||
} |
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) { |
||||
Box( |
||||
modifier = Modifier.size(72.dp).background(Color(0xFFE53935), CircleShape), |
||||
contentAlignment = Alignment.Center |
||||
) { |
||||
IconButton(onClick = { viewModel.hangupCall() }) { |
||||
Icon(Icons.Filled.CallEnd, "Завершить", tint = Color.White, modifier = Modifier.size(36.dp)) |
||||
} |
||||
} |
||||
Spacer(Modifier.height(6.dp)) |
||||
Text("Завершить", color = Color(0xFF9BA6AD), fontSize = 12.sp) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,76 @@
|
||||
package com.utun.chat.ui.screens |
||||
|
||||
import androidx.compose.foundation.background |
||||
import androidx.compose.foundation.layout.* |
||||
import androidx.compose.foundation.shape.CircleShape |
||||
import androidx.compose.material.icons.Icons |
||||
import androidx.compose.material.icons.filled.Call |
||||
import androidx.compose.material.icons.filled.CallEnd |
||||
import androidx.compose.material3.* |
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.runtime.collectAsState |
||||
import androidx.compose.ui.Alignment |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.graphics.Color |
||||
import androidx.compose.ui.text.font.FontWeight |
||||
import androidx.compose.ui.unit.dp |
||||
import androidx.compose.ui.unit.sp |
||||
import androidx.compose.ui.window.Dialog |
||||
import com.utun.chat.viewmodel.ChatViewModel |
||||
|
||||
@Composable |
||||
fun IncomingCallDialog( |
||||
viewModel: ChatViewModel |
||||
) { |
||||
val st = viewModel.callState.collectAsState().value ?: return |
||||
val name = st.peerName.ifEmpty { "0x%016x".format(st.peerNodeId) } |
||||
|
||||
Dialog(onDismissRequest = { }) { |
||||
Surface( |
||||
shape = MaterialTheme.shapes.large, |
||||
color = Color(0xFF1B2733) |
||||
) { |
||||
Column( |
||||
modifier = Modifier.padding(32.dp), |
||||
horizontalAlignment = Alignment.CenterHorizontally |
||||
) { |
||||
Box( |
||||
modifier = Modifier.size(80.dp).background(Color(0xFF1F6FEB), CircleShape), |
||||
contentAlignment = Alignment.Center |
||||
) { |
||||
Text(name.take(1).uppercase(), fontSize = 34.sp, color = Color.White, fontWeight = FontWeight.Bold) |
||||
} |
||||
Spacer(Modifier.height(20.dp)) |
||||
Text(name, color = Color.White, fontSize = 22.sp, fontWeight = FontWeight.Medium) |
||||
Spacer(Modifier.height(6.dp)) |
||||
Text("Входящий звонок…", color = Color(0xFF9BA6AD), fontSize = 14.sp) |
||||
Spacer(Modifier.height(28.dp)) |
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(48.dp)) { |
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) { |
||||
Box( |
||||
modifier = Modifier.size(64.dp).background(Color(0xFFE53935), CircleShape), |
||||
contentAlignment = Alignment.Center |
||||
) { |
||||
IconButton(onClick = { viewModel.declineCall() }) { |
||||
Icon(Icons.Filled.CallEnd, "Отклонить", tint = Color.White, modifier = Modifier.size(30.dp)) |
||||
} |
||||
} |
||||
Text("Отклонить", color = Color(0xFF9BA6AD), fontSize = 12.sp) |
||||
} |
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) { |
||||
Box( |
||||
modifier = Modifier.size(64.dp).background(Color(0xFF43A047), CircleShape), |
||||
contentAlignment = Alignment.Center |
||||
) { |
||||
IconButton(onClick = { viewModel.acceptCall() }) { |
||||
Icon(Icons.Filled.Call, "Принять", tint = Color.White, modifier = Modifier.size(30.dp)) |
||||
} |
||||
} |
||||
Text("Принять", color = Color(0xFF9BA6AD), fontSize = 12.sp) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
Binary file not shown.
@ -0,0 +1,156 @@
|
||||
#include "call_audio.h" |
||||
#include "instance_lite.h" |
||||
#include "../../../lib/opus_codec.h" |
||||
#include "../../../lib/debug_config.h" |
||||
#include "../../../lib/mem.h" |
||||
#include "../../../lib/u_async.h" |
||||
#include "../../../src/call/call.h" |
||||
#include <string.h> |
||||
#include <pthread.h> |
||||
|
||||
#define CALL_AUDIO_BITRATE 32000 |
||||
|
||||
static pthread_mutex_t g_mtx = PTHREAD_MUTEX_INITIALIZER; |
||||
static int g_active = 0; |
||||
static uint64_t g_call_id = 0; |
||||
static opus_codec_encoder_t* g_encoder = NULL; |
||||
static opus_codec_decoder_t* g_decoder = NULL; |
||||
static call_audio_pcm_recv_fn g_pcm_fn = NULL; |
||||
static void* g_pcm_arg = NULL; |
||||
|
||||
void call_audio_set_pcm_recv_cb(call_audio_pcm_recv_fn fn, void* arg) { |
||||
pthread_mutex_lock(&g_mtx); |
||||
g_pcm_fn = fn; |
||||
g_pcm_arg = arg; |
||||
pthread_mutex_unlock(&g_mtx); |
||||
} |
||||
|
||||
/* uasync-поток: принятый Opus-кадр пира (из call.c media_recv). */ |
||||
static void call_audio_media_recv(struct UTUN_INSTANCE* inst, uint64_t call_id, |
||||
const uint8_t* opus, int len, void* arg) { |
||||
(void)inst; (void)arg; |
||||
int16_t pcm[CALL_AUDIO_FRAME_SAMPLES]; |
||||
call_audio_pcm_recv_fn fn = NULL; |
||||
void* fn_arg = NULL; |
||||
int n = 0; |
||||
|
||||
pthread_mutex_lock(&g_mtx); |
||||
if (g_active && call_id == g_call_id && g_decoder && opus && len > 0) { |
||||
n = opus_codec_decode(g_decoder, opus, len, pcm, CALL_AUDIO_FRAME_SAMPLES); |
||||
fn = g_pcm_fn; |
||||
fn_arg = g_pcm_arg; |
||||
} |
||||
pthread_mutex_unlock(&g_mtx); |
||||
|
||||
if (n > 0 && fn) |
||||
fn(call_id, pcm, n, fn_arg); |
||||
} |
||||
|
||||
int call_audio_start(uint64_t call_id) { |
||||
if (call_id == 0) { |
||||
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "call_audio_start: bad call_id=0"); |
||||
return -1; |
||||
} |
||||
|
||||
pthread_mutex_lock(&g_mtx); |
||||
if (g_active && g_call_id == call_id) { |
||||
pthread_mutex_unlock(&g_mtx); |
||||
return 0; /* уже запущен для этого звонка */ |
||||
} |
||||
if (g_active) { |
||||
/* другой звонок активен — сбрасываем его */ |
||||
pthread_mutex_unlock(&g_mtx); |
||||
call_audio_stop(); |
||||
pthread_mutex_lock(&g_mtx); |
||||
} |
||||
|
||||
opus_codec_encoder_t* enc = opus_codec_encoder_create(CALL_AUDIO_SAMPLE_RATE, 1); |
||||
if (!enc) { |
||||
pthread_mutex_unlock(&g_mtx); |
||||
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "call_audio_start: encoder create failed"); |
||||
return -1; |
||||
} |
||||
opus_codec_encoder_bitrate_set(enc, CALL_AUDIO_BITRATE); |
||||
|
||||
opus_codec_decoder_t* dec = opus_codec_decoder_create(CALL_AUDIO_SAMPLE_RATE, 1); |
||||
if (!dec) { |
||||
opus_codec_encoder_destroy(enc); |
||||
pthread_mutex_unlock(&g_mtx); |
||||
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "call_audio_start: decoder create failed"); |
||||
return -1; |
||||
} |
||||
|
||||
g_encoder = enc; |
||||
g_decoder = dec; |
||||
g_call_id = call_id; |
||||
g_active = 1; |
||||
pthread_mutex_unlock(&g_mtx); |
||||
|
||||
call_set_media_recv_cb(instance_lite_get_instance(), call_audio_media_recv, NULL); |
||||
DEBUG_INFO(DEBUG_CATEGORY_CALL, "call_audio_start: ok call=0x%016llx", |
||||
(unsigned long long)call_id); |
||||
return 0; |
||||
} |
||||
|
||||
int call_audio_feed_pcm(uint64_t call_id, const int16_t* pcm, int count) { |
||||
if (!pcm || count != CALL_AUDIO_FRAME_SAMPLES) |
||||
return -1; |
||||
|
||||
uint8_t opus[256]; |
||||
struct UASYNC* ua = NULL; |
||||
struct UTUN_INSTANCE* inst = NULL; |
||||
int len = -1; |
||||
|
||||
pthread_mutex_lock(&g_mtx); |
||||
if (!g_active || call_id != g_call_id || !g_encoder) { |
||||
pthread_mutex_unlock(&g_mtx); |
||||
return -1; |
||||
} |
||||
len = opus_codec_encode(g_encoder, pcm, count, opus, (int)sizeof(opus)); |
||||
ua = instance_lite_get_uasync(); |
||||
inst = instance_lite_get_instance(); |
||||
pthread_mutex_unlock(&g_mtx); |
||||
|
||||
if (len <= 0 || !ua || !uasync_is_running(ua) || !inst) |
||||
return -1; |
||||
|
||||
struct call_media_arg* a = (struct call_media_arg*)u_malloc(sizeof(*a)); |
||||
if (!a) return -1; |
||||
a->inst = inst; |
||||
a->call_id = call_id; |
||||
a->len = (uint16_t)len; |
||||
memcpy(a->opus, opus, (size_t)len); |
||||
uasync_post(ua, call_send_media_trampoline, a); |
||||
return 0; |
||||
} |
||||
|
||||
void call_audio_stop(void) { |
||||
opus_codec_encoder_t* enc = NULL; |
||||
opus_codec_decoder_t* dec = NULL; |
||||
int was_active = 0; |
||||
|
||||
pthread_mutex_lock(&g_mtx); |
||||
if (g_active) { |
||||
was_active = 1; |
||||
g_active = 0; |
||||
g_call_id = 0; |
||||
enc = g_encoder; g_encoder = NULL; |
||||
dec = g_decoder; g_decoder = NULL; |
||||
} |
||||
pthread_mutex_unlock(&g_mtx); |
||||
|
||||
if (!was_active) |
||||
return; |
||||
|
||||
call_set_media_recv_cb(instance_lite_get_instance(), NULL, NULL); |
||||
if (enc) opus_codec_encoder_destroy(enc); |
||||
if (dec) opus_codec_decoder_destroy(dec); |
||||
DEBUG_INFO(DEBUG_CATEGORY_CALL, "call_audio_stop"); |
||||
} |
||||
|
||||
int call_audio_is_active(uint64_t call_id) { |
||||
pthread_mutex_lock(&g_mtx); |
||||
int active = g_active && call_id == g_call_id; |
||||
pthread_mutex_unlock(&g_mtx); |
||||
return active; |
||||
} |
||||
@ -0,0 +1,52 @@
|
||||
#ifndef CALL_AUDIO_H |
||||
#define CALL_AUDIO_H |
||||
|
||||
/* call_audio.h — аудио-движок P2P-звонка для Android (аналог десктопного CallAudioEngine)
|
||||
* |
||||
* Держит один активный звонок: Opus encoder (PCM→Opus, отправка пиру) и |
||||
* decoder (Opus→PCM, приём от пира). Аудио I/O живёт в Kotlin (AudioRecord/ |
||||
* AudioTrack), кодеки — здесь (Opus есть только в C). |
||||
* |
||||
* Потоки: |
||||
* - feed_pcm: из AudioRecord-потока Kotlin (кодирование + uasync_post) |
||||
* - media_recv: из uasync-потока (call.c) — декодирование + коллбэк PCM |
||||
* - start/stop: из GUI-потока Kotlin (по ACCEPTED/ENDED) |
||||
* Все общие поля защищены мьютексом; encoder/decoder используются строго |
||||
* одним потоком каждый, но освобождаются в stop под тем же мьютексом. */ |
||||
#include <stdint.h> |
||||
|
||||
#ifdef __cplusplus |
||||
extern "C" { |
||||
#endif |
||||
|
||||
/* Параметры аудио: Opus 48kHz mono, кадр 20мс = 960 сэмплов. */ |
||||
#define CALL_AUDIO_SAMPLE_RATE 48000 |
||||
#define CALL_AUDIO_FRAME_MS 20 |
||||
#define CALL_AUDIO_FRAME_SAMPLES (CALL_AUDIO_SAMPLE_RATE * CALL_AUDIO_FRAME_MS / 1000) |
||||
|
||||
/* Запустить полнодуплексное аудио для активного звонка (после ACCEPTED).
|
||||
* Создаёт encoder+decoder, регистрирует приём медиа в call.c. |
||||
* Повторный start с тем же call_id — no-op; с другим — сперва stop. |
||||
* Возвращает 0 при успехе, <0 при ошибке. */ |
||||
int call_audio_start(uint64_t call_id); |
||||
|
||||
/* Отправить один кадр PCM (ровно CALL_AUDIO_FRAME_SAMPLES) пиру.
|
||||
* Кодируется в Opus и уходит через call_send_media (uasync-пост). |
||||
* Можно звать из любого потока. 0 — успех, <0 — звонок не активен/ошибка. */ |
||||
int call_audio_feed_pcm(uint64_t call_id, const int16_t* pcm, int count); |
||||
|
||||
/* Остановить аудио: снять recv-cb, освободить encoder/decoder. */ |
||||
void call_audio_stop(void); |
||||
|
||||
/* Активен ли звонок с данным call_id (для логов/диагностики). */ |
||||
int call_audio_is_active(uint64_t call_id); |
||||
|
||||
/* Коллбэк приёма декодированного PCM пира (960 сэмплов/кадр).
|
||||
* Вызывается в uasync-потоке на каждый принятый Opus-кадр. */ |
||||
typedef void (*call_audio_pcm_recv_fn)(uint64_t call_id, const int16_t* pcm, int count, void* arg); |
||||
void call_audio_set_pcm_recv_cb(call_audio_pcm_recv_fn fn, void* arg); |
||||
|
||||
#ifdef __cplusplus |
||||
} |
||||
#endif |
||||
#endif /* CALL_AUDIO_H */ |
||||
@ -0,0 +1,67 @@
|
||||
#pragma once |
||||
|
||||
#include <cstdint> |
||||
#include <cstring> |
||||
#include <mutex> |
||||
#include <vector> |
||||
|
||||
/* Кольцевой буфер кадров переменной длины (SPSC, mutex-protected).
|
||||
* |
||||
* Producer (uasync-поток) и consumer (аудио-поток). Память предвыделена одним |
||||
* блоком — ноль аллокаций на кадр. При переполнении дропается старейший кадр |
||||
* (семантика джиттер-буфера). |
||||
*/ |
||||
class AudioFrameRing { |
||||
public: |
||||
AudioFrameRing(int capacity, int slotSize) |
||||
: cap_(capacity), slotSize_(slotSize), |
||||
buf_((size_t)capacity * (size_t)slotSize, 0), |
||||
lens_((size_t)capacity, 0) {} |
||||
|
||||
/* Producer: положить кадр. Возвращает false если len > slotSize.
|
||||
* При переполнении дропает старейший кадр (успех). */ |
||||
bool push(const uint8_t* data, int len) { |
||||
if (len <= 0 || len > slotSize_) return false; |
||||
std::lock_guard<std::mutex> lk(mtx_); |
||||
if (count_ == cap_) { |
||||
head_ = (head_ + 1) % cap_; /* drop oldest */ |
||||
count_--; |
||||
} |
||||
std::memcpy(buf_.data() + (size_t)tail_ * slotSize_, data, (size_t)len); |
||||
lens_[(size_t)tail_] = (uint16_t)len; |
||||
tail_ = (tail_ + 1) % cap_; |
||||
count_++; |
||||
return true; |
||||
} |
||||
|
||||
/* Consumer: взять старейший кадр. Возвращает длину (0 = пусто). */ |
||||
int pop(uint8_t* out, int outCap) { |
||||
std::lock_guard<std::mutex> lk(mtx_); |
||||
if (count_ == 0) return 0; |
||||
int len = (int)lens_[(size_t)head_]; |
||||
if (out && len > 0 && len <= outCap) |
||||
std::memcpy(out, buf_.data() + (size_t)head_ * slotSize_, (size_t)len); |
||||
head_ = (head_ + 1) % cap_; |
||||
count_--; |
||||
return len; |
||||
} |
||||
|
||||
void clear() { |
||||
std::lock_guard<std::mutex> lk(mtx_); |
||||
head_ = tail_ = count_ = 0; |
||||
} |
||||
|
||||
int size() const { |
||||
std::lock_guard<std::mutex> lk(mtx_); |
||||
return count_; |
||||
} |
||||
|
||||
int capacity() const { return cap_; } |
||||
|
||||
private: |
||||
int cap_, slotSize_; |
||||
int head_ = 0, tail_ = 0, count_ = 0; |
||||
std::vector<uint8_t> buf_; |
||||
std::vector<uint16_t> lens_; |
||||
mutable std::mutex mtx_; |
||||
}; |
||||
@ -0,0 +1,183 @@
|
||||
#include "call_audio_engine.h" |
||||
#include "sound_manager.h" |
||||
#include "../transport/gui_bridge.h" |
||||
|
||||
#include "miniaudio.h" |
||||
|
||||
extern "C" { |
||||
#include "call/call_audio.h" |
||||
#include "../../lib/debug_config.h" |
||||
} |
||||
|
||||
#include <QTimer> |
||||
#include <cstring> |
||||
|
||||
static CallAudioEngine* g_callAudioEngine = nullptr; |
||||
|
||||
static void callAudioDataCallback(ma_device* dev, void* out, const void* in, ma_uint32 frames) { |
||||
CallAudioEngine* e = (CallAudioEngine*)dev->pUserData; |
||||
if (e) e->dataCallback(in, out, frames); |
||||
} |
||||
|
||||
CallAudioEngine* CallAudioEngine::instance() { |
||||
if (!g_callAudioEngine) g_callAudioEngine = new CallAudioEngine(); |
||||
return g_callAudioEngine; |
||||
} |
||||
|
||||
int CallAudioEngine::start(quint64 callId) { |
||||
if (m_active) { |
||||
if (m_callId == callId) return 0; |
||||
stop(); |
||||
} |
||||
m_callId = callId; |
||||
|
||||
struct UTUN_INSTANCE* inst = gui_bridge_get_inst(); |
||||
if (!inst) { |
||||
DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "CallAudio: no instance, cannot start"); |
||||
return -1; |
||||
} |
||||
if (call_audio_init(inst) != 0) { |
||||
DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "CallAudio: call_audio_init failed"); |
||||
return -1; |
||||
} |
||||
if (call_audio_start(callId) != 0) { |
||||
DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "CallAudio: call_audio_start failed call=%016llx", (unsigned long long)callId); |
||||
return -1; |
||||
} |
||||
|
||||
m_captureBuf.assign(CALL_AUDIO_FRAME_SAMPLES, 0); |
||||
m_capturePos = 0; |
||||
m_muted = false; |
||||
|
||||
if (initDevice() != 0) { |
||||
call_audio_release(callId); |
||||
return -1; |
||||
} |
||||
|
||||
m_active = true; |
||||
DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "CallAudio: started call=%016llx", (unsigned long long)callId); |
||||
return 0; |
||||
} |
||||
|
||||
int CallAudioEngine::initDevice() { |
||||
ma_context* ctx = SoundManager::instance()->context(); |
||||
if (!ctx) { |
||||
DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "CallAudio: no audio context"); |
||||
return -1; |
||||
} |
||||
|
||||
m_device = new ma_device; |
||||
std::memset(m_device, 0, sizeof(*m_device)); |
||||
ma_device_config config = ma_device_config_init(ma_device_type_duplex); |
||||
config.capture.format = ma_format_s16; |
||||
config.capture.channels = 1; |
||||
config.playback.format = ma_format_s16; |
||||
config.playback.channels = 1; |
||||
config.sampleRate = CALL_AUDIO_SAMPLE_RATE; |
||||
config.periodSizeInFrames = 480; |
||||
config.dataCallback = callAudioDataCallback; |
||||
config.pUserData = this; |
||||
|
||||
if (m_playbackDeviceIndex >= 0) { |
||||
ma_device_info* pPlaybackDevices = nullptr; |
||||
ma_uint32 playbackCount = 0; |
||||
if (ma_context_get_devices(ctx, &pPlaybackDevices, &playbackCount, nullptr, nullptr) == MA_SUCCESS && |
||||
(ma_uint32)m_playbackDeviceIndex < playbackCount) { |
||||
config.playback.pDeviceID = &pPlaybackDevices[m_playbackDeviceIndex].id; |
||||
DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "CallAudio: using playback device[%d]: %s", |
||||
m_playbackDeviceIndex, pPlaybackDevices[m_playbackDeviceIndex].name); |
||||
} else { |
||||
DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "CallAudio: playback device[%d] not found, using default", |
||||
m_playbackDeviceIndex); |
||||
} |
||||
} |
||||
|
||||
if (ma_device_init(ctx, &config, m_device) != MA_SUCCESS) { |
||||
DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "CallAudio: device init failed, trying default"); |
||||
config.playback.pDeviceID = nullptr; |
||||
if (ma_device_init(ctx, &config, m_device) != MA_SUCCESS) { |
||||
DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "CallAudio: default device init also failed"); |
||||
delete m_device; m_device = nullptr; |
||||
return -1; |
||||
} |
||||
} |
||||
if (ma_device_start(m_device) != MA_SUCCESS) { |
||||
DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "CallAudio: device start failed"); |
||||
ma_device_uninit(m_device); |
||||
delete m_device; m_device = nullptr; |
||||
return -1; |
||||
} |
||||
m_deviceStarted = true; |
||||
m_capturePos = 0; |
||||
return 0; |
||||
} |
||||
|
||||
void CallAudioEngine::setMuted(bool muted) { |
||||
if (m_muted.load() == muted) return; |
||||
m_muted = muted; |
||||
DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "CallAudio: mute %s", muted ? "on" : "off"); |
||||
} |
||||
|
||||
void CallAudioEngine::setPlaybackDeviceIndex(int index) { |
||||
if (index == m_playbackDeviceIndex) return; |
||||
int prev = m_playbackDeviceIndex; |
||||
m_playbackDeviceIndex = index; |
||||
if (m_active) { |
||||
DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "CallAudio: switch playback device %d -> %d", prev, index); |
||||
teardownDevice(); |
||||
if (initDevice() != 0) |
||||
DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "CallAudio: switch playback device failed (kept index=%d)", index); |
||||
} |
||||
} |
||||
|
||||
void CallAudioEngine::beginEnd(quint64 callId) { |
||||
if (!m_active || m_callId != callId) return; |
||||
call_audio_begin_end(callId); /* старт тона завершения */ |
||||
QTimer::singleShot(500, this, [this, callId]() { |
||||
if (m_callId == callId) stop(); |
||||
}); |
||||
} |
||||
|
||||
void CallAudioEngine::stop() { |
||||
if (!m_active && !m_device && !m_deviceStarted) return; |
||||
m_active = false; |
||||
m_muted = false; |
||||
quint64 cid = m_callId.load(); |
||||
|
||||
teardownDevice(); |
||||
call_audio_release(cid); |
||||
|
||||
m_callId = 0; |
||||
DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "CallAudio: stopped"); |
||||
} |
||||
|
||||
void CallAudioEngine::teardownDevice() { |
||||
if (m_deviceStarted) { |
||||
ma_device_stop(m_device); |
||||
ma_device_uninit(m_device); |
||||
m_deviceStarted = false; |
||||
} |
||||
if (m_device) { delete m_device; m_device = nullptr; } |
||||
} |
||||
|
||||
void CallAudioEngine::dataCallback(const void* in, void* out, unsigned int frames) { |
||||
const int16_t* in16 = (const int16_t*)in; |
||||
int16_t* out16 = (int16_t*)out; |
||||
|
||||
/* захват: копим 960 samples → encode+send (сервисный слой). mute → тишина. */ |
||||
if (in16) { |
||||
bool muted = m_muted.load(); |
||||
for (unsigned int i = 0; i < frames; i++) { |
||||
m_captureBuf[m_capturePos++] = muted ? 0 : in16[i]; |
||||
if (m_capturePos >= (int)m_captureBuf.size()) { |
||||
call_audio_feed_pcm(m_callId.load(), m_captureBuf.data(), (int)m_captureBuf.size()); |
||||
m_capturePos = 0; |
||||
} |
||||
} |
||||
} |
||||
|
||||
/* воспроизведение: финальный PCM из сервисного слоя (jitter+stretch+тоны) */ |
||||
int n = call_audio_pull_pcm(m_callId.load(), out16, (int)frames); |
||||
if (n < (int)frames) |
||||
std::memset(out16 + n, 0, (size_t)(frames - n) * sizeof(int16_t)); |
||||
} |
||||
@ -0,0 +1,62 @@
|
||||
#pragma once |
||||
|
||||
#include <QObject> |
||||
#include <atomic> |
||||
#include <cstdint> |
||||
#include <vector> |
||||
|
||||
struct ma_device; |
||||
|
||||
/* Аудио-контур звонка (desktop): только miniaudio I/O + захват/воспроизведение.
|
||||
* Весь аудиопроцессинг (Opus, джиттер-буфер, time-stretch, тоны) — в сервисном |
||||
* слое src/call/call_audio.c; сюда приходит/отсюда уходит финальный PCM. |
||||
* |
||||
* Потоки: |
||||
* - аудио-поток (miniaudio callback): capture → call_audio_feed_pcm, |
||||
* call_audio_pull_pcm → playback; |
||||
* - GUI-поток: start()/beginEnd()/stop() по ACCEPTED/ENDED. */ |
||||
class CallAudioEngine : public QObject { |
||||
Q_OBJECT |
||||
public: |
||||
static CallAudioEngine* instance(); |
||||
|
||||
/* Запустить полнодуплекс для звонка (после ACCEPTED). 0 = успех. */ |
||||
int start(quint64 callId); |
||||
|
||||
/* По CALL_ENDED: тон завершения (~450мс), затем авто-teardown. */ |
||||
void beginEnd(quint64 callId); |
||||
|
||||
/* Немедленный teardown (device + освобождение call_audio). */ |
||||
void stop(); |
||||
|
||||
bool isActive() const { return m_active.load(); } |
||||
quint64 callId() const { return m_callId.load(); } |
||||
|
||||
/* Mute: пир слышит тишину (отправляем нулевые кадры вместо захваченного PCM). */ |
||||
void setMuted(bool muted); |
||||
bool isMuted() const { return m_muted.load(); } |
||||
|
||||
/* Устройство вывода звонка (-1 = default). Смена на лету пересоздаёт ma_device. */ |
||||
void setPlaybackDeviceIndex(int index); |
||||
int playbackDeviceIndex() const { return m_playbackDeviceIndex; } |
||||
|
||||
/* аудио-поток: miniaudio data callback (захват + воспроизведение) */ |
||||
void dataCallback(const void* in, void* out, unsigned int frames); |
||||
|
||||
private: |
||||
CallAudioEngine() = default; |
||||
void teardownDevice(); |
||||
int initDevice(); |
||||
|
||||
std::atomic<bool> m_active{false}; |
||||
std::atomic<uint64_t> m_callId{0}; |
||||
std::atomic<bool> m_muted{false}; |
||||
|
||||
int m_playbackDeviceIndex = -1; /* только GUI-поток */ |
||||
|
||||
ma_device* m_device = nullptr; |
||||
bool m_deviceStarted = false; |
||||
|
||||
std::vector<int16_t> m_captureBuf; /* 960 samples, аудио-поток */ |
||||
int m_capturePos = 0; |
||||
}; |
||||
@ -0,0 +1,179 @@
|
||||
#include "callwindow.h" |
||||
#include "../transport/gui_bridge.h" |
||||
#include "call_audio_engine.h" |
||||
#include "sound_manager.h" |
||||
|
||||
#include <QVBoxLayout> |
||||
#include <QHBoxLayout> |
||||
#include <QLabel> |
||||
#include <QPushButton> |
||||
#include <QComboBox> |
||||
#include <QTimer> |
||||
#include <QDateTime> |
||||
#include <QCloseEvent> |
||||
#include <cstring> |
||||
|
||||
extern "C" { |
||||
#include "call/call.h" |
||||
#include "../../lib/mem.h" |
||||
#include "../../lib/debug_config.h" |
||||
} |
||||
|
||||
CallWindow::CallWindow(QWidget* parent) |
||||
: QWidget(parent, Qt::Window) { |
||||
setWindowTitle(QStringLiteral("Звонок")); |
||||
setFixedSize(320, 300); |
||||
setAttribute(Qt::WA_DeleteOnClose, false); |
||||
|
||||
m_peerLabel = new QLabel(this); |
||||
m_peerLabel->setAlignment(Qt::AlignCenter); |
||||
m_statusLabel = new QLabel(this); |
||||
m_statusLabel->setAlignment(Qt::AlignCenter); |
||||
m_durationLabel = new QLabel(QStringLiteral("00:00"), this); |
||||
m_durationLabel->setAlignment(Qt::AlignCenter); |
||||
|
||||
m_acceptBtn = new QPushButton(QStringLiteral("Принять"), this); |
||||
m_declineBtn = new QPushButton(QStringLiteral("Отклонить"), this); |
||||
m_hangupBtn = new QPushButton(QStringLiteral("Завершить"), this); |
||||
|
||||
m_muteBtn = new QPushButton(QStringLiteral("Mute"), this); |
||||
m_muteBtn->setCheckable(true); |
||||
m_muteBtn->setStyleSheet( |
||||
"QPushButton { border: 1px solid palette(mid); border-radius: 4px;" |
||||
" padding: 4px 12px; font-size: 12px; background: palette(button); }" |
||||
"QPushButton:checked { background: #c0392b; color: white; }"); |
||||
|
||||
m_deviceCombo = new QComboBox(this); |
||||
m_deviceCombo->setStyleSheet( |
||||
"QComboBox { border: 1px solid palette(mid); border-radius: 4px;" |
||||
" padding: 6px 10px; font-size: 13px; background: palette(base); }"); |
||||
|
||||
QVBoxLayout* lay = new QVBoxLayout(this); |
||||
lay->addWidget(m_peerLabel); |
||||
lay->addWidget(m_statusLabel); |
||||
lay->addWidget(m_durationLabel); |
||||
QHBoxLayout* btn = new QHBoxLayout(); |
||||
btn->addWidget(m_acceptBtn); |
||||
btn->addWidget(m_declineBtn); |
||||
btn->addWidget(m_hangupBtn); |
||||
lay->addLayout(btn); |
||||
lay->addWidget(m_muteBtn); |
||||
lay->addWidget(m_deviceCombo); |
||||
|
||||
connect(m_acceptBtn, &QPushButton::clicked, this, &CallWindow::onAccept); |
||||
connect(m_declineBtn, &QPushButton::clicked, this, &CallWindow::onDecline); |
||||
connect(m_hangupBtn, &QPushButton::clicked, this, &CallWindow::onHangup); |
||||
connect(m_muteBtn, &QPushButton::toggled, this, &CallWindow::onMuteToggled); |
||||
connect(m_deviceCombo, QOverload<int>::of(&QComboBox::activated), this, &CallWindow::onDeviceChanged); |
||||
|
||||
m_durationTimer = new QTimer(this); |
||||
m_durationTimer->setInterval(1000); |
||||
connect(m_durationTimer, &QTimer::timeout, this, &CallWindow::updateDuration); |
||||
|
||||
setState(Outgoing); |
||||
} |
||||
|
||||
CallWindow::~CallWindow() = default; |
||||
|
||||
void CallWindow::setPeerName(const QString& name) { |
||||
m_peerLabel->setText(name); |
||||
} |
||||
|
||||
void CallWindow::setState(State s) { |
||||
m_state = s; |
||||
switch (s) { |
||||
case Outgoing: |
||||
m_statusLabel->setText(QStringLiteral("Звоним…")); |
||||
m_durationLabel->hide(); |
||||
break; |
||||
case Incoming: |
||||
m_statusLabel->setText(QStringLiteral("Входящий звонок…")); |
||||
m_durationLabel->hide(); |
||||
break; |
||||
case Active: |
||||
m_statusLabel->setText(QStringLiteral("Разговор")); |
||||
m_durationLabel->setText(QStringLiteral("00:00")); |
||||
m_durationLabel->show(); |
||||
m_startMs = QDateTime::currentMSecsSinceEpoch(); |
||||
m_durationTimer->start(); |
||||
populateDeviceCombo(); |
||||
m_muteBtn->setChecked(false); |
||||
break; |
||||
case Ended: |
||||
m_statusLabel->setText(QStringLiteral("Завершён")); |
||||
m_durationTimer->stop(); |
||||
break; |
||||
} |
||||
updateButtons(); |
||||
} |
||||
|
||||
void CallWindow::populateDeviceCombo() { |
||||
m_deviceCombo->blockSignals(true); |
||||
m_deviceCombo->clear(); |
||||
m_deviceCombo->addItem(QStringLiteral("Default (auto)"), -1); |
||||
const auto devs = SoundManager::enumeratePlaybackDevices(); |
||||
for (int i = 0; i < devs.size(); i++) { |
||||
QString label = devs[i].name; |
||||
if (devs[i].isDefault) label += QStringLiteral(" (default)"); |
||||
m_deviceCombo->addItem(label, i); |
||||
} |
||||
int cur = CallAudioEngine::instance()->playbackDeviceIndex(); |
||||
for (int i = 0; i < m_deviceCombo->count(); i++) { |
||||
if (m_deviceCombo->itemData(i).toInt() == cur) { m_deviceCombo->setCurrentIndex(i); break; } |
||||
} |
||||
m_deviceCombo->blockSignals(false); |
||||
} |
||||
|
||||
void CallWindow::updateButtons() { |
||||
m_acceptBtn->setVisible(m_state == Incoming); |
||||
m_declineBtn->setVisible(m_state == Incoming); |
||||
m_hangupBtn->setVisible(m_state == Outgoing || m_state == Active); |
||||
m_muteBtn->setVisible(m_state == Active); |
||||
m_deviceCombo->setVisible(m_state == Active); |
||||
} |
||||
|
||||
void CallWindow::postCallFn(void (*fn)(void*), uint64_t callId) { |
||||
struct call_id_arg* a = (struct call_id_arg*)u_malloc(sizeof(*a)); |
||||
if (!a) return; |
||||
a->inst = gui_bridge_get_inst(); |
||||
a->call_id = callId; |
||||
gui_bridge_post_uasync_fn(fn, a); |
||||
} |
||||
|
||||
void CallWindow::onAccept() { |
||||
setState(Active); |
||||
postCallFn(call_accept_trampoline, m_callId); |
||||
} |
||||
|
||||
void CallWindow::onDecline() { |
||||
postCallFn(call_decline_trampoline, m_callId); |
||||
} |
||||
|
||||
void CallWindow::onHangup() { |
||||
postCallFn(call_hangup_trampoline, m_callId); |
||||
} |
||||
|
||||
void CallWindow::onMuteToggled(bool checked) { |
||||
CallAudioEngine::instance()->setMuted(checked); |
||||
m_muteBtn->setText(checked ? QStringLiteral("Вкл. микрофон") : QStringLiteral("Mute")); |
||||
} |
||||
|
||||
void CallWindow::onDeviceChanged(int index) { |
||||
int devIdx = m_deviceCombo->itemData(index).toInt(); |
||||
CallAudioEngine::instance()->setPlaybackDeviceIndex(devIdx); |
||||
} |
||||
|
||||
void CallWindow::closeEvent(QCloseEvent* event) { |
||||
if (m_state == Active) postCallFn(call_hangup_trampoline, m_callId); |
||||
emit closed(m_callId); |
||||
event->accept(); |
||||
} |
||||
|
||||
void CallWindow::updateDuration() { |
||||
if (m_state != Active) return; |
||||
qint64 ms = QDateTime::currentMSecsSinceEpoch() - m_startMs; |
||||
qint64 s = ms / 1000; |
||||
m_durationLabel->setText(QStringLiteral("%1:%2") |
||||
.arg(s / 60, 2, 10, QChar('0')) |
||||
.arg(s % 60, 2, 10, QChar('0'))); |
||||
} |
||||
@ -0,0 +1,60 @@
|
||||
#pragma once |
||||
|
||||
#include <QWidget> |
||||
#include <cstdint> |
||||
|
||||
class QLabel; |
||||
class QPushButton; |
||||
class QComboBox; |
||||
class QTimer; |
||||
|
||||
/* Окно звонка: состояния Outgoing (звоним), Incoming (принять/отклонить),
|
||||
* Active (идёт разговор, таймер), Ended (завершён). */ |
||||
class CallWindow : public QWidget { |
||||
Q_OBJECT |
||||
public: |
||||
enum State { Outgoing, Incoming, Active, Ended }; |
||||
|
||||
explicit CallWindow(QWidget* parent = nullptr); |
||||
~CallWindow() override; |
||||
|
||||
void setCallId(quint64 id) { m_callId = id; } |
||||
quint64 callId() const { return m_callId; } |
||||
|
||||
void setPeerName(const QString& name); |
||||
void setState(State s); |
||||
State state() const { return m_state; } |
||||
|
||||
signals: |
||||
void closed(quint64 callId); |
||||
|
||||
protected: |
||||
void closeEvent(QCloseEvent* event) override; |
||||
|
||||
private slots: |
||||
void onAccept(); |
||||
void onDecline(); |
||||
void onHangup(); |
||||
void updateDuration(); |
||||
void onMuteToggled(bool checked); |
||||
void onDeviceChanged(int index); |
||||
|
||||
private: |
||||
void updateButtons(); |
||||
void populateDeviceCombo(); |
||||
void postCallFn(void (*fn)(void*), uint64_t callId); |
||||
|
||||
quint64 m_callId = 0; |
||||
State m_state = Outgoing; |
||||
qint64 m_startMs = 0; |
||||
|
||||
QLabel* m_peerLabel; |
||||
QLabel* m_statusLabel; |
||||
QLabel* m_durationLabel; |
||||
QPushButton* m_acceptBtn; |
||||
QPushButton* m_declineBtn; |
||||
QPushButton* m_hangupBtn; |
||||
QPushButton* m_muteBtn; |
||||
QComboBox* m_deviceCombo; |
||||
QTimer* m_durationTimer; |
||||
}; |
||||
@ -0,0 +1,101 @@
|
||||
// test_audio_jitter.cpp — проверка адаптивного джиттер-буфера (SoundTouch).
|
||||
//
|
||||
// 1. Нет потерь до ёмкости (50 кадров = 1000мс), дроп после переполнения.
|
||||
// 2. Догон: глубокий буфер → темп растёт выше 1.0x (ускоренное воспроизведение).
|
||||
// 3. Стационар: при неглубоком буфере темп остаётся ~1.0x (без runaway).
|
||||
|
||||
#include "call/audio_jitter.h" |
||||
|
||||
#include <cmath> |
||||
#include <cstdio> |
||||
#include <cstring> |
||||
#include <vector> |
||||
|
||||
static int g_fail = 0; |
||||
|
||||
#define CHECK(cond, ...) do { \ |
||||
if (!(cond)) { g_fail++; printf("FAIL: " __VA_ARGS__); printf("\n"); } \
|
||||
} while (0) |
||||
|
||||
static void make_sine(std::vector<int16_t>& frame, double freq, double phase0) { |
||||
for (size_t i = 0; i < frame.size(); i++) |
||||
frame[i] = (int16_t)(sin(phase0 + 2.0 * M_PI * freq * (double)i / 48000.0) * 8000.0); |
||||
} |
||||
|
||||
int main(void) { |
||||
const int FRAME = 960; |
||||
const int CAP = 50; |
||||
std::vector<int16_t> frame(FRAME, 0); |
||||
std::vector<int16_t> out(480, 0); |
||||
int buffer_ms = 0, tempo_x100 = 0; |
||||
|
||||
/* ── 1. ёмкость и дроп ── */ |
||||
{ |
||||
ajb* j = ajb_create(48000, 1, FRAME, 20, CAP); |
||||
CHECK(j != nullptr, "create failed"); |
||||
|
||||
make_sine(frame, 440.0, 0.0); |
||||
for (int i = 0; i < CAP; i++) ajb_push(j, frame.data(), FRAME); |
||||
ajb_get_stats(j, &buffer_ms, &tempo_x100); |
||||
printf(" capacity: pushed %d frames, buffer_ms=%d dropped=%u\n", CAP, buffer_ms, (unsigned)ajb_dropped(j)); |
||||
CHECK(ajb_dropped(j) == 0, "expected no drop at capacity, dropped=%u", (unsigned)ajb_dropped(j)); |
||||
CHECK(buffer_ms == CAP * 20, "expected buffer_ms=%d, got %d", CAP * 20, buffer_ms); |
||||
|
||||
for (int i = 0; i < 5; i++) ajb_push(j, frame.data(), FRAME); |
||||
printf(" overflow: dropped=%u (expect 5)\n", (unsigned)ajb_dropped(j)); |
||||
CHECK(ajb_dropped(j) == 5, "expected 5 drops, got %u", (unsigned)ajb_dropped(j)); |
||||
ajb_destroy(j); |
||||
} |
||||
|
||||
/* ── 2. догон: глубокий буфер → tempo > 1.0x ── */ |
||||
{ |
||||
ajb* j = ajb_create(48000, 1, FRAME, 20, CAP); |
||||
make_sine(frame, 440.0, 0.0); |
||||
for (int i = 0; i < 40; i++) ajb_push(j, frame.data(), FRAME); /* 800мс burst */ |
||||
|
||||
int max_tempo = 100; |
||||
int total_out = 0; |
||||
for (int it = 0; it < 200; it++) { |
||||
ajb_get_stats(j, &buffer_ms, &tempo_x100); |
||||
if (buffer_ms == 0) break; |
||||
if (tempo_x100 > max_tempo) max_tempo = tempo_x100; |
||||
int n = ajb_pull(j, out.data(), (int)out.size()); |
||||
total_out += n; |
||||
} |
||||
ajb_get_stats(j, &buffer_ms, &tempo_x100); |
||||
printf(" catch-up: max_tempo_x100=%d total_out=%d final_buffer_ms=%d\n", |
||||
max_tempo, total_out, buffer_ms); |
||||
CHECK(max_tempo > 110, "expected tempo to exceed 1.10x during deep buffer, got %d", max_tempo); |
||||
CHECK(total_out < 40 * FRAME, "expected output < input (stretch), out=%d in=%d", total_out, 40 * FRAME); |
||||
CHECK(buffer_ms == 0, "expected drained buffer, buffer_ms=%d", buffer_ms); |
||||
ajb_destroy(j); |
||||
} |
||||
|
||||
/* ── 3. стационар: feed 1 frame → pull 1 frame, tempo ~1.0x ── */ |
||||
{ |
||||
ajb* j = ajb_create(48000, 1, FRAME, 20, CAP); |
||||
make_sine(frame, 440.0, 0.0); |
||||
int max_tempo = 100; |
||||
for (int i = 0; i < 60; i++) { |
||||
ajb_push(j, frame.data(), FRAME); |
||||
int got = 0; |
||||
while (got < FRAME) { |
||||
int n = ajb_pull(j, out.data(), (int)out.size()); |
||||
if (n <= 0) break; |
||||
got += n; |
||||
} |
||||
ajb_get_stats(j, &buffer_ms, &tempo_x100); |
||||
if (tempo_x100 > max_tempo) max_tempo = tempo_x100; |
||||
} |
||||
printf(" steady-state: max_tempo_x100=%d (expect ~100)\n", max_tempo); |
||||
CHECK(max_tempo <= 110, "tempo should stay ~1.0x in steady state, got %d", max_tempo); |
||||
ajb_destroy(j); |
||||
} |
||||
|
||||
if (g_fail == 0) { |
||||
printf("TEST PASSED\n"); |
||||
return 0; |
||||
} |
||||
printf("TEST FAILED (%d checks)\n", g_fail); |
||||
return 1; |
||||
} |
||||
Loading…
Reference in new issue