Browse Source
Новый модуль src/radio (radio/radio_audio/radio_headless) — push-to-talk радио поверх topo-групп, доставка аудио в канал, headless-режим и тесты. Интеграция в chatgui (ptt_hotkey_manager, radio_audio_engine, радионастройки, вызовы) и chatgui-android (RadioAudioEngine, RadioOverlayService, UI).proxy
69 changed files with 5372 additions and 51 deletions
@ -0,0 +1,432 @@
|
||||
// radio.c — рация (PTT walkie-talkie): мультикаст голоса по CHAT-группе.
|
||||
//
|
||||
// Флудит Opus-кадры (ETCP_ID_RADIO) всем подписчикам канала — узлам с
|
||||
// TOPO_FLAG_RADIO в NODEINFO. Пакет несёт hop_list (узлы, через которые он уже
|
||||
// прошёл) для защиты от циклов и форвардится только в те ветви, за которыми есть
|
||||
// хотя бы один подписчик (RPF-обрезание по BGP-путям). Кадры нумеруются от 0,
|
||||
// последний кадр передачи помечается FIN.
|
||||
//
|
||||
// Приём: если мы подписаны (group->radio_active) — доставляем кадр в frame_fn
|
||||
// (аудио-движок radio_audio). Независимо от подписки форвардим дальше.
|
||||
// Все функции — в uasync-потоке.
|
||||
|
||||
#include <stdlib.h> |
||||
#include <string.h> |
||||
#include "../lib/platform_compat.h" |
||||
#include "../lib/debug_config.h" |
||||
#include "../lib/mem.h" |
||||
#include "../lib/u_async.h" |
||||
#include "../lib/ll_queue.h" |
||||
#include "../transport_layer/etcp_api.h" |
||||
#include "../transport_layer/etcp.h" |
||||
#include "../routing_layer/topo_node.h" |
||||
#include "../routing_layer/topo_group.h" |
||||
#include "../chat/chat_event.h" |
||||
#include "radio.h" |
||||
#include "radio_proto.h" |
||||
|
||||
#define RADIO_ID "radio" |
||||
|
||||
#define RADIO_MAX_TALKERS 16 /* максимум одновременно говорящих источников */ |
||||
|
||||
struct radio_ctx { |
||||
struct TOPO_GROUP* group; |
||||
uint16_t stream_id; /* id текущей передачи (talk-burst) */ |
||||
uint16_t seq; /* seq следующего исходящего кадра */ |
||||
int talking; /* 1 = идёт передача (PTT зажата) */ |
||||
uint32_t c_tx_frames; /* отправлено кадров */ |
||||
uint32_t c_rx_frames; /* доставлено локально (мы подписаны) */ |
||||
uint32_t c_fwd_frames; /* форварднуто кадров */ |
||||
uint32_t c_loop_dropped; /* дропнуто из-за цикла (self в hop_list) */ |
||||
uint32_t c_nosub_dropped; /* не отправлено: за соседом нет подписчиков */ |
||||
uint32_t c_bad_dropped; /* дропнуто некорректных пакетов */ |
||||
struct radio_talker { uint64_t src; uint16_t stream; } talkers[RADIO_MAX_TALKERS]; /* активные говорящие */ |
||||
}; |
||||
|
||||
struct radio_instance { |
||||
radio_frame_fn frame_fn; |
||||
void* frame_arg; |
||||
}; |
||||
|
||||
static struct radio_ctx* radio_of(struct TOPO_GROUP* group) { |
||||
return group ? group->radio : NULL; |
||||
} |
||||
|
||||
static struct radio_instance* radio_inst(struct UTUN_INSTANCE* inst) { |
||||
return inst ? inst->radio : NULL; |
||||
} |
||||
|
||||
int radio_init(struct TOPO_GROUP* group) { |
||||
if (!group) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: init null group", RADIO_ID); return -1; } |
||||
if (group->radio) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: already initialized grp=%016llx", RADIO_ID, (unsigned long long)group->group_id); return -1; } |
||||
|
||||
struct radio_ctx* ctx = u_calloc(1, sizeof(struct radio_ctx)); |
||||
if (!ctx) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: alloc failed", RADIO_ID); return -1; } |
||||
ctx->group = group; |
||||
ctx->stream_id = (uint16_t)(get_time_tb() & 0xFFFF); |
||||
group->radio = ctx; |
||||
|
||||
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: init ok grp=%016llx", RADIO_ID, (unsigned long long)group->group_id); |
||||
return 0; |
||||
} |
||||
|
||||
void radio_destroy(struct TOPO_GROUP* group) { |
||||
if (!group || !group->radio) return; |
||||
struct radio_ctx* ctx = group->radio; |
||||
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: destroy grp=%016llx (tx=%u rx=%u fwd=%u loop=%u nosub=%u bad=%u)", |
||||
RADIO_ID, (unsigned long long)group->group_id, |
||||
ctx->c_tx_frames, ctx->c_rx_frames, ctx->c_fwd_frames, |
||||
ctx->c_loop_dropped, ctx->c_nosub_dropped, ctx->c_bad_dropped); |
||||
u_free(ctx); |
||||
group->radio = NULL; |
||||
} |
||||
|
||||
/* ── сборка payload ── */ |
||||
|
||||
/* Пишет payload рации (начиная с group_id) в buf, возвращает длину. */ |
||||
static size_t radio_build(uint8_t* buf, uint64_t group_id, uint64_t src, uint16_t stream, |
||||
uint16_t seq, uint8_t flags, const uint64_t* hops, uint8_t hop_count, |
||||
const uint8_t* opus, int opus_len) { |
||||
size_t off = 0; |
||||
memcpy(buf + off, &group_id, 8); off += 8; |
||||
memcpy(buf + off, &src, 8); off += 8; |
||||
memcpy(buf + off, &stream, 2); off += 2; |
||||
memcpy(buf + off, &seq, 2); off += 2; |
||||
buf[off++] = flags; |
||||
buf[off++] = hop_count; |
||||
if (hop_count) { memcpy(buf + off, hops, (size_t)hop_count * 8); off += (size_t)hop_count * 8; } |
||||
if (opus_len > 0) { memcpy(buf + off, opus, (size_t)opus_len); off += (size_t)opus_len; } |
||||
return off; |
||||
} |
||||
|
||||
/* ── отправка ── */ |
||||
|
||||
static void radio_send_to(struct TOPO_GROUP* group, struct ETCP_CONN* conn, |
||||
const uint8_t* payload, size_t payload_len) { |
||||
struct ll_entry* ee = queue_entry_new(0); |
||||
if (!ee) return; |
||||
uint8_t* pkt = u_malloc(1 + payload_len); |
||||
if (!pkt) { queue_entry_free(ee); return; } |
||||
pkt[0] = ETCP_ID_RADIO; |
||||
memcpy(pkt + 1, payload, payload_len); |
||||
ee->dgram = pkt; |
||||
ee->len = (size_t)(1 + payload_len); |
||||
if (etcp_send(conn, ee) != 0) { u_free(pkt); queue_entry_free(ee); } |
||||
} |
||||
|
||||
/* Есть ли за conn (не считая уже пройденных hop_list) хотя бы один подписчик рации. */ |
||||
static int radio_neighbor_has_subscriber(struct TOPO_GROUP* group, struct ETCP_CONN* conn, |
||||
const uint64_t* hop_list, uint8_t hop_count) { |
||||
if (!group || !conn || !group->nodes) return 0; |
||||
uint64_t self = group->instance ? group->instance->node_id : 0; |
||||
struct ll_entry* ne = group->nodes->head; |
||||
while (ne) { |
||||
struct TOPO_GROUP_NODE* nq = (struct TOPO_GROUP_NODE*)ne; |
||||
if (nq->radio && nq->node_id != self) { |
||||
int visited = 0; |
||||
for (int i = 0; i < hop_count; i++) if (hop_list[i] == nq->node_id) { visited = 1; break; } |
||||
if (!visited) { |
||||
struct ll_entry* pe = nq->paths ? nq->paths->head : NULL; |
||||
while (pe) { |
||||
struct TOPO_NODEPATH* p = (struct TOPO_NODEPATH*)pe; |
||||
if (p->conn == conn) return 1; |
||||
pe = pe->next; |
||||
} |
||||
} |
||||
} |
||||
ne = ne->next; |
||||
} |
||||
return 0; |
||||
} |
||||
|
||||
/* Флудит payload (уже содержит group_id..hop_list..opus) всем соседям, кроме from_conn. */ |
||||
static void radio_flood(struct TOPO_GROUP* group, struct ETCP_CONN* from_conn, |
||||
const uint8_t* payload, size_t payload_len, |
||||
const uint64_t* hop_list, uint8_t hop_count) { |
||||
struct radio_ctx* ctx = radio_of(group); |
||||
if (!ctx || !group->senders_list) return; |
||||
struct ll_entry* se = group->senders_list->head; |
||||
while (se) { |
||||
struct TOPO_GROUP_CONN_ITEM* item = (struct TOPO_GROUP_CONN_ITEM*)se->data; |
||||
if (item && item->conn && item->conn != from_conn) { |
||||
if (radio_neighbor_has_subscriber(group, item->conn, hop_list, hop_count)) { |
||||
radio_send_to(group, item->conn, payload, payload_len); |
||||
ctx->c_fwd_frames++; |
||||
} else { |
||||
ctx->c_nosub_dropped++; |
||||
} |
||||
} |
||||
se = se->next; |
||||
} |
||||
} |
||||
|
||||
/* ── индикация говорящих ── */ |
||||
|
||||
static void radio_talk_event(struct TOPO_GROUP* group, uint64_t src, uint16_t stream, uint8_t on) { |
||||
uint8_t buf[19]; |
||||
size_t off = 0; |
||||
memcpy(buf + off, &group->group_id, 8); off += 8; |
||||
memcpy(buf + off, &src, 8); off += 8; |
||||
memcpy(buf + off, &stream, 2); off += 2; |
||||
buf[off++] = on; |
||||
chat_event_post(group->instance, CHAT_EVT_RADIO_TALK, buf, (int)off); |
||||
DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "%s: talk %s src=%016llx stream=%u", |
||||
RADIO_ID, on ? "START" : "STOP", (unsigned long long)src, stream); |
||||
} |
||||
|
||||
/* зафиксировать начало передачи: пост on=1, если это новый поток источника */ |
||||
static void radio_talker_on(struct radio_ctx* ctx, uint64_t src, uint16_t stream) { |
||||
for (int i = 0; i < RADIO_MAX_TALKERS; i++) { |
||||
if (ctx->talkers[i].src == src) { |
||||
if (ctx->talkers[i].stream != stream) { |
||||
ctx->talkers[i].stream = stream; |
||||
radio_talk_event(ctx->group, src, stream, 1); |
||||
} |
||||
return; |
||||
} |
||||
} |
||||
for (int i = 0; i < RADIO_MAX_TALKERS; i++) { |
||||
if (ctx->talkers[i].src == 0) { |
||||
ctx->talkers[i].src = src; |
||||
ctx->talkers[i].stream = stream; |
||||
radio_talk_event(ctx->group, src, stream, 1); |
||||
return; |
||||
} |
||||
} |
||||
} |
||||
|
||||
/* зафиксировать конец передачи: пост on=0, если источник был в таблице */ |
||||
static void radio_talker_off(struct radio_ctx* ctx, uint64_t src) { |
||||
for (int i = 0; i < RADIO_MAX_TALKERS; i++) { |
||||
if (ctx->talkers[i].src == src) { |
||||
radio_talk_event(ctx->group, src, ctx->talkers[i].stream, 0); |
||||
ctx->talkers[i].src = 0; |
||||
ctx->talkers[i].stream = 0; |
||||
return; |
||||
} |
||||
} |
||||
} |
||||
|
||||
/* ── приём ── */ |
||||
|
||||
static void radio_recv(struct TOPO_GROUP* group, struct ETCP_CONN* from_conn, |
||||
const uint8_t* payload, size_t payload_len) { |
||||
struct radio_ctx* ctx = radio_of(group); |
||||
if (!ctx || !from_conn || payload_len < RADIO_HDR_SIZE) return; |
||||
|
||||
const uint8_t* p = payload; |
||||
uint64_t group_id, src; |
||||
uint16_t stream, seq; |
||||
uint8_t flags, hop_count; |
||||
memcpy(&group_id, p + RADIO_OFF_GROUP, 8); |
||||
memcpy(&src, p + RADIO_OFF_SRC, 8); |
||||
memcpy(&stream, p + RADIO_OFF_STREAM, 2); |
||||
memcpy(&seq, p + RADIO_OFF_SEQ, 2); |
||||
flags = p[RADIO_OFF_FLAGS]; |
||||
hop_count = p[RADIO_OFF_HOPS]; |
||||
|
||||
if (group_id != group->group_id || hop_count > RADIO_MAX_HOPS |
||||
|| payload_len < RADIO_HDR_SIZE + (size_t)hop_count * 8) { |
||||
ctx->c_bad_dropped++; |
||||
DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: bad pkt grp=%016llx hops=%u len=%zu from=%s", |
||||
RADIO_ID, (unsigned long long)group_id, hop_count, payload_len, from_conn->log_name); |
||||
return; |
||||
} |
||||
const uint64_t* hops = (const uint64_t*)(p + RADIO_OFF_HOPLIST); |
||||
const uint8_t* opus = p + RADIO_OFF_HOPLIST + (size_t)hop_count * 8; |
||||
int opus_len = (int)(payload_len - RADIO_OFF_HOPLIST - (size_t)hop_count * 8); |
||||
if (opus_len > RADIO_MAX_OPUS) { ctx->c_bad_dropped++; return; } |
||||
|
||||
uint64_t self = group->instance->node_id; |
||||
|
||||
/* цикл: мы уже проходили этот пакет */ |
||||
for (int i = 0; i < hop_count; i++) { |
||||
if (hops[i] == self) { |
||||
ctx->c_loop_dropped++; |
||||
DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "%s: loop drop src=%016llx stream=%u seq=%u", RADIO_ID, (unsigned long long)src, stream, seq); |
||||
return; |
||||
} |
||||
} |
||||
|
||||
/* доставка локально, если мы подписаны */ |
||||
if (group->radio_active && ctx->group) { |
||||
struct radio_instance* ri = radio_inst(group->instance); |
||||
if (ri && ri->frame_fn) { |
||||
ri->frame_fn(group->instance, group_id, src, stream, seq, |
||||
(flags & RADIO_FLAG_FIN) ? 1 : 0, opus, opus_len, ri->frame_arg); |
||||
ctx->c_rx_frames++; |
||||
} |
||||
/* индикация говорящих: старт/стоп передачи */ |
||||
if (flags & RADIO_FLAG_FIN) radio_talker_off(ctx, src); |
||||
else radio_talker_on(ctx, src, stream); |
||||
} |
||||
|
||||
/* форвардинг: новый hop_list = старый + self */ |
||||
uint64_t new_hops[RADIO_MAX_HOPS]; |
||||
memcpy(new_hops, hops, (size_t)hop_count * 8); |
||||
new_hops[hop_count] = self; |
||||
uint8_t new_count = hop_count + 1; |
||||
|
||||
if (new_count >= RADIO_MAX_HOPS) { |
||||
DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "%s: max hops reached, no fwd src=%016llx", RADIO_ID, (unsigned long long)src); |
||||
return; |
||||
} |
||||
|
||||
uint8_t out[RADIO_HDR_SIZE + RADIO_MAX_HOPS * 8 + RADIO_MAX_OPUS]; |
||||
size_t out_len = radio_build(out, group_id, src, stream, seq, flags, new_hops, new_count, opus, opus_len); |
||||
radio_flood(group, from_conn, out, out_len, new_hops, new_count); |
||||
} |
||||
|
||||
static void radio_recv_dispatcher(struct ETCP_CONN* from_conn, struct ll_entry* entry) { |
||||
if (!from_conn || !entry || entry->len < 1 + 8 + RADIO_HDR_SIZE) { |
||||
if (entry) { queue_dgram_free(entry); queue_entry_free(entry); } |
||||
return; |
||||
} |
||||
struct UTUN_INSTANCE* instance = from_conn->instance; |
||||
if (!instance) { queue_dgram_free(entry); queue_entry_free(entry); return; } |
||||
|
||||
uint8_t* dgram = entry->dgram; |
||||
if (dgram[0] != ETCP_ID_RADIO) { queue_dgram_free(entry); queue_entry_free(entry); return; } |
||||
|
||||
uint64_t group_id; |
||||
memcpy(&group_id, dgram + 1, 8); |
||||
if (instance->topo_groups) { |
||||
struct TOPO_GROUP* group = topo_groups_find(instance->topo_groups, group_id); |
||||
if (group) radio_recv(group, from_conn, dgram + 1, entry->len - 1); |
||||
} |
||||
queue_dgram_free(entry); queue_entry_free(entry); |
||||
} |
||||
|
||||
int radio_init_instance(struct UTUN_INSTANCE* inst) { |
||||
if (!inst) return -1; |
||||
if (inst->radio) return 0; |
||||
struct radio_instance* ri = u_calloc(1, sizeof(*ri)); |
||||
if (!ri) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: alloc instance failed", RADIO_ID); return -1; } |
||||
inst->radio = ri; |
||||
if (etcp_bind(inst, ETCP_ID_RADIO, radio_recv_dispatcher) != 0) { |
||||
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: etcp_bind(ETCP_ID_RADIO) failed", RADIO_ID); |
||||
u_free(ri); inst->radio = NULL; |
||||
return -1; |
||||
} |
||||
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: instance initialized (bound ETCP_ID_RADIO)", RADIO_ID); |
||||
return 0; |
||||
} |
||||
|
||||
void radio_destroy_instance(struct UTUN_INSTANCE* inst) { |
||||
if (!inst || !inst->radio) return; |
||||
etcp_unbind(inst, ETCP_ID_RADIO); |
||||
u_free(inst->radio); |
||||
inst->radio = NULL; |
||||
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: instance destroyed", RADIO_ID); |
||||
} |
||||
|
||||
void radio_set_frame_cb(struct UTUN_INSTANCE* inst, radio_frame_fn fn, void* arg) { |
||||
struct radio_instance* ri = radio_inst(inst); |
||||
if (!ri) return; |
||||
ri->frame_fn = fn; |
||||
ri->frame_arg = arg; |
||||
} |
||||
|
||||
/* ── подписка (вкл/выкл прослушивание) ── */ |
||||
|
||||
void radio_set_active(struct UTUN_INSTANCE* inst, uint64_t group_id, int on) { |
||||
if (!inst || !inst->topo_groups) return; |
||||
struct TOPO_GROUP* group = topo_groups_find(inst->topo_groups, group_id); |
||||
if (!group) { DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: set_active group 0x%016llx not found", RADIO_ID, (unsigned long long)group_id); return; } |
||||
topo_group_set_radio(group, on); |
||||
} |
||||
|
||||
int radio_is_active(struct UTUN_INSTANCE* inst, uint64_t group_id) { |
||||
if (!inst || !inst->topo_groups) return 0; |
||||
struct TOPO_GROUP* group = topo_groups_find(inst->topo_groups, group_id); |
||||
return group ? group->radio_active : 0; |
||||
} |
||||
|
||||
int radio_subscribers(struct UTUN_INSTANCE* inst, uint64_t group_id) { |
||||
if (!inst || !inst->topo_groups) return 0; |
||||
struct TOPO_GROUP* group = topo_groups_find(inst->topo_groups, group_id); |
||||
return group ? topo_group_radio_subscribers(group) : 0; |
||||
} |
||||
|
||||
/* ── передача (PTT) ── */ |
||||
|
||||
int radio_talk_begin(struct UTUN_INSTANCE* inst, uint64_t group_id) { |
||||
if (!inst || !inst->topo_groups) return -1; |
||||
struct TOPO_GROUP* group = topo_groups_find(inst->topo_groups, group_id); |
||||
if (!group) { DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: talk_begin group 0x%016llx not found", RADIO_ID, (unsigned long long)group_id); return -1; } |
||||
struct radio_ctx* ctx = radio_of(group); |
||||
if (!ctx) return -1; |
||||
ctx->stream_id = (uint16_t)(ctx->stream_id + 1); |
||||
ctx->seq = 0; |
||||
ctx->talking = 1; |
||||
DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "%s: talk_begin grp=%016llx stream=%u", RADIO_ID, (unsigned long long)group_id, ctx->stream_id); |
||||
return 0; |
||||
} |
||||
|
||||
int radio_talk_send(struct UTUN_INSTANCE* inst, uint64_t group_id, const uint8_t* opus, int len) { |
||||
if (!inst || !inst->topo_groups || !opus || len < 0 || len > RADIO_MAX_OPUS) return -1; |
||||
struct TOPO_GROUP* group = topo_groups_find(inst->topo_groups, group_id); |
||||
if (!group) return -1; |
||||
struct radio_ctx* ctx = radio_of(group); |
||||
if (!ctx) return -1; |
||||
if (!ctx->talking) radio_talk_begin(inst, group_id); |
||||
|
||||
uint64_t self = inst->node_id; |
||||
uint64_t hops[1] = { self }; |
||||
uint8_t out[RADIO_HDR_SIZE + 8 + RADIO_MAX_OPUS]; |
||||
size_t out_len = radio_build(out, group_id, self, ctx->stream_id, ctx->seq, 0, hops, 1, opus, len); |
||||
ctx->seq++; |
||||
ctx->c_tx_frames++; |
||||
|
||||
radio_flood(group, NULL, out, out_len, hops, 1); |
||||
return 0; |
||||
} |
||||
|
||||
int radio_talk_end(struct UTUN_INSTANCE* inst, uint64_t group_id) { |
||||
if (!inst || !inst->topo_groups) return -1; |
||||
struct TOPO_GROUP* group = topo_groups_find(inst->topo_groups, group_id); |
||||
if (!group) return -1; |
||||
struct radio_ctx* ctx = radio_of(group); |
||||
if (!ctx) return -1; |
||||
if (!ctx->talking) return 0; |
||||
|
||||
uint64_t self = inst->node_id; |
||||
uint64_t hops[1] = { self }; |
||||
uint8_t out[RADIO_HDR_SIZE + 8]; |
||||
size_t out_len = radio_build(out, group_id, self, ctx->stream_id, ctx->seq, |
||||
RADIO_FLAG_FIN, hops, 1, NULL, 0); |
||||
ctx->seq++; |
||||
ctx->c_tx_frames++; |
||||
ctx->talking = 0; |
||||
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "%s: talk_end grp=%016llx stream=%u frames=%u", |
||||
RADIO_ID, (unsigned long long)group_id, ctx->stream_id, ctx->c_tx_frames); |
||||
radio_flood(group, NULL, out, out_len, hops, 1); |
||||
return 0; |
||||
} |
||||
|
||||
/* ── trampoline-функции (GUI/аудио-поток → uasync) ── */ |
||||
|
||||
void radio_talk_begin_trampoline(void* arg) { |
||||
struct radio_talk_arg* a = (struct radio_talk_arg*)arg; |
||||
radio_talk_begin(a->inst, a->group_id); |
||||
u_free(a); |
||||
} |
||||
|
||||
void radio_talk_end_trampoline(void* arg) { |
||||
struct radio_talk_arg* a = (struct radio_talk_arg*)arg; |
||||
radio_talk_end(a->inst, a->group_id); |
||||
u_free(a); |
||||
} |
||||
|
||||
void radio_talk_send_trampoline(void* arg) { |
||||
struct radio_media_arg* a = (struct radio_media_arg*)arg; |
||||
radio_talk_send(a->inst, a->group_id, a->opus, a->len); |
||||
u_free(a); |
||||
} |
||||
|
||||
void radio_set_active_trampoline(void* arg) { |
||||
struct radio_set_active_arg* a = (struct radio_set_active_arg*)arg; |
||||
radio_set_active(a->inst, a->group_id, a->on); |
||||
u_free(a); |
||||
} |
||||
@ -0,0 +1,75 @@
|
||||
// radio.h — рация (PTT walkie-talkie): мультикаст голоса по CHAT-группе.
|
||||
//
|
||||
// Флудит Opus-кадры всем подписчикам канала (узлы с TOPO_FLAG_RADIO в NODEINFO).
|
||||
// Передача полудуплексная: радио включается на прослушивание, PTT — на передачу.
|
||||
// Все функции — в uasync-потоке (кроме radio_set_frame_cb, который ставит
|
||||
// обработчик приёма: его вызывает radio_audio в момент init).
|
||||
#ifndef RADIO_H |
||||
#define RADIO_H |
||||
|
||||
#include <stdint.h> |
||||
|
||||
struct TOPO_GROUP; |
||||
struct UTUN_INSTANCE; |
||||
|
||||
#ifdef __cplusplus |
||||
extern "C" { |
||||
#endif |
||||
|
||||
/* ── жизненный цикл ── */ |
||||
|
||||
int radio_init(struct TOPO_GROUP* group); /* из topo_group_create */ |
||||
void radio_destroy(struct TOPO_GROUP* group); /* из topo_group_destroy */ |
||||
|
||||
int radio_init_instance(struct UTUN_INSTANCE* inst); /* etcp_bind(ETCP_ID_RADIO) */ |
||||
void radio_destroy_instance(struct UTUN_INSTANCE* inst); /* etcp_unbind */ |
||||
|
||||
/* ── приём Opus-кадра пира (для аудио-движка radio_audio) ── */ |
||||
|
||||
typedef void (*radio_frame_fn)(struct UTUN_INSTANCE* inst, uint64_t group_id, |
||||
uint64_t src_node_id, uint16_t stream_id, uint16_t seq, |
||||
uint8_t fin, const uint8_t* opus, int len, void* arg); |
||||
void radio_set_frame_cb(struct UTUN_INSTANCE* inst, radio_frame_fn fn, void* arg); |
||||
|
||||
/* ── передача (PTT), uasync-поток ── */ |
||||
|
||||
int radio_talk_begin(struct UTUN_INSTANCE* inst, uint64_t group_id); /* новая передача, seq=0 */ |
||||
int radio_talk_send(struct UTUN_INSTANCE* inst, uint64_t group_id, const uint8_t* opus, int len); |
||||
int radio_talk_end(struct UTUN_INSTANCE* inst, uint64_t group_id); /* FIN-кадр */ |
||||
|
||||
/* Включить/выключить прослушивание рации канала (флаг + BGP-рассылка). */ |
||||
void radio_set_active(struct UTUN_INSTANCE* inst, uint64_t group_id, int on); |
||||
int radio_is_active(struct UTUN_INSTANCE* inst, uint64_t group_id); |
||||
|
||||
/* Число подписчиков рации канала (без себя) — для индикатора в GUI. */ |
||||
int radio_subscribers(struct UTUN_INSTANCE* inst, uint64_t group_id); |
||||
|
||||
/* ── trampoline-структуры (GUI/аудио-поток → uasync) ── */ |
||||
|
||||
struct radio_talk_arg { |
||||
struct UTUN_INSTANCE* inst; |
||||
uint64_t group_id; |
||||
}; |
||||
|
||||
struct radio_media_arg { |
||||
struct UTUN_INSTANCE* inst; |
||||
uint64_t group_id; |
||||
uint16_t len; |
||||
uint8_t opus[256]; |
||||
}; |
||||
|
||||
struct radio_set_active_arg { |
||||
struct UTUN_INSTANCE* inst; |
||||
uint64_t group_id; |
||||
int on; |
||||
}; |
||||
|
||||
void radio_talk_begin_trampoline(void* arg); |
||||
void radio_talk_end_trampoline(void* arg); |
||||
void radio_talk_send_trampoline(void* arg); |
||||
void radio_set_active_trampoline(void* arg); |
||||
|
||||
#ifdef __cplusplus |
||||
} |
||||
#endif |
||||
#endif /* RADIO_H */ |
||||
@ -0,0 +1,299 @@
|
||||
// radio_audio.c — сервисный аудио-движок рации (Opus TX + per-source jitter + микшер).
|
||||
//
|
||||
// Таблица говорящих: per-source инстанс (Opus-декодер + voice_jitter с time-stretch),
|
||||
// ключ = src_node_id. Один пир — один поток: новый stream_id сбрасывает инстанс
|
||||
// (не создаём дубль). Инстанс удаляется по FIN (после дренажа буфера) или по таймауту
|
||||
// (нет кадров 2с — потерян FIN / пир отвалился).
|
||||
//
|
||||
// RX: radio.c → radio_set_frame_cb → radio_audio_on_frame (uasync) → vj_push (кодированный кадр);
|
||||
// аудио-поток radio_audio_pull_pcm → vj_pull (декод + SoundTouch stretch) → микшер.
|
||||
// TX: аудио-поток radio_audio_feed_pcm → encode → uasync_post(radio_talk_send_trampoline).
|
||||
// Микшер избыточных источников — насыщающее суммирование (soft-clip). Полудуплекс.
|
||||
// Синглтон на один активный group_id (GUI слушает один канал), защищён мьютексом.
|
||||
|
||||
#include "radio_audio.h" |
||||
#include "radio.h" |
||||
#include "../call/voice_jitter.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 "../../lib/platform_compat.h" |
||||
|
||||
#include <pthread.h> |
||||
#include <string.h> |
||||
|
||||
#define RADIO_AUDIO_ID "radio_audio" |
||||
#define RADIO_AUDIO_BITRATE 32000 |
||||
#define RADIO_AUDIO_MAX_SOURCES 16 |
||||
#define RADIO_AUDIO_MAX_FRAMES 50 /* ~1000мс @20мс */ |
||||
#define RADIO_AUDIO_MIX_SAMPLES 960 /* максимум сэмплов за pull */ |
||||
#define RADIO_AUDIO_SOURCE_TIMEOUT_TB 20000 /* 2с без кадров → источник завершён */ |
||||
|
||||
struct radio_source { |
||||
uint64_t src_node_id; |
||||
uint16_t stream_id; |
||||
uint8_t used; |
||||
uint8_t ending; /* получен FIN — дренируем и удаляем */ |
||||
uint64_t last_frame_tb; |
||||
opus_codec_decoder_t* dec; /* decode-аргумент для vj (stateless per call) */ |
||||
struct vj* vj; /* jitter-буфер с time-stretch */ |
||||
}; |
||||
|
||||
static pthread_mutex_t g_mtx = PTHREAD_MUTEX_INITIALIZER; |
||||
static struct UTUN_INSTANCE* g_inst = NULL; |
||||
static int g_active = 0; |
||||
static uint64_t g_group_id = 0; |
||||
static opus_codec_encoder_t* g_encoder = NULL; |
||||
static struct radio_source g_sources[RADIO_AUDIO_MAX_SOURCES]; |
||||
static int32_t g_mix[RADIO_AUDIO_MIX_SAMPLES]; |
||||
|
||||
/* vj decode-коллбэк: дёргается только из аудио-потока (vj_pull). */ |
||||
static int radio_audio_decode_cb(void* arg, const uint8_t* enc, int len, int16_t* pcm, int max_samples) { |
||||
opus_codec_decoder_t* dec = (opus_codec_decoder_t*)arg; |
||||
return opus_codec_decode(dec, enc, len, pcm, max_samples); |
||||
} |
||||
|
||||
/* ── таблица говорящих ── */ |
||||
|
||||
static struct radio_source* radio_src_find(uint64_t src) { |
||||
for (int i = 0; i < RADIO_AUDIO_MAX_SOURCES; i++) |
||||
if (g_sources[i].used && g_sources[i].src_node_id == src) return &g_sources[i]; |
||||
return NULL; |
||||
} |
||||
|
||||
static void radio_src_free(struct radio_source* s) { |
||||
if (s->vj) { vj_destroy(s->vj); s->vj = NULL; } |
||||
if (s->dec) { opus_codec_decoder_destroy(s->dec); s->dec = NULL; } |
||||
s->used = 0; s->ending = 0; s->last_frame_tb = 0; |
||||
} |
||||
|
||||
/* Получить/создать инстанс источника. Один пир — один инстанс: если stream_id
|
||||
* сменился (новая передача PTT того же пира) — сбрасываем jitter, не дублируем. */ |
||||
static struct radio_source* radio_src_acquire(uint64_t src, uint16_t stream) { |
||||
struct radio_source* s = radio_src_find(src); |
||||
if (s) { |
||||
if (s->stream_id != stream) { |
||||
s->stream_id = stream; |
||||
s->ending = 0; |
||||
vj_destroy(s->vj); |
||||
s->vj = vj_create(RADIO_AUDIO_SAMPLE_RATE, 1, RADIO_AUDIO_FRAME_SAMPLES, RADIO_AUDIO_FRAME_MS, |
||||
RADIO_AUDIO_MAX_FRAMES, radio_audio_decode_cb, s->dec); |
||||
if (!s->vj) { radio_src_free(s); return NULL; } |
||||
DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "%s: source reset src=%016llx stream=%u", RADIO_AUDIO_ID, (unsigned long long)src, stream); |
||||
} |
||||
return s; |
||||
} |
||||
for (int i = 0; i < RADIO_AUDIO_MAX_SOURCES; i++) { |
||||
if (g_sources[i].used) continue; |
||||
s = &g_sources[i]; |
||||
s->dec = opus_codec_decoder_create(RADIO_AUDIO_SAMPLE_RATE, 1); |
||||
if (!s->dec) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: decoder create failed", RADIO_AUDIO_ID); return NULL; } |
||||
s->vj = vj_create(RADIO_AUDIO_SAMPLE_RATE, 1, RADIO_AUDIO_FRAME_SAMPLES, RADIO_AUDIO_FRAME_MS, |
||||
RADIO_AUDIO_MAX_FRAMES, radio_audio_decode_cb, s->dec); |
||||
if (!s->vj) { opus_codec_decoder_destroy(s->dec); s->dec = NULL; return NULL; } |
||||
s->src_node_id = src; |
||||
s->stream_id = stream; |
||||
s->used = 1; s->ending = 0; s->last_frame_tb = 0; |
||||
DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "%s: new source src=%016llx stream=%u", RADIO_AUDIO_ID, (unsigned long long)src, stream); |
||||
return s; |
||||
} |
||||
DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: too many sources (>=%d), drop src=%016llx", |
||||
RADIO_AUDIO_ID, RADIO_AUDIO_MAX_SOURCES, (unsigned long long)src); |
||||
return NULL; |
||||
} |
||||
|
||||
/* ── lifecycle ── */ |
||||
|
||||
int radio_audio_init(struct UTUN_INSTANCE* inst) { |
||||
if (!inst) return -1; |
||||
if (g_inst == inst) return 0; |
||||
g_inst = inst; |
||||
radio_set_frame_cb(inst, radio_audio_on_frame, NULL); |
||||
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: initialized", RADIO_AUDIO_ID); |
||||
return 0; |
||||
} |
||||
|
||||
void radio_audio_destroy(struct UTUN_INSTANCE* inst) { |
||||
if (!inst || g_inst != inst) return; |
||||
radio_set_frame_cb(inst, NULL, NULL); |
||||
g_inst = NULL; |
||||
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: destroyed", RADIO_AUDIO_ID); |
||||
} |
||||
|
||||
int radio_audio_start(uint64_t group_id) { |
||||
if (group_id == 0) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: start bad group_id=0", RADIO_AUDIO_ID); return -1; } |
||||
|
||||
pthread_mutex_lock(&g_mtx); |
||||
if (g_active && g_group_id == group_id) { pthread_mutex_unlock(&g_mtx); return 0; } |
||||
if (g_active) { pthread_mutex_unlock(&g_mtx); radio_audio_stop(); pthread_mutex_lock(&g_mtx); } |
||||
|
||||
opus_codec_encoder_t* enc = opus_codec_encoder_create(RADIO_AUDIO_SAMPLE_RATE, 1); |
||||
if (!enc) { pthread_mutex_unlock(&g_mtx); DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: encoder create failed", RADIO_AUDIO_ID); return -1; } |
||||
opus_codec_encoder_bitrate_set(enc, RADIO_AUDIO_BITRATE); |
||||
|
||||
g_encoder = enc; |
||||
g_group_id = group_id; |
||||
g_active = 1; |
||||
memset(g_sources, 0, sizeof(g_sources)); |
||||
pthread_mutex_unlock(&g_mtx); |
||||
|
||||
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: started grp=%016llx", RADIO_AUDIO_ID, (unsigned long long)group_id); |
||||
return 0; |
||||
} |
||||
|
||||
void radio_audio_stop(void) { |
||||
opus_codec_encoder_t* enc = NULL; |
||||
int was_active = 0; |
||||
|
||||
pthread_mutex_lock(&g_mtx); |
||||
if (g_active) { |
||||
was_active = 1; |
||||
g_active = 0; |
||||
g_group_id = 0; |
||||
enc = g_encoder; g_encoder = NULL; |
||||
for (int i = 0; i < RADIO_AUDIO_MAX_SOURCES; i++) |
||||
if (g_sources[i].used) radio_src_free(&g_sources[i]); |
||||
} |
||||
pthread_mutex_unlock(&g_mtx); |
||||
|
||||
if (was_active) { |
||||
if (enc) opus_codec_encoder_destroy(enc); |
||||
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: stopped", RADIO_AUDIO_ID); |
||||
} |
||||
} |
||||
|
||||
int radio_audio_active(void) { |
||||
int a = 0; |
||||
pthread_mutex_lock(&g_mtx); |
||||
a = g_active; |
||||
pthread_mutex_unlock(&g_mtx); |
||||
return a; |
||||
} |
||||
|
||||
/* ── PTT (GUI → uasync) ── */ |
||||
|
||||
static void radio_audio_post_talk(struct UASYNC* ua, uint64_t group_id, void (*fn)(void*)) { |
||||
struct radio_talk_arg* a = (struct radio_talk_arg*)u_malloc(sizeof(*a)); |
||||
if (!a) return; |
||||
a->inst = g_inst; |
||||
a->group_id = group_id; |
||||
uasync_post(ua, fn, a); |
||||
} |
||||
|
||||
void radio_audio_talk_begin(uint64_t group_id) { |
||||
if (!g_inst || !g_inst->ua) return; |
||||
radio_audio_post_talk(g_inst->ua, group_id, radio_talk_begin_trampoline); |
||||
} |
||||
|
||||
void radio_audio_talk_end(uint64_t group_id) { |
||||
if (!g_inst || !g_inst->ua) return; |
||||
radio_audio_post_talk(g_inst->ua, group_id, radio_talk_end_trampoline); |
||||
} |
||||
|
||||
/* ── TX (аудио-поток) ── */ |
||||
|
||||
int radio_audio_feed_pcm(uint64_t group_id, const int16_t* pcm, int count) { |
||||
if (!pcm || count != RADIO_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 || group_id != g_group_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) return -1; |
||||
if (!ua || !inst) { |
||||
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: feed_pcm: instance not set", RADIO_AUDIO_ID); |
||||
return -1; |
||||
} |
||||
|
||||
struct radio_media_arg* a = (struct radio_media_arg*)u_malloc(sizeof(*a)); |
||||
if (!a) return -1; |
||||
a->inst = inst; |
||||
a->group_id = group_id; |
||||
a->len = (uint16_t)len; |
||||
memcpy(a->opus, opus, (size_t)len); |
||||
uasync_post(ua, radio_talk_send_trampoline, a); |
||||
return 0; |
||||
} |
||||
|
||||
/* ── RX (uasync-поток): кодированный кадр → vj_push ── */ |
||||
|
||||
void radio_audio_on_frame(struct UTUN_INSTANCE* inst, uint64_t group_id, |
||||
uint64_t src_node_id, uint16_t stream_id, uint16_t seq, |
||||
uint8_t fin, const uint8_t* opus, int len, void* arg) { |
||||
(void)inst; (void)seq; (void)arg; |
||||
if (!opus || len <= 0) return; |
||||
|
||||
pthread_mutex_lock(&g_mtx); |
||||
if (g_active && group_id == g_group_id) { |
||||
struct radio_source* s = radio_src_acquire(src_node_id, stream_id); |
||||
if (s) { |
||||
vj_push(s->vj, opus, len); |
||||
s->last_frame_tb = get_time_tb(); |
||||
if (fin) s->ending = 1; |
||||
} |
||||
} |
||||
pthread_mutex_unlock(&g_mtx); |
||||
} |
||||
|
||||
/* ── RX микшер (аудио-поток) ── */ |
||||
|
||||
int radio_audio_pull_pcm(uint64_t group_id, int16_t* out, int max_samples) { |
||||
if (!out || max_samples <= 0) return 0; |
||||
if (max_samples > RADIO_AUDIO_MIX_SAMPLES) max_samples = RADIO_AUDIO_MIX_SAMPLES; |
||||
|
||||
int active = 0; |
||||
pthread_mutex_lock(&g_mtx); |
||||
if (g_active && group_id == g_group_id) active = 1; |
||||
pthread_mutex_unlock(&g_mtx); |
||||
if (!active) { memset(out, 0, (size_t)max_samples * sizeof(int16_t)); return max_samples; } |
||||
|
||||
memset(g_mix, 0, (size_t)max_samples * sizeof(int32_t)); |
||||
|
||||
uint64_t now = get_time_tb(); |
||||
|
||||
pthread_mutex_lock(&g_mtx); |
||||
for (int i = 0; i < RADIO_AUDIO_MAX_SOURCES; i++) { |
||||
struct radio_source* s = &g_sources[i]; |
||||
if (!s->used) continue; |
||||
/* таймаут источника (нет кадров 2с — потерян FIN/обрыв передающего).
|
||||
* только для НЕ-ending: ending-источник дренируется до пустого буфера. */ |
||||
if (!s->ending && s->last_frame_tb && (now - s->last_frame_tb > RADIO_AUDIO_SOURCE_TIMEOUT_TB)) { |
||||
DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: source timeout src=%016llx", RADIO_AUDIO_ID, (unsigned long long)s->src_node_id); |
||||
radio_src_free(s); |
||||
continue; |
||||
} |
||||
int16_t tmp[RADIO_AUDIO_MIX_SAMPLES]; |
||||
int n = vj_pull(s->vj, tmp, max_samples); |
||||
for (int k = 0; k < n; k++) g_mix[k] += tmp[k]; |
||||
if (s->ending && n == 0) { |
||||
uint32_t dropped = 0, underruns = 0; |
||||
vj_get_stats(s->vj, NULL, NULL, &dropped, &underruns); |
||||
DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "%s: source ended src=%016llx dropped=%u underruns=%u", |
||||
RADIO_AUDIO_ID, (unsigned long long)s->src_node_id, dropped, underruns); |
||||
radio_src_free(s); |
||||
} |
||||
} |
||||
pthread_mutex_unlock(&g_mtx); |
||||
|
||||
/* насыщающее суммирование (soft-clip) в int16 */ |
||||
for (int k = 0; k < max_samples; k++) { |
||||
int32_t v = g_mix[k]; |
||||
if (v > 32767) v = 32767; |
||||
else if (v < -32768) v = -32768; |
||||
out[k] = (int16_t)v; |
||||
} |
||||
return max_samples; |
||||
} |
||||
@ -0,0 +1,63 @@
|
||||
// radio_audio.h — сервисный аудио-движок рации (единый для desktop/Android/headless).
|
||||
//
|
||||
// Владеет Opus encoder (TX), per-source декодерами и микшером (RX). Приём:
|
||||
// radio.c → radio_set_frame_cb → radio_audio_on_frame (uasync) декодирует Opus-кадр
|
||||
// и кладёт PCM в ring-буфер источника; аудио-поток radio_audio_pull_pcm микширует
|
||||
// PCM всех источников. Передача: аудио-поток radio_audio_feed_pcm кодирует PCM и
|
||||
// постит radio_talk_send в uasync. Микшер избыточных источников — насыщающее
|
||||
// суммирование (soft-clip). Полудуплекс: радио включено — слушаем, PTT — говорим.
|
||||
//
|
||||
// Потоки (как call_audio):
|
||||
// - radio_audio_start / talk_begin / talk_end / stop — GUI-поток;
|
||||
// - radio_audio_feed_pcm / pull_pcm — аудио-поток;
|
||||
// - radio_audio_on_frame — uasync-поток (из radio.c).
|
||||
// Общее состояние — под мьютексом. Синглтон на один активный group_id.
|
||||
|
||||
#ifndef RADIO_AUDIO_H |
||||
#define RADIO_AUDIO_H |
||||
|
||||
#include <stdint.h> |
||||
|
||||
struct UTUN_INSTANCE; |
||||
|
||||
#ifdef __cplusplus |
||||
extern "C" { |
||||
#endif |
||||
|
||||
#define RADIO_AUDIO_SAMPLE_RATE 48000 |
||||
#define RADIO_AUDIO_FRAME_MS 20 |
||||
#define RADIO_AUDIO_FRAME_SAMPLES (RADIO_AUDIO_SAMPLE_RATE * RADIO_AUDIO_FRAME_MS / 1000) /* 960 */ |
||||
|
||||
/* Зарегистрировать аудио-движок в radio.c (идемпотентно). */ |
||||
int radio_audio_init(struct UTUN_INSTANCE* inst); |
||||
void radio_audio_destroy(struct UTUN_INSTANCE* inst); |
||||
|
||||
/* GUI-поток: начать прослушивание канала (encoder + реестр источников). */ |
||||
int radio_audio_start(uint64_t group_id); |
||||
|
||||
/* GUI-поток: прекратить прослушивание (освободить всё). */ |
||||
void radio_audio_stop(void); |
||||
|
||||
/* GUI-поток: PTT зажата/отпущена — постит radio_talk_begin/end в uasync. */ |
||||
void radio_audio_talk_begin(uint64_t group_id); |
||||
void radio_audio_talk_end(uint64_t group_id); |
||||
|
||||
/* аудио-поток: закодировать PCM-кадр (ровно RADIO_AUDIO_FRAME_SAMPLES) и отправить. */ |
||||
int radio_audio_feed_pcm(uint64_t group_id, const int16_t* pcm, int count); |
||||
|
||||
/* аудио-поток: выдать до max_samples микшированного PCM (источники микшируются,
|
||||
* источники без данных дают тишину). Возвращает число сэмплов (обычно max). */ |
||||
int radio_audio_pull_pcm(uint64_t group_id, int16_t* out, int max_samples); |
||||
|
||||
/* 1 = аудио-движок активен (радио канала включено). */ |
||||
int radio_audio_active(void); |
||||
|
||||
/* uasync-поток: принятый Opus-кадр источника (декодируем + кладём в ring). */ |
||||
void radio_audio_on_frame(struct UTUN_INSTANCE* inst, uint64_t group_id, |
||||
uint64_t src_node_id, uint16_t stream_id, uint16_t seq, |
||||
uint8_t fin, const uint8_t* opus, int len, void* arg); |
||||
|
||||
#ifdef __cplusplus |
||||
} |
||||
#endif |
||||
#endif /* RADIO_AUDIO_H */ |
||||
@ -0,0 +1,491 @@
|
||||
// radio_headless.c — headless-аудио-сокет рации (PCM, без тактирования).
|
||||
//
|
||||
// TCP-сокет с бинарным фреймингом [type:1][group_id:8][len:2][data:len].
|
||||
// Клиент привязывается к каналу (HELLO) и обменивается готовым PCM (int16 mono 48k):
|
||||
// клиент → ядро: FRAME (960 сэмплов) — кодируется в Opus и флудится подписчикам;
|
||||
// клиент → ядро: PTT_START / PTT_STOP — radio_talk_begin / radio_talk_end;
|
||||
// ядро → клиент: микшированный PCM всех говорящих (FRAME), таймер 20мс.
|
||||
// Таблица говорящих: per-source (src_node_id) декодер + PCM-ring; один пир — один
|
||||
// поток; инстанс удаляется по FIN (дренаж) или по таймауту 2с. Без time-stretch —
|
||||
// тактирование на стороне клиента (как в call_headless).
|
||||
//
|
||||
// Управление подпиской (вкл/выкл рации) — на control-сокете (chat_headless_control):
|
||||
// radio_on / radio_off.
|
||||
#include "radio_headless.h" |
||||
#include "radio.h" |
||||
#include "../utun_instance.h" |
||||
#include "../../lib/opus_codec.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 RH_ID "radio_headless" |
||||
|
||||
#define RH_RECV_BUF_SIZE 65536 |
||||
#define RH_SEND_BUF_SIZE 65536 |
||||
#define RH_MAX_CLIENTS 16 |
||||
#define RH_MAX_FRAME 2048 |
||||
|
||||
#define RH_SAMPLE_RATE 48000 |
||||
#define RH_FRAME_SAMPLES 960 /* 20мс @48k */ |
||||
#define RH_PCM_FRAME_BYTES (RH_FRAME_SAMPLES * (int)sizeof(int16_t)) /* 1920 */ |
||||
#define RH_BITRATE 32000 |
||||
|
||||
#define RH_MAX_SOURCES 16 |
||||
#define RH_RING_FRAMES 25 /* ~500мс @20мс */ |
||||
#define RH_RING_SAMPLES (RH_RING_FRAMES * RH_FRAME_SAMPLES) |
||||
#define RH_MIX_TIMER_TB 200 /* 20мс — период микширования */ |
||||
#define RH_SOURCE_TIMEOUT_TB 20000 /* 2с без кадров → источник завершён */ |
||||
|
||||
struct rh_source { |
||||
uint64_t src_node_id; |
||||
uint16_t stream_id; |
||||
uint8_t used; |
||||
uint8_t ending; |
||||
uint64_t last_frame_tb; |
||||
opus_codec_decoder_t* dec; |
||||
int16_t* ring; /* u_malloc(RH_RING_SAMPLES) */ |
||||
int rd, wr, count; |
||||
}; |
||||
|
||||
struct radio_headless_client { |
||||
socket_t fd; |
||||
void* socket_id; |
||||
uint64_t group_id; /* 0 = не привязан */ |
||||
uint8_t recv_buf[RH_RECV_BUF_SIZE]; |
||||
int recv_len; |
||||
uint8_t send_buf[RH_SEND_BUF_SIZE]; |
||||
int send_len; |
||||
int send_offset; |
||||
int closing; |
||||
struct radio_headless* rh; |
||||
struct radio_headless_client* next; |
||||
}; |
||||
|
||||
struct radio_headless { |
||||
struct UASYNC* ua; |
||||
struct UTUN_INSTANCE* inst; |
||||
struct radio_headless_client* clients; |
||||
int client_count; |
||||
socket_t listen_fd; |
||||
void* listen_sock_id; |
||||
int running; |
||||
uint64_t group_id; /* активный канал (0 = не задан) */ |
||||
uint64_t enc_group_id; |
||||
opus_codec_encoder_t* enc; /* TX-кодер активного канала */ |
||||
struct rh_source sources[RH_MAX_SOURCES]; |
||||
void* mix_timer; |
||||
int32_t mix[RH_FRAME_SAMPLES]; |
||||
/* диагностика */ |
||||
uint32_t c_enc_frames, c_dec_frames, c_enc_dropped, c_dec_dropped; |
||||
}; |
||||
|
||||
static struct radio_headless* rh_of(struct UTUN_INSTANCE* inst) { |
||||
return inst ? (struct radio_headless*)inst->radio_headless : NULL; |
||||
} |
||||
|
||||
static void rh_close_client(struct radio_headless_client* cli); |
||||
|
||||
/* ── отправка фрейма клиенту (буферизация как в call_headless) ── */ |
||||
|
||||
static void rh_send_raw(struct radio_headless_client* cli, const uint8_t* data, size_t len) { |
||||
struct radio_headless* rh = cli->rh; |
||||
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 (rh && rh->ua) uasync_set_socket_write(rh->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 (rh && rh->ua) uasync_set_socket_write(rh->ua, cli->socket_id, 1); |
||||
} |
||||
if (r < 0) { |
||||
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: send failed err=%d, closing client", RH_ID, socket_get_error()); |
||||
rh_close_client(cli); |
||||
} |
||||
} |
||||
|
||||
static void rh_write_frame(struct radio_headless_client* cli, uint8_t type, uint64_t group_id, |
||||
const uint8_t* data, uint16_t len) { |
||||
uint8_t hdr[RADIO_AUDIO_HDR_SIZE]; |
||||
hdr[0] = type; |
||||
memcpy(hdr + 1, &group_id, 8); |
||||
hdr[9] = (uint8_t)(len >> 8); |
||||
hdr[10] = (uint8_t)(len & 0xFF); |
||||
rh_send_raw(cli, hdr, sizeof(hdr)); |
||||
if (len) rh_send_raw(cli, data, len); |
||||
} |
||||
|
||||
/* ── Opus-кодер активного канала ── */ |
||||
|
||||
static opus_codec_encoder_t* rh_encoder(struct radio_headless* rh, uint64_t group_id) { |
||||
if (rh->enc && rh->enc_group_id == group_id) return rh->enc; |
||||
if (rh->enc) { opus_codec_encoder_destroy(rh->enc); rh->enc = NULL; } |
||||
rh->enc = opus_codec_encoder_create(RH_SAMPLE_RATE, 1); |
||||
if (rh->enc) { |
||||
opus_codec_encoder_bitrate_set(rh->enc, RH_BITRATE); |
||||
rh->enc_group_id = group_id; |
||||
} else { |
||||
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: encoder create failed", RH_ID); |
||||
} |
||||
return rh->enc; |
||||
} |
||||
|
||||
/* ── таблица говорящих ── */ |
||||
|
||||
static struct rh_source* rh_src_find(struct radio_headless* rh, uint64_t src) { |
||||
for (int i = 0; i < RH_MAX_SOURCES; i++) |
||||
if (rh->sources[i].used && rh->sources[i].src_node_id == src) return &rh->sources[i]; |
||||
return NULL; |
||||
} |
||||
|
||||
static void rh_src_free(struct rh_source* s) { |
||||
if (s->dec) { opus_codec_decoder_destroy(s->dec); s->dec = NULL; } |
||||
if (s->ring) { u_free(s->ring); s->ring = NULL; } |
||||
s->used = 0; s->ending = 0; s->rd = s->wr = s->count = 0; |
||||
} |
||||
|
||||
static struct rh_source* rh_src_acquire(struct radio_headless* rh, uint64_t src, uint16_t stream) { |
||||
struct rh_source* s = rh_src_find(rh, src); |
||||
if (s) { |
||||
if (s->stream_id != stream) { |
||||
s->stream_id = stream; |
||||
s->ending = 0; s->rd = s->wr = s->count = 0; |
||||
DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "%s: source reset src=%016llx stream=%u", RH_ID, (unsigned long long)src, stream); |
||||
} |
||||
return s; |
||||
} |
||||
for (int i = 0; i < RH_MAX_SOURCES; i++) { |
||||
if (rh->sources[i].used) continue; |
||||
s = &rh->sources[i]; |
||||
s->dec = opus_codec_decoder_create(RH_SAMPLE_RATE, 1); |
||||
if (!s->dec) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: decoder create failed", RH_ID); return NULL; } |
||||
s->ring = u_malloc(RH_RING_SAMPLES * sizeof(int16_t)); |
||||
if (!s->ring) { opus_codec_decoder_destroy(s->dec); s->dec = NULL; return NULL; } |
||||
s->src_node_id = src; |
||||
s->stream_id = stream; |
||||
s->used = 1; s->ending = 0; s->rd = s->wr = s->count = 0; |
||||
DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "%s: new source src=%016llx stream=%u", RH_ID, (unsigned long long)src, stream); |
||||
return s; |
||||
} |
||||
DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: too many sources (>=%d), drop src=%016llx", RH_ID, RH_MAX_SOURCES, (unsigned long long)src); |
||||
return NULL; |
||||
} |
||||
|
||||
static void rh_src_push(struct rh_source* s, const int16_t* pcm, int n) { |
||||
int cap = RH_RING_SAMPLES; |
||||
if (n > cap) { pcm += n - cap; n = cap; } |
||||
if (s->count + n > cap) { |
||||
int overflow = s->count + n - cap; |
||||
s->rd = (s->rd + overflow) % cap; |
||||
s->count -= overflow; |
||||
} |
||||
for (int i = 0; i < n; i++) { |
||||
s->ring[s->wr] = pcm[i]; |
||||
s->wr = (s->wr + 1) % cap; |
||||
} |
||||
s->count += n; |
||||
} |
||||
|
||||
/* ── приём Opus-кадра пира (frame cb) → декод → ring источника ── */ |
||||
|
||||
static void rh_frame_cb(struct UTUN_INSTANCE* inst, uint64_t group_id, |
||||
uint64_t src_node_id, uint16_t stream_id, uint16_t seq, |
||||
uint8_t fin, const uint8_t* opus, int len, void* arg) { |
||||
(void)seq; |
||||
struct radio_headless* rh = (struct radio_headless*)arg; |
||||
if (!rh || !rh->running || !opus || len <= 0) return; |
||||
|
||||
/* активный канал: первый кадр фиксирует group_id (узел слушает один канал) */ |
||||
if (rh->group_id == 0) rh->group_id = group_id; |
||||
if (group_id != rh->group_id) return; |
||||
|
||||
struct rh_source* s = rh_src_acquire(rh, src_node_id, stream_id); |
||||
if (!s) return; |
||||
int16_t pcm[RH_FRAME_SAMPLES]; |
||||
int n = opus_codec_decode(s->dec, opus, len, pcm, RH_FRAME_SAMPLES); |
||||
if (n <= 0) { rh->c_dec_dropped++; return; } |
||||
rh_src_push(s, pcm, n); |
||||
rh->c_dec_frames++; |
||||
s->last_frame_tb = get_time_tb(); |
||||
if (fin) s->ending = 1; |
||||
(void)inst; |
||||
} |
||||
|
||||
/* ── периодический микшер: sum источников → PCM клиентам ── */ |
||||
|
||||
static void rh_mix_tick(void* arg) { |
||||
struct radio_headless* rh = (struct radio_headless*)arg; |
||||
rh->mix_timer = NULL; |
||||
if (!rh || !rh->running) return; |
||||
|
||||
if (rh->group_id != 0) { |
||||
memset(rh->mix, 0, sizeof(rh->mix)); |
||||
uint64_t now = get_time_tb(); |
||||
int any = 0; |
||||
|
||||
for (int i = 0; i < RH_MAX_SOURCES; i++) { |
||||
struct rh_source* s = &rh->sources[i]; |
||||
if (!s->used) continue; |
||||
if (!s->ending && s->last_frame_tb && (now - s->last_frame_tb > RH_SOURCE_TIMEOUT_TB)) { |
||||
DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: source timeout src=%016llx", RH_ID, (unsigned long long)s->src_node_id); |
||||
rh_src_free(s); |
||||
continue; |
||||
} |
||||
int take = s->count < RH_FRAME_SAMPLES ? s->count : RH_FRAME_SAMPLES; |
||||
for (int k = 0; k < take; k++) { |
||||
rh->mix[k] += s->ring[s->rd]; |
||||
s->rd = (s->rd + 1) % RH_RING_SAMPLES; |
||||
} |
||||
s->count -= take; |
||||
if (take > 0) any = 1; |
||||
if (s->ending && s->count == 0) { |
||||
DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "%s: source ended src=%016llx", RH_ID, (unsigned long long)s->src_node_id); |
||||
rh_src_free(s); |
||||
} |
||||
} |
||||
|
||||
/* шлём PCM клиентам только когда есть что микшировать (тишина — неявно) */ |
||||
if (any) { |
||||
int16_t pcm[RH_FRAME_SAMPLES]; |
||||
for (int k = 0; k < RH_FRAME_SAMPLES; k++) { |
||||
int32_t v = rh->mix[k]; |
||||
if (v > 32767) v = 32767; |
||||
else if (v < -32768) v = -32768; |
||||
pcm[k] = (int16_t)v; |
||||
} |
||||
for (struct radio_headless_client* c = rh->clients; c; c = c->next) { |
||||
if (c->group_id == rh->group_id && !c->closing) |
||||
rh_write_frame(c, RADIO_AUDIO_FRAME, rh->group_id, (const uint8_t*)pcm, RH_PCM_FRAME_BYTES); |
||||
} |
||||
} |
||||
} |
||||
|
||||
rh->mix_timer = uasync_set_timeout(rh->ua, RH_MIX_TIMER_TB, rh, rh_mix_tick, "rh_mix"); |
||||
} |
||||
|
||||
/* ── разбор входящих фреймов ── */ |
||||
|
||||
static void rh_parse_frames(struct radio_headless_client* cli) { |
||||
struct radio_headless* rh = cli->rh; |
||||
uint8_t* p = cli->recv_buf; |
||||
int avail = cli->recv_len; |
||||
int consumed = 0; |
||||
while (avail - consumed >= RADIO_AUDIO_HDR_SIZE) { |
||||
uint8_t type = p[consumed]; |
||||
uint64_t group_id; memcpy(&group_id, p + consumed + 1, 8); |
||||
uint16_t flen = (uint16_t)((p[consumed + 9] << 8) | p[consumed + 10]); |
||||
if (flen > RH_MAX_FRAME) { |
||||
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: frame too big %u — closing", RH_ID, flen); |
||||
rh_close_client(cli); |
||||
return; |
||||
} |
||||
if (avail - consumed < (int)(RADIO_AUDIO_HDR_SIZE + flen)) break; |
||||
const uint8_t* data = p + consumed + RADIO_AUDIO_HDR_SIZE; |
||||
|
||||
switch (type) { |
||||
case RADIO_AUDIO_HELLO: |
||||
cli->group_id = group_id; |
||||
if (rh->group_id == 0) rh->group_id = group_id; |
||||
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: client bound to group=0x%016llx", RH_ID, (unsigned long long)group_id); |
||||
break; |
||||
case RADIO_AUDIO_PTT_START: |
||||
radio_talk_begin(rh->inst, group_id); |
||||
break; |
||||
case RADIO_AUDIO_PTT_STOP: |
||||
radio_talk_end(rh->inst, group_id); |
||||
break; |
||||
case RADIO_AUDIO_FRAME: |
||||
if (flen != RH_PCM_FRAME_BYTES) { |
||||
rh->c_enc_dropped++; |
||||
DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: bad PCM frame size=%u (expect %d) — dropped", |
||||
RH_ID, flen, RH_PCM_FRAME_BYTES); |
||||
} else { |
||||
opus_codec_encoder_t* enc = rh_encoder(rh, group_id); |
||||
uint8_t opus[256]; |
||||
int olen = enc ? opus_codec_encode(enc, (const int16_t*)data, RH_FRAME_SAMPLES, opus, (int)sizeof(opus)) : -1; |
||||
if (olen <= 0) { |
||||
rh->c_enc_dropped++; |
||||
DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: encode failed (olen=%d) group=0x%016llx", |
||||
RH_ID, olen, (unsigned long long)group_id); |
||||
} else { |
||||
rh->c_enc_frames++; |
||||
if (radio_talk_send(rh->inst, group_id, opus, olen) != 0) |
||||
DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: talk_send failed group=0x%016llx len=%d", |
||||
RH_ID, (unsigned long long)group_id, olen); |
||||
} |
||||
} |
||||
break; |
||||
default: |
||||
DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: unknown frame type=0x%02x — closing", RH_ID, type); |
||||
rh_close_client(cli); |
||||
return; |
||||
} |
||||
consumed += RADIO_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 rh_write_callback(socket_t fd, void* arg) { |
||||
struct radio_headless_client* cli = (struct radio_headless_client*)arg; |
||||
struct radio_headless* rh = cli ? cli->rh : NULL; |
||||
if (!cli || cli->closing || fd != cli->fd) return; |
||||
if (cli->send_len <= 0) { if (rh && rh->ua) uasync_set_socket_write(rh->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) { |
||||
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: send failed (r=%zd err=%d) — closing client", RH_ID, r, socket_get_error()); |
||||
rh_close_client(cli); |
||||
return; |
||||
} |
||||
cli->send_offset += (int)r; |
||||
if (cli->send_offset >= cli->send_len) { cli->send_len = 0; cli->send_offset = 0; if (rh && rh->ua) uasync_set_socket_write(rh->ua, cli->socket_id, 0); } |
||||
} |
||||
|
||||
static void rh_close_client(struct radio_headless_client* cli) { |
||||
struct radio_headless* rh = cli ? cli->rh : NULL; |
||||
if (!cli || cli->closing) return; |
||||
cli->closing = 1; |
||||
if (rh && rh->ua && cli->socket_id) uasync_remove_socket_t(rh->ua, cli->fd); |
||||
if (cli->fd != SOCKET_INVALID) { socket_close_wrapper(cli->fd); cli->fd = SOCKET_INVALID; } |
||||
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: client closed group=0x%016llx", RH_ID, (unsigned long long)cli->group_id); |
||||
} |
||||
|
||||
static void rh_read_callback(socket_t fd, void* arg) { |
||||
struct radio_headless_client* cli = (struct radio_headless_client*)arg; |
||||
if (!cli || cli->closing) return; |
||||
uint8_t tmp[4096]; |
||||
ssize_t r = recv(fd, tmp, (int)sizeof(tmp), 0); |
||||
if (r <= 0) { rh_close_client(cli); return; } |
||||
if (cli->recv_len + (int)r > RH_RECV_BUF_SIZE) { |
||||
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: recv buffer overflow — closing", RH_ID); |
||||
rh_close_client(cli); |
||||
return; |
||||
} |
||||
memcpy(cli->recv_buf + cli->recv_len, tmp, (size_t)r); |
||||
cli->recv_len += (int)r; |
||||
rh_parse_frames(cli); |
||||
} |
||||
|
||||
static void rh_accept_callback(socket_t fd, void* arg) { |
||||
struct radio_headless* rh = (struct radio_headless*)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_RADIO, "%s: accept failed: %s", RH_ID, socket_strerror(e)); |
||||
return; |
||||
} |
||||
socket_set_nonblocking(cfd); |
||||
if (rh->client_count >= RH_MAX_CLIENTS) { socket_close_wrapper(cfd); DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: max clients reached", RH_ID); return; } |
||||
|
||||
struct radio_headless_client* cli = u_calloc(1, sizeof(*cli)); |
||||
if (!cli) { socket_close_wrapper(cfd); return; } |
||||
cli->fd = cfd; |
||||
cli->rh = rh; |
||||
cli->socket_id = uasync_add_socket_t(rh->ua, cfd, rh_read_callback, rh_write_callback, NULL, cli); |
||||
if (!cli->socket_id) { u_free(cli); socket_close_wrapper(cfd); DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: failed to register client fd", RH_ID); return; } |
||||
|
||||
cli->next = rh->clients; rh->clients = cli; rh->client_count++; |
||||
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: client connected fd=%d total=%d", RH_ID, (int)cfd, rh->client_count); |
||||
} |
||||
|
||||
/* ── периодическая очистка закрытых клиентов ── */ |
||||
|
||||
static void rh_cleanup(void* arg) { |
||||
struct radio_headless* rh = (struct radio_headless*)arg; |
||||
struct radio_headless_client** prev = &rh->clients; |
||||
while (*prev) { |
||||
if ((*prev)->closing) { |
||||
struct radio_headless_client* dead = *prev; *prev = dead->next; |
||||
if (dead->fd != SOCKET_INVALID) socket_close_wrapper(dead->fd); |
||||
u_free(dead); rh->client_count--; |
||||
} else prev = &(*prev)->next; |
||||
} |
||||
if (rh->ua) uasync_set_timeout(rh->ua, 5000, rh, rh_cleanup, "rh_cleanup"); |
||||
} |
||||
|
||||
/* ── init / destroy ── */ |
||||
|
||||
int radio_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 radio_headless* rh = rh_of(inst); |
||||
if (rh && rh->running) return 0; |
||||
|
||||
if (!rh) { |
||||
rh = u_calloc(1, sizeof(*rh)); |
||||
if (!rh) return -1; |
||||
inst->radio_headless = rh; |
||||
} |
||||
rh->ua = ua; rh->inst = inst; |
||||
|
||||
rh->listen_fd = socket(AF_INET, SOCK_STREAM, 0); |
||||
if (rh->listen_fd == SOCKET_INVALID) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: socket() failed: %s", RH_ID, socket_strerror(socket_get_error())); return -1; } |
||||
socket_set_reuseaddr(rh->listen_fd, 1); |
||||
socket_set_nonblocking(rh->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(rh->listen_fd); rh->listen_fd = SOCKET_INVALID; return -1; } |
||||
if (bind(rh->listen_fd, (struct sockaddr*)&sin, sizeof(sin)) < 0) { |
||||
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: bind(%s:%d) failed: %s", RH_ID, bind_ip, port, socket_strerror(socket_get_error())); |
||||
socket_close_wrapper(rh->listen_fd); rh->listen_fd = SOCKET_INVALID; return -1; |
||||
} |
||||
if (listen(rh->listen_fd, 5) < 0) { socket_close_wrapper(rh->listen_fd); rh->listen_fd = SOCKET_INVALID; return -1; } |
||||
|
||||
rh->listen_sock_id = uasync_add_socket_t(ua, rh->listen_fd, rh_accept_callback, NULL, NULL, rh); |
||||
if (!rh->listen_sock_id) { socket_close_wrapper(rh->listen_fd); rh->listen_fd = SOCKET_INVALID; return -1; } |
||||
|
||||
radio_set_frame_cb(inst, rh_frame_cb, rh); |
||||
uasync_set_timeout(ua, 5000, rh, rh_cleanup, "rh_cleanup"); |
||||
rh->mix_timer = uasync_set_timeout(ua, RH_MIX_TIMER_TB, rh, rh_mix_tick, "rh_mix"); |
||||
|
||||
rh->running = 1; |
||||
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: audio socket listening on %s:%d", RH_ID, bind_ip, port); |
||||
return 0; |
||||
} |
||||
|
||||
void radio_headless_destroy(struct UTUN_INSTANCE* inst) { |
||||
struct radio_headless* rh = rh_of(inst); |
||||
if (!rh || !rh->running) return; |
||||
rh->running = 0; |
||||
radio_set_frame_cb(inst, NULL, NULL); |
||||
if (rh->mix_timer && rh->ua) { uasync_cancel_timeout(rh->ua, rh->mix_timer); rh->mix_timer = NULL; } |
||||
if (rh->listen_sock_id && rh->ua) uasync_remove_socket_t(rh->ua, rh->listen_fd); |
||||
if (rh->listen_fd != SOCKET_INVALID) { socket_close_wrapper(rh->listen_fd); rh->listen_fd = SOCKET_INVALID; } |
||||
if (rh->enc) { opus_codec_encoder_destroy(rh->enc); rh->enc = NULL; } |
||||
for (int i = 0; i < RH_MAX_SOURCES; i++) if (rh->sources[i].used) rh_src_free(&rh->sources[i]); |
||||
struct radio_headless_client* c = rh->clients; |
||||
while (c) { |
||||
struct radio_headless_client* n = c->next; |
||||
if (c->fd != SOCKET_INVALID) socket_close_wrapper(c->fd); |
||||
u_free(c); |
||||
c = n; |
||||
} |
||||
rh->clients = NULL; rh->client_count = 0; |
||||
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: audio socket destroyed (enc=%u dec=%u enc_drop=%u dec_drop=%u)", |
||||
RH_ID, rh->c_enc_frames, rh->c_dec_frames, rh->c_enc_dropped, rh->c_dec_dropped); |
||||
} |
||||
@ -0,0 +1,43 @@
|
||||
// radio_headless.h — headless-аудио-сокет рации (PCM, без тактирования).
|
||||
//
|
||||
// Отдельный TCP-сокет, через который headless-клиент (CLI/скрипт) обменивается
|
||||
// готовым PCM с ядром рации. Ядро кодирует PCM→Opus (TX), принимает Opus от пиров,
|
||||
// микширует их в общий PCM (RX, простой микшер без time-stretch — тактирование на
|
||||
// стороне клиента, как в call_headless). Управление рацией/PTT — через фреймы
|
||||
// PTT_START/PTT_STOP и команды radio_on/radio_off на control-сокете.
|
||||
//
|
||||
// Бинарный фрейм (обе стороны):
|
||||
// [type:1][group_id:8][len:2][data:len]
|
||||
// type = 0x01 HELLO (клиент → ядро: привязаться к group_id, data пусто)
|
||||
// type = 0x02 PTT_START (клиент → ядро: radio_talk_begin, data пусто)
|
||||
// type = 0x03 PTT_STOP (клиент → ядро: radio_talk_end, data пусто)
|
||||
// type = 0x04 FRAME (PCM int16 mono 48k, 960 сэмплов = 1920 байт)
|
||||
// клиент → ядро = PCM захвата (кодируется и флудится подписчикам);
|
||||
// ядро → клиент = микшированный PCM всех говорящих
|
||||
#ifndef RADIO_HEADLESS_H |
||||
#define RADIO_HEADLESS_H |
||||
|
||||
#include <stdint.h> |
||||
|
||||
struct UASYNC; |
||||
struct UTUN_INSTANCE; |
||||
|
||||
#define RADIO_AUDIO_HELLO 0x01 |
||||
#define RADIO_AUDIO_PTT_START 0x02 |
||||
#define RADIO_AUDIO_PTT_STOP 0x03 |
||||
#define RADIO_AUDIO_FRAME 0x04 |
||||
#define RADIO_AUDIO_HDR_SIZE 11 /* type(1) + group_id(8) + len(2) */ |
||||
#define RADIO_AUDIO_PCM_FRAME_BYTES (960 * (int)sizeof(int16_t)) /* 1920 — PCM int16 mono 48k 20мс */ |
||||
|
||||
#ifdef __cplusplus |
||||
extern "C" { |
||||
#endif |
||||
|
||||
int radio_headless_init(struct UASYNC* ua, struct UTUN_INSTANCE* inst, |
||||
const char* bind_ip, int port); |
||||
void radio_headless_destroy(struct UTUN_INSTANCE* inst); |
||||
|
||||
#ifdef __cplusplus |
||||
} |
||||
#endif |
||||
#endif /* RADIO_HEADLESS_H */ |
||||
@ -0,0 +1,39 @@
|
||||
// radio_proto.h — wire-формат и константы рации (PTT walkie-talkie мультикаст).
|
||||
//
|
||||
// Транспорт: raw ETCP (etcp_bind/etcp_send), первый байт кодограммы = ETCP_ID_RADIO.
|
||||
// Пакет флудится по senders_list CHAT-группы с hop-list против циклов и
|
||||
// RPF-обрезанием ветвей без подписчиков (узел с radio==1 в NODEINFO).
|
||||
//
|
||||
// Формат payload (после dgram[0] = ETCP_ID_RADIO):
|
||||
// [group_id:8][src_node_id:8][stream_id:2][seq:2][flags:1][hop_count:1]
|
||||
// [hop_list:8*hop_count][opus...]
|
||||
// flags: bit0 = FIN (последний кадр передачи)
|
||||
// stream_id — случайный идентификатор передачи (talk-burst), seq нумерует кадры от 0.
|
||||
#ifndef RADIO_PROTO_H |
||||
#define RADIO_PROTO_H |
||||
|
||||
#include <stdint.h> |
||||
|
||||
#ifdef __cplusplus |
||||
extern "C" { |
||||
#endif |
||||
|
||||
#define RADIO_FLAG_FIN 0x01 |
||||
|
||||
#define RADIO_MAX_HOPS 16 |
||||
#define RADIO_MAX_OPUS 256 |
||||
|
||||
#define RADIO_HDR_SIZE 22 /* group(8)+src(8)+stream(2)+seq(2)+flags(1)+hop_count(1), без hop_list */ |
||||
|
||||
#define RADIO_OFF_GROUP 0 |
||||
#define RADIO_OFF_SRC 8 |
||||
#define RADIO_OFF_STREAM 16 |
||||
#define RADIO_OFF_SEQ 18 |
||||
#define RADIO_OFF_FLAGS 20 |
||||
#define RADIO_OFF_HOPS 21 |
||||
#define RADIO_OFF_HOPLIST 22 |
||||
|
||||
#ifdef __cplusplus |
||||
} |
||||
#endif |
||||
#endif /* RADIO_PROTO_H */ |
||||
@ -0,0 +1,446 @@
|
||||
// test_radio.c — интеграционный тест рации (PTT мультикаст голоса).
|
||||
//
|
||||
// Однопоточный: 3 узла (A, B, C) в одном процессе, один UASYNC, без fork.
|
||||
// Топология — цепочка A←B←C (A сервер, B клиент к A + сервер, C клиент к B).
|
||||
// Все три в одной CHAT-группе. Master state-machine на таймере.
|
||||
//
|
||||
// Сценарий:
|
||||
// 1. A,B,C radio_on → флаг TOPO_FLAG_RADIO распространяется по BGP (node.radio==1).
|
||||
// 2. A: radio_talk_begin + 5 кадров + radio_talk_end → B и C (через relay B) получают
|
||||
// кадры в порядке seq 0..4 и FIN.
|
||||
// 3. C radio_off (обрезание) → A снова говорит → B получает, C не получает.
|
||||
|
||||
#include "chat_core.h" |
||||
#include "chat_sync.h" |
||||
#include "chat_event.h" |
||||
#include "member_sync.h" |
||||
#include "../radio/radio.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 IDX_C 2 |
||||
|
||||
#define TICK_TB 100 /* 10 ms на такт */ |
||||
#define MAX_TICKS 4000 /* глобальный таймаут ≈ 40 c */ |
||||
#define N_FRAMES 5 |
||||
|
||||
enum rad_phase { |
||||
P_WAIT_CONN, |
||||
P_SETUP, |
||||
P_WAIT_BGP, |
||||
P_RADIO_ON, |
||||
P_WAIT_FLAGS, |
||||
P_TALK1, |
||||
P_WAIT_RX1, |
||||
P_RADIO_OFF_C, |
||||
P_WAIT_OFF_C, |
||||
P_TALK2, |
||||
P_WAIT_RX2, |
||||
P_DONE, |
||||
}; |
||||
|
||||
struct rad_ctx { |
||||
struct UASYNC* ua; |
||||
struct UTUN_INSTANCE* inst[3]; |
||||
enum rad_phase phase; |
||||
int result; /* 0=running, 1=ok, 2=fail */ |
||||
int ticks; |
||||
void* timer; |
||||
}; |
||||
|
||||
/* приёмник кадров (per-узел): проверка порядка seq и FIN */ |
||||
struct rx_evt { |
||||
int active; |
||||
uint16_t stream; |
||||
uint16_t expect_seq; |
||||
int frames; /* число data-кадров (fin=0) текущего потока */ |
||||
int fin_seen; |
||||
int seq_ok; |
||||
}; |
||||
|
||||
struct rad_shared { |
||||
uint8_t ch_x_pub[32], ch_ed_pub[32], ch_ed_priv[32], ch_sig[64]; |
||||
uint64_t nid[3]; |
||||
uint8_t x_pub[3][32], x_priv[3][32], ed_pub[3][32]; |
||||
int port[3]; |
||||
}; |
||||
static struct rad_shared g_sh; |
||||
static struct rx_evt g_rx[3]; |
||||
|
||||
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; |
||||
} |
||||
|
||||
/* ── данные сценария + конфиги (цепочка A←B←C) ── */ |
||||
|
||||
static void fill_shared(int base_port) { |
||||
memset(&g_sh, 0, sizeof(g_sh)); |
||||
for (int i = 0; i < 3; 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", "radio-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 < 3; 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" : i == IDX_B ? "b" : "c"); |
||||
snprintf(dbd, sizeof(dbd), "%s/db%s", dir, i == IDX_A ? "a" : i == IDX_B ? "b" : "c"); |
||||
utun_mkdir(dbd, 0755); |
||||
|
||||
char client[512] = ""; |
||||
if (i == IDX_B) { /* B → 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]); |
||||
} else if (i == IDX_C) { /* C → B */ |
||||
char bpub[65]; to_hex(g_sh.x_pub[IDX_B], 32, bpub); |
||||
snprintf(client, sizeof(client), "[client: to_b]\npeer_public_key=%s\nlink=s1:127.0.0.1:%d\n", bpub, g_sh.port[IDX_B]); |
||||
} |
||||
|
||||
wf(path, |
||||
"[global]\ntun_ip=10.98.%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" : i == IDX_B ? "b" : "c", |
||||
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 int node_radio(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->radio : 0; |
||||
} |
||||
|
||||
/* ── frame cb (per-узел) ── */ |
||||
|
||||
static void rx_frame_cb(struct UTUN_INSTANCE* inst, uint64_t group_id, |
||||
uint64_t src, uint16_t stream, uint16_t seq, |
||||
uint8_t fin, const uint8_t* opus, int len, void* arg) { |
||||
(void)inst; (void)group_id; (void)src; (void)opus; (void)len; |
||||
struct rx_evt* rx = (struct rx_evt*)arg; |
||||
if (fin) { |
||||
if (rx->active && stream == rx->stream) rx->fin_seen = 1; |
||||
return; |
||||
} |
||||
if (!rx->active || stream != rx->stream) { |
||||
rx->active = 1; |
||||
rx->stream = stream; |
||||
rx->expect_seq = 0; |
||||
rx->frames = 0; |
||||
rx->fin_seen = 0; |
||||
rx->seq_ok = 1; |
||||
} |
||||
if (seq != rx->expect_seq) rx->seq_ok = 0; |
||||
rx->expect_seq = (uint16_t)(seq + 1); |
||||
rx->frames++; |
||||
} |
||||
|
||||
/* ── настройка канала и мемберов ── */ |
||||
|
||||
static void channel_put_shared(struct UTUN_INSTANCE* inst, int has_priv) { |
||||
topo_node_sqlite_channel_put(inst->topo_sqlite_db, CH_ID, "radio-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_put_placeholder(inst, IDX_C, "C"); |
||||
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_put_placeholder(inst, IDX_C, "C"); |
||||
member_sync_start(inst, g_sh.nid[IDX_A], CH_ID, NULL, NULL); |
||||
member_sync_start(inst, g_sh.nid[IDX_C], CH_ID, NULL, NULL); |
||||
break; |
||||
case IDX_C: |
||||
channel_put_shared(inst, 0); |
||||
member_put_self(inst, "C"); |
||||
member_put_placeholder(inst, IDX_A, "A"); |
||||
member_put_placeholder(inst, IDX_B, "B"); |
||||
member_sync_start(inst, g_sh.nid[IDX_B], CH_ID, NULL, NULL); |
||||
break; |
||||
} |
||||
} |
||||
|
||||
/* ── talk: 5 кадров + FIN от A ── */ |
||||
|
||||
static void do_talk(struct UTUN_INSTANCE* A) { |
||||
uint64_t gid = strtoull(CH_ID, NULL, 10); |
||||
static const uint8_t opus[16] = { 0xf8, 0xff, 0xfe, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d }; |
||||
radio_talk_begin(A, gid); |
||||
for (int i = 0; i < N_FRAMES; i++) radio_talk_send(A, gid, opus, sizeof(opus)); |
||||
radio_talk_end(A, gid); |
||||
} |
||||
|
||||
/* ── master state-machine ── */ |
||||
|
||||
static void rad_tick(void* arg) { |
||||
struct rad_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]; |
||||
struct UTUN_INSTANCE* C = t->inst[IDX_C]; |
||||
uint64_t na = g_sh.nid[IDX_A], nb = g_sh.nid[IDX_B], nc = g_sh.nid[IDX_C]; |
||||
uint64_t gid = strtoull(CH_ID, NULL, 10); |
||||
|
||||
switch (t->phase) { |
||||
case P_WAIT_CONN: |
||||
if (conn_up(A, nb) && conn_up(B, na) && conn_up(B, nc) && conn_up(C, nb)) |
||||
t->phase = P_SETUP; |
||||
break; |
||||
case P_SETUP: |
||||
do_setup(A, IDX_A); |
||||
do_setup(B, IDX_B); |
||||
do_setup(C, IDX_C); |
||||
t->phase = P_WAIT_BGP; |
||||
break; |
||||
case P_WAIT_BGP: |
||||
if (bgp_has(A, nb) && bgp_has(A, nc) && bgp_has(B, na) && bgp_has(B, nc) && bgp_has(C, na) && bgp_has(C, nb)) |
||||
t->phase = P_RADIO_ON; |
||||
break; |
||||
case P_RADIO_ON: |
||||
radio_set_active(A, gid, 1); |
||||
radio_set_active(B, gid, 1); |
||||
radio_set_active(C, gid, 1); |
||||
t->phase = P_WAIT_FLAGS; |
||||
break; |
||||
case P_WAIT_FLAGS: |
||||
if (node_radio(A, nb) && node_radio(A, nc) && node_radio(B, na) && node_radio(B, nc) && node_radio(C, na) && node_radio(C, nb)) |
||||
t->phase = P_TALK1; |
||||
break; |
||||
case P_TALK1: |
||||
memset(g_rx, 0, sizeof(g_rx)); |
||||
do_talk(A); |
||||
t->phase = P_WAIT_RX1; |
||||
break; |
||||
case P_WAIT_RX1: |
||||
if (g_rx[IDX_B].frames == N_FRAMES && g_rx[IDX_B].fin_seen && g_rx[IDX_B].seq_ok |
||||
&& g_rx[IDX_C].frames == N_FRAMES && g_rx[IDX_C].fin_seen && g_rx[IDX_C].seq_ok) |
||||
t->phase = P_RADIO_OFF_C; |
||||
break; |
||||
case P_RADIO_OFF_C: |
||||
radio_set_active(C, gid, 0); |
||||
t->phase = P_WAIT_OFF_C; |
||||
break; |
||||
case P_WAIT_OFF_C: |
||||
/* ждём, пока A и B увидят radio=0 у C (обрезание ветви C) */ |
||||
if (!node_radio(A, nc) && !node_radio(B, nc)) { |
||||
memset(g_rx, 0, sizeof(g_rx)); |
||||
t->phase = P_TALK2; |
||||
} |
||||
break; |
||||
case P_TALK2: |
||||
do_talk(A); |
||||
t->phase = P_WAIT_RX2; |
||||
break; |
||||
case P_WAIT_RX2: |
||||
if (g_rx[IDX_B].frames == N_FRAMES && g_rx[IDX_B].fin_seen && g_rx[IDX_B].seq_ok |
||||
&& g_rx[IDX_C].frames == 0 && !g_rx[IDX_C].fin_seen) |
||||
t->phase = P_DONE; |
||||
break; |
||||
case P_DONE: |
||||
t->result = 1; |
||||
uasync_stop(t->ua); |
||||
return; |
||||
} |
||||
|
||||
if (++t->ticks > MAX_TICKS) { |
||||
fprintf(stderr, "test: timeout in phase %d (B_rx=%d/%d fin=%d seqok=%d C_rx=%d/%d fin=%d seqok=%d radio_flags: A(B=%d C=%d) B(A=%d C=%d) C(A=%d B=%d))\n", |
||||
(int)t->phase, |
||||
g_rx[IDX_B].frames, N_FRAMES, g_rx[IDX_B].fin_seen, g_rx[IDX_B].seq_ok, |
||||
g_rx[IDX_C].frames, N_FRAMES, g_rx[IDX_C].fin_seen, g_rx[IDX_C].seq_ok, |
||||
node_radio(A, nb), node_radio(A, nc), node_radio(B, na), node_radio(B, nc), |
||||
node_radio(C, na), node_radio(C, nb)); |
||||
t->result = 2; |
||||
uasync_stop(t->ua); |
||||
return; |
||||
} |
||||
t->timer = uasync_set_timeout(t->ua, TICK_TB, t, rad_tick, "rad_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("radio", "debug"); |
||||
debug_set_category_level_by_name("bgp", "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_radio_XXXXXX"); |
||||
if (!mkdtemp(dir)) { fprintf(stderr, "mkdtemp failed\n"); return 1; } |
||||
setenv("UTUN_TEST_DIR", dir, 1); |
||||
|
||||
fill_shared(58000 + (getpid() % 2000)); |
||||
write_configs(dir); |
||||
|
||||
printf("=== test_radio ===\n"); fflush(stdout); |
||||
|
||||
TEST("single-thread: radio flag + talk (seq/fin) + relay + prune (3 nodes)"); { |
||||
struct UASYNC* ua = uasync_create(); |
||||
if (!ua) { FAIL("uasync_create failed"); return 1; } |
||||
|
||||
struct rad_ctx t; |
||||
memset(&t, 0, sizeof(t)); |
||||
t.ua = ua; |
||||
t.phase = P_WAIT_CONN; |
||||
|
||||
char cfgA[512], cfgB[512], cfgC[512]; |
||||
snprintf(cfgA, sizeof(cfgA), "%s/a.conf", dir); |
||||
snprintf(cfgB, sizeof(cfgB), "%s/b.conf", dir); |
||||
snprintf(cfgC, sizeof(cfgC), "%s/c.conf", dir); |
||||
|
||||
t.inst[IDX_A] = utun_instance_create(ua, cfgA); |
||||
t.inst[IDX_B] = utun_instance_create(ua, cfgB); |
||||
t.inst[IDX_C] = utun_instance_create(ua, cfgC); |
||||
if (!t.inst[IDX_A] || !t.inst[IDX_B] || !t.inst[IDX_C]) { |
||||
FAIL("instance create failed A=%p B=%p C=%p", (void*)t.inst[IDX_A], (void*)t.inst[IDX_B], (void*)t.inst[IDX_C]); |
||||
uasync_destroy(ua, 0); |
||||
return 1; |
||||
} |
||||
utun_instance_init(t.inst[IDX_A]); |
||||
utun_instance_init(t.inst[IDX_B]); |
||||
utun_instance_init(t.inst[IDX_C]); |
||||
|
||||
radio_set_frame_cb(t.inst[IDX_A], rx_frame_cb, &g_rx[IDX_A]); |
||||
radio_set_frame_cb(t.inst[IDX_B], rx_frame_cb, &g_rx[IDX_B]); |
||||
radio_set_frame_cb(t.inst[IDX_C], rx_frame_cb, &g_rx[IDX_C]); |
||||
|
||||
t.timer = uasync_set_timeout(ua, TICK_TB, &t, rad_tick, "rad_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]); |
||||
if (t.inst[IDX_C]) utun_instance_destroy(t.inst[IDX_C]); |
||||
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,470 @@
|
||||
// test_radio_headless.c — e2e-тест рации через headless API (control + audio сокеты).
|
||||
//
|
||||
// Два узла (A, B) в одном процессе, один UASYNC. Тест выступает «headless-клиентом»
|
||||
// обоих узлов: control-сокеты (JSON: radio_on/off/subscribers) и audio-сокеты
|
||||
// (бинарные PCM-фреймы рации) каждого узла.
|
||||
//
|
||||
// Сценарий (через реальные TCP-сокеты):
|
||||
// A radio_on, B radio_on → A: PTT_START + FRAME(ненулевой PCM) + PTT_STOP
|
||||
// → B получает микшированный ненулевой PCM → radio_subscribers(A) == 1.
|
||||
|
||||
#include "chat_core.h" |
||||
#include "chat_sync.h" |
||||
#include "chat_event.h" |
||||
#include "member_sync.h" |
||||
#include "chat_headless_control.h" |
||||
#include "../radio/radio.h" |
||||
#include "../radio/radio_headless.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 hr_phase { |
||||
P_WAIT_CONN, |
||||
P_SETUP, |
||||
P_WAIT_BGP, |
||||
P_CONNECT_CLIENTS, |
||||
P_RADIO_ON, |
||||
P_WAIT_RADIO_ON, |
||||
P_BIND_AUDIO, |
||||
P_TALK, |
||||
P_WAIT_MEDIA, |
||||
P_CHECK_SUBSCRIBERS, |
||||
P_WAIT_SUBSCRIBERS, |
||||
P_DONE, |
||||
}; |
||||
|
||||
struct hr_conn { |
||||
socket_t fd; |
||||
void* socket_id; |
||||
char buf[65536]; |
||||
int len; |
||||
}; |
||||
|
||||
struct hr_ctx { |
||||
struct UASYNC* ua; |
||||
struct UTUN_INSTANCE* inst[2]; |
||||
enum hr_phase phase; |
||||
int result; |
||||
int ticks; |
||||
void* timer; |
||||
struct hr_conn ctl[2]; |
||||
struct hr_conn aud[2]; |
||||
}; |
||||
|
||||
struct hr_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 hr_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-radio") + 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.99.%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 int node_radio(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->radio : 0; |
||||
} |
||||
|
||||
static void channel_put_shared(struct UTUN_INSTANCE* inst, int has_priv) { |
||||
topo_node_sqlite_channel_put(inst->topo_sqlite_db, CH_ID, "hl-radio", 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 hr_recv_cb(socket_t fd, void* arg) { |
||||
struct hr_conn* c = (struct hr_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; |
||||
} |
||||
} |
||||
|
||||
static int hr_connect(struct hr_ctx* t, struct hr_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, hr_recv_cb, NULL, NULL, c); |
||||
if (!c->socket_id) { socket_close_wrapper(c->fd); c->fd = SOCKET_INVALID; return -1; } |
||||
return 0; |
||||
} |
||||
|
||||
static void hr_send(struct hr_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 hr_has(struct hr_conn* c, const char* substr) { |
||||
return c && memmem(c->buf, (size_t)c->len, substr, strlen(substr)) != NULL; |
||||
} |
||||
|
||||
/* есть ли в audio-буфере FRAME с ненулевым PCM (данные рации) */ |
||||
static int hr_has_nonzero_pcm(struct hr_conn* c) { |
||||
if (!c) return 0; |
||||
const char* buf = c->buf; |
||||
int len = c->len; |
||||
for (int i = 0; i + RADIO_AUDIO_HDR_SIZE <= len; i++) { |
||||
if ((uint8_t)buf[i] != RADIO_AUDIO_FRAME) continue; |
||||
uint16_t flen = (uint16_t)(((uint8_t)buf[i + 9] << 8) | (uint8_t)buf[i + 10]); |
||||
if (flen != RADIO_AUDIO_PCM_FRAME_BYTES) continue; |
||||
if (i + RADIO_AUDIO_HDR_SIZE + flen > len) break; |
||||
const int16_t* pcm = (const int16_t*)(buf + i + RADIO_AUDIO_HDR_SIZE); |
||||
for (int k = 0; k < 960; k++) if (pcm[k] != 0) return 1; |
||||
} |
||||
return 0; |
||||
} |
||||
|
||||
/* ── master state-machine ── */ |
||||
|
||||
static void hr_tick(void* arg) { |
||||
struct hr_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]; |
||||
uint64_t gid = strtoull(CH_ID, NULL, 10); |
||||
|
||||
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 (radio_headless_init(A->ua, A, "127.0.0.1", 9710 + (int)(getpid() % 100)) != 0 |
||||
|| radio_headless_init(B->ua, B, "127.0.0.1", 9810 + (int)(getpid() % 100)) != 0) { |
||||
fprintf(stderr, "radio 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 (hr_connect(t, &t->ctl[IDX_A], 9700 + (int)(getpid() % 100)) != 0 |
||||
|| hr_connect(t, &t->ctl[IDX_B], 9800 + (int)(getpid() % 100)) != 0 |
||||
|| hr_connect(t, &t->aud[IDX_A], 9710 + (int)(getpid() % 100)) != 0 |
||||
|| hr_connect(t, &t->aud[IDX_B], 9810 + (int)(getpid() % 100)) != 0) { |
||||
fprintf(stderr, "client connect failed\n"); t->result = 2; break; |
||||
} |
||||
t->phase = P_RADIO_ON; |
||||
break; |
||||
case P_RADIO_ON: { |
||||
char json[256]; |
||||
snprintf(json, sizeof(json), "{\"id\":1,\"cmd\":\"radio_on\",\"ch\":\"%s\"}\n", CH_ID); |
||||
hr_send(&t->ctl[IDX_A], json, strlen(json)); |
||||
hr_send(&t->ctl[IDX_B], json, strlen(json)); |
||||
t->phase = P_WAIT_RADIO_ON; |
||||
break; |
||||
} |
||||
case P_WAIT_RADIO_ON: |
||||
/* оба подтвердили radio_on и A видит флаг B через BGP */ |
||||
if (hr_has(&t->ctl[IDX_A], "\"radio\":true") && hr_has(&t->ctl[IDX_B], "\"radio\":true") |
||||
&& node_radio(A, nb) && node_radio(B, na)) |
||||
t->phase = P_BIND_AUDIO; |
||||
break; |
||||
case P_BIND_AUDIO: { |
||||
uint8_t hdr[RADIO_AUDIO_HDR_SIZE]; |
||||
hdr[0] = RADIO_AUDIO_HELLO; |
||||
memcpy(hdr + 1, &gid, 8); |
||||
hdr[9] = 0; hdr[10] = 0; |
||||
hr_send(&t->aud[IDX_A], hdr, sizeof(hdr)); |
||||
hr_send(&t->aud[IDX_B], hdr, sizeof(hdr)); |
||||
t->phase = P_TALK; |
||||
break; |
||||
} |
||||
case P_TALK: { |
||||
uint8_t ptt[RADIO_AUDIO_HDR_SIZE]; |
||||
ptt[0] = RADIO_AUDIO_PTT_START; memcpy(ptt + 1, &gid, 8); ptt[9] = 0; ptt[10] = 0; |
||||
hr_send(&t->aud[IDX_A], ptt, sizeof(ptt)); |
||||
|
||||
uint8_t frm[RADIO_AUDIO_HDR_SIZE + RADIO_AUDIO_PCM_FRAME_BYTES]; |
||||
frm[0] = RADIO_AUDIO_FRAME; memcpy(frm + 1, &gid, 8); |
||||
frm[9] = (uint8_t)(RADIO_AUDIO_PCM_FRAME_BYTES >> 8); |
||||
frm[10] = (uint8_t)(RADIO_AUDIO_PCM_FRAME_BYTES & 0xFF); |
||||
for (int k = 0; k < 960; k++) ((int16_t*)(frm + RADIO_AUDIO_HDR_SIZE))[k] = 1000; |
||||
hr_send(&t->aud[IDX_A], frm, sizeof(frm)); |
||||
|
||||
ptt[0] = RADIO_AUDIO_PTT_STOP; |
||||
hr_send(&t->aud[IDX_A], ptt, sizeof(ptt)); |
||||
t->phase = P_WAIT_MEDIA; |
||||
break; |
||||
} |
||||
case P_WAIT_MEDIA: |
||||
if (hr_has_nonzero_pcm(&t->aud[IDX_B])) t->phase = P_CHECK_SUBSCRIBERS; |
||||
break; |
||||
case P_CHECK_SUBSCRIBERS: { |
||||
char json[256]; |
||||
snprintf(json, sizeof(json), "{\"id\":2,\"cmd\":\"radio_subscribers\",\"ch\":\"%s\"}\n", CH_ID); |
||||
hr_send(&t->ctl[IDX_A], json, strlen(json)); |
||||
t->phase = P_WAIT_SUBSCRIBERS; |
||||
break; |
||||
} |
||||
case P_WAIT_SUBSCRIBERS: |
||||
if (hr_has(&t->ctl[IDX_A], "\"subscribers\":1")) 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 radio_flags A(B)=%d B(A)=%d)\n", |
||||
(int)t->phase, conn_up(A, nb), conn_up(B, na), node_radio(A, nb), node_radio(B, na)); |
||||
t->result = 2; |
||||
uasync_stop(t->ua); |
||||
return; |
||||
} |
||||
t->timer = uasync_set_timeout(t->ua, TICK_TB, t, hr_tick, "hr_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("radio", "debug"); |
||||
debug_set_category_level_by_name("bgp", "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_radio_hl_XXXXXX"); |
||||
if (!mkdtemp(dir)) { fprintf(stderr, "mkdtemp failed\n"); return 1; } |
||||
setenv("UTUN_TEST_DIR", dir, 1); |
||||
|
||||
fill_shared(59000 + (getpid() % 2000)); |
||||
write_configs(dir); |
||||
|
||||
printf("=== test_radio_headless ===\n"); fflush(stdout); |
||||
|
||||
TEST("headless: radio_on + PTT PCM-поток + radio_subscribers через сокеты"); { |
||||
struct UASYNC* ua = uasync_create(); |
||||
if (!ua) { FAIL("uasync_create failed"); return 1; } |
||||
|
||||
struct hr_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, hr_tick, "hr_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,205 @@
|
||||
package com.utun.chat.data |
||||
|
||||
import android.content.Context |
||||
import android.media.AudioAttributes |
||||
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 |
||||
|
||||
/** |
||||
* Полудуплексный аудио-движок рации (аналог CallAudioEngine, но с PTT). |
||||
* |
||||
* Потоки: |
||||
* - capture-поток: AudioRecord.read(960) → NativeLib.radioAudioFeed (только при зажатом PTT) |
||||
* - play-поток: NativeLib.radioAudioPull (C: per-source jitter → декод → микшер) → AudioTrack.write |
||||
* |
||||
* Воспроизведение тянет микшированный PCM всех говорящих из единого голосового стека |
||||
* в C (radio_audio): per-source jitter + time-stretch + микшер. |
||||
*/ |
||||
class RadioAudioEngine { |
||||
|
||||
companion object { |
||||
const val SAMPLE_RATE = 48000 |
||||
const val FRAME_MS = 20 |
||||
const val FRAME_SAMPLES = SAMPLE_RATE * FRAME_MS / 1000 // 960 |
||||
|
||||
/** Процессный синглтон: доступен из Activity и оверлей-сервиса. */ |
||||
@Volatile private var inst: RadioAudioEngine? = null |
||||
fun instance(): RadioAudioEngine = inst ?: synchronized(this) { |
||||
inst ?: RadioAudioEngine().also { inst = it } |
||||
} |
||||
} |
||||
|
||||
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 |
||||
|
||||
@Volatile private var active = false |
||||
@Volatile private var groupId = 0L |
||||
@Volatile private var talking = false |
||||
|
||||
fun isActive(): Boolean = active |
||||
|
||||
fun isTalking(): Boolean = talking |
||||
|
||||
/** Начать прослушивание канала (переключатель рации вкл). */ |
||||
fun start(group: Long): Boolean { |
||||
stop() |
||||
|
||||
if (!NativeLib.radioAudioStart(group)) { |
||||
LogManager.addLog("ERROR", "RadioAudio", "native radioAudioStart failed group=$group") |
||||
return false |
||||
} |
||||
|
||||
groupId = group |
||||
talking = 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", "RadioAudio", "set audio mode failed: ${e.message}") |
||||
} |
||||
|
||||
// ── 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", "RadioAudio", "AudioRecord create failed: ${e.message}") |
||||
NativeLib.radioAudioStop(); restoreAudioMode(); return false |
||||
} |
||||
if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) { |
||||
LogManager.addLog("ERROR", "RadioAudio", "AudioRecord not initialized") |
||||
audioRecord?.release(); audioRecord = null |
||||
NativeLib.radioAudioStop(); 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", "RadioAudio", "AudioTrack create failed: ${e.message}") |
||||
audioRecord?.release(); audioRecord = null |
||||
audioTrack = null |
||||
NativeLib.radioAudioStop(); restoreAudioMode(); return false |
||||
} |
||||
|
||||
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", "RadioAudio", "started group=$group recBuf=$recMin trackBuf=$trackBuf") |
||||
return true |
||||
} |
||||
|
||||
fun stop() { |
||||
if (!active) return |
||||
active = false |
||||
talking = 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 |
||||
|
||||
NativeLib.radioAudioStop() |
||||
restoreAudioMode() |
||||
|
||||
groupId = 0L |
||||
LogManager.addLog("INFO", "RadioAudio", "stopped") |
||||
} |
||||
|
||||
/** PTT зажата — начинаем передачу. */ |
||||
fun talkBegin() { |
||||
if (!active || talking) return |
||||
talking = true |
||||
NativeLib.radioTalkBegin(groupId) |
||||
} |
||||
|
||||
/** PTT отпущена — завершаем передачу (FIN). */ |
||||
fun talkEnd() { |
||||
if (!active || !talking) return |
||||
talking = false |
||||
NativeLib.radioTalkEnd(groupId) |
||||
} |
||||
|
||||
private fun captureLoop() { |
||||
val buf = 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 |
||||
} |
||||
if (talking && !NativeLib.radioAudioFeed(groupId, buf)) { |
||||
LogManager.addLog("WARN", "RadioAudio", "radioAudioFeed returned false") |
||||
} |
||||
} |
||||
LogManager.addLog("DEBUG", "RadioAudio", "capture loop done") |
||||
} |
||||
|
||||
private fun playLoop() { |
||||
val buf = ShortArray(FRAME_SAMPLES) |
||||
val silence = ShortArray(FRAME_SAMPLES) |
||||
while (active) { |
||||
val t0 = System.nanoTime() |
||||
val track = audioTrack ?: return |
||||
val n = NativeLib.radioAudioPull(groupId, buf) |
||||
if (n > 0) track.write(buf, 0, n) |
||||
if (n < FRAME_SAMPLES) track.write(silence, 0, FRAME_SAMPLES - n) |
||||
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", "RadioAudio", "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", "RadioAudio", "restore audio mode failed: ${e.message}") |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,169 @@
|
||||
package com.utun.chat.data |
||||
|
||||
import android.app.NotificationChannel |
||||
import android.app.NotificationManager |
||||
import android.app.Service |
||||
import android.content.Context |
||||
import android.content.Intent |
||||
import android.graphics.PixelFormat |
||||
import android.os.Build |
||||
import android.os.IBinder |
||||
import android.provider.Settings |
||||
import android.view.Gravity |
||||
import android.view.LayoutInflater |
||||
import android.view.MotionEvent |
||||
import android.view.View |
||||
import android.view.WindowManager |
||||
import android.widget.TextView |
||||
import androidx.core.app.NotificationCompat |
||||
import kotlin.math.abs |
||||
|
||||
/** |
||||
* Оверлей PTT поверх других приложений: плавающая круглая кнопка, drag-and-drop. |
||||
* Удержание активирует передачу (RadioAudioEngine.talkBegin/talkEnd), отпускание — |
||||
* завершает. Работает пока включена опция «PTT поверх приложений» и разрешено |
||||
* «поверх других приложений» (SYSTEM_ALERT_WINDOW). |
||||
*/ |
||||
class RadioOverlayService : Service() { |
||||
|
||||
companion object { |
||||
const val CHANNEL_ID = "radio_overlay" |
||||
const val NOTIF_ID = 1002 |
||||
|
||||
fun canDrawOverlays(ctx: Context): Boolean = |
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.M || Settings.canDrawOverlays(ctx) |
||||
|
||||
fun start(ctx: Context) { |
||||
val i = Intent(ctx, RadioOverlayService::class.java) |
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) |
||||
ctx.startForegroundService(i) |
||||
else |
||||
ctx.startService(i) |
||||
} |
||||
|
||||
fun stop(ctx: Context) { |
||||
ctx.stopService(Intent(ctx, RadioOverlayService::class.java)) |
||||
} |
||||
} |
||||
|
||||
private var windowManager: WindowManager? = null |
||||
private var overlayView: View? = null |
||||
private var params: WindowManager.LayoutParams? = null |
||||
private var talking = false |
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null |
||||
|
||||
override fun onCreate() { |
||||
super.onCreate() |
||||
createChannel() |
||||
startForeground(NOTIF_ID, buildNotification()) |
||||
|
||||
windowManager = getSystemService(WINDOW_SERVICE) as WindowManager |
||||
if (!canDrawOverlays(this)) { |
||||
LogManager.addLog("WARN", "RadioOverlay", "SYSTEM_ALERT_WINDOW not granted — stopping") |
||||
stopSelf() |
||||
return |
||||
} |
||||
addOverlay() |
||||
} |
||||
|
||||
private fun createChannel() { |
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { |
||||
val ch = NotificationChannel(CHANNEL_ID, "Рация PTT", NotificationManager.IMPORTANCE_LOW).apply { |
||||
description = "Оверлей PTT рации" |
||||
} |
||||
getSystemService(NotificationManager::class.java).createNotificationChannel(ch) |
||||
} |
||||
} |
||||
|
||||
private fun buildNotification() = NotificationCompat.Builder(this, CHANNEL_ID) |
||||
.setSmallIcon(android.R.drawable.ic_btn_speak_now) |
||||
.setContentTitle("Рация активна") |
||||
.setContentText("PTT-кнопка поверх приложений") |
||||
.setOngoing(true) |
||||
.build() |
||||
|
||||
private fun addOverlay() { |
||||
val wm = windowManager ?: return |
||||
val v = TextView(this).apply { |
||||
text = "\uD83C\uDF99" |
||||
textSize = 30f |
||||
gravity = android.view.Gravity.CENTER |
||||
setBackgroundColor(0xFFE74C3C.toInt()) |
||||
} |
||||
val lp = WindowManager.LayoutParams( |
||||
WindowManager.LayoutParams.WRAP_CONTENT, |
||||
WindowManager.LayoutParams.WRAP_CONTENT, |
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) |
||||
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY |
||||
else |
||||
@Suppress("DEPRECATION") WindowManager.LayoutParams.TYPE_PHONE, |
||||
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, |
||||
PixelFormat.TRANSLUCENT |
||||
).apply { |
||||
width = dp(96); height = dp(96) |
||||
gravity = Gravity.TOP or Gravity.START |
||||
x = 16; y = 200 |
||||
} |
||||
v.setOnTouchListener(object : View.OnTouchListener { |
||||
private var startX = 0; private var startY = 0 |
||||
private var touchX = 0f; private var touchY = 0f |
||||
private var dragging = false |
||||
|
||||
override fun onTouch(view: View, event: MotionEvent): Boolean { |
||||
when (event.action) { |
||||
MotionEvent.ACTION_DOWN -> { |
||||
startX = params?.x ?: lp.x |
||||
startY = params?.y ?: lp.y |
||||
touchX = event.rawX |
||||
touchY = event.rawY |
||||
dragging = false |
||||
beginTalk() |
||||
return true |
||||
} |
||||
MotionEvent.ACTION_MOVE -> { |
||||
if (abs(event.rawX - touchX) > 8 || abs(event.rawY - touchY) > 8) { |
||||
dragging = true |
||||
endTalk() |
||||
lp.x = startX + (event.rawX - touchX).toInt() |
||||
lp.y = startY + (event.rawY - touchY).toInt() |
||||
wm.updateViewLayout(v, lp) |
||||
} |
||||
return true |
||||
} |
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { |
||||
endTalk() |
||||
return true |
||||
} |
||||
} |
||||
return false |
||||
} |
||||
}) |
||||
wm.addView(v, lp) |
||||
overlayView = v |
||||
params = lp |
||||
LogManager.addLog("INFO", "RadioOverlay", "overlay shown") |
||||
} |
||||
|
||||
private fun beginTalk() { |
||||
if (talking) return |
||||
talking = true |
||||
RadioAudioEngine.instance().talkBegin() |
||||
} |
||||
|
||||
private fun endTalk() { |
||||
if (!talking) return |
||||
talking = false |
||||
RadioAudioEngine.instance().talkEnd() |
||||
} |
||||
|
||||
private fun dp(v: Int): Int = (v * resources.displayMetrics.density).toInt() |
||||
|
||||
override fun onDestroy() { |
||||
endTalk() |
||||
overlayView?.let { windowManager?.removeView(it) } |
||||
overlayView = null |
||||
LogManager.addLog("INFO", "RadioOverlay", "overlay destroyed") |
||||
super.onDestroy() |
||||
} |
||||
} |
||||
@ -0,0 +1,75 @@
|
||||
#include "ptt_hotkey_manager.h" |
||||
#include "radio_audio_engine.h" |
||||
#include "../db/db_manager.h" |
||||
#include "qhotkey.h" |
||||
|
||||
#include <QKeySequence> |
||||
#include <QDebug> |
||||
|
||||
static PttHotkeyManager* g_pttHotkeys = nullptr; |
||||
|
||||
PttHotkeyManager* PttHotkeyManager::instance() { |
||||
if (!g_pttHotkeys) g_pttHotkeys = new PttHotkeyManager(); |
||||
return g_pttHotkeys; |
||||
} |
||||
|
||||
bool PttHotkeyManager::isSupported() { |
||||
return QHotkey::isPlatformSupported(); |
||||
} |
||||
|
||||
void PttHotkeyManager::init(DbManager* db) { |
||||
m_db = db; |
||||
reload(); |
||||
} |
||||
|
||||
QStringList PttHotkeyManager::hotkeys() const { |
||||
return m_seqs; |
||||
} |
||||
|
||||
void PttHotkeyManager::setHotkeys(const QStringList& seqs) { |
||||
applyHotkeys(seqs); |
||||
} |
||||
|
||||
void PttHotkeyManager::reload() { |
||||
QStringList seqs; |
||||
if (m_db && m_db->isOpen()) { |
||||
const QString raw = m_db->getUiState("radio_ptt_hotkeys"); |
||||
if (!raw.isEmpty()) |
||||
seqs = raw.split('|', Qt::SkipEmptyParts); |
||||
} |
||||
applyHotkeys(seqs); |
||||
} |
||||
|
||||
void PttHotkeyManager::clearHotkeys() { |
||||
for (QHotkey* h : m_hotkeys) { |
||||
h->resetShortcut(); |
||||
h->deleteLater(); |
||||
} |
||||
m_hotkeys.clear(); |
||||
} |
||||
|
||||
void PttHotkeyManager::applyHotkeys(const QStringList& seqs) { |
||||
clearHotkeys(); |
||||
m_seqs.clear(); |
||||
|
||||
for (const QString& s : seqs) { |
||||
QKeySequence seq(s); |
||||
if (seq.isEmpty()) continue; |
||||
auto* hk = new QHotkey(seq, true, this); |
||||
if (!hk->isRegistered()) { |
||||
qWarning("PttHotkey: failed to register '%s'", qPrintable(s)); |
||||
hk->deleteLater(); |
||||
continue; |
||||
} |
||||
connect(hk, &QHotkey::activated, this, []() { |
||||
RadioAudioEngine::instance()->talkBegin(); |
||||
}); |
||||
connect(hk, &QHotkey::released, this, []() { |
||||
RadioAudioEngine::instance()->talkEnd(); |
||||
}); |
||||
m_hotkeys.append(hk); |
||||
m_seqs.append(seq.toString()); |
||||
} |
||||
|
||||
qDebug("PttHotkey: applied %d hotkey(s)", (int)m_hotkeys.size()); |
||||
} |
||||
@ -0,0 +1,39 @@
|
||||
#pragma once |
||||
|
||||
#include <QObject> |
||||
#include <QList> |
||||
#include <QStringList> |
||||
|
||||
class QHotkey; |
||||
class DbManager; |
||||
|
||||
/* Глобальные PTT-хоткеи рации (QHotkey): удержание любой комбинации активирует
|
||||
* передачу (talkBegin), отпускание — завершает (talkEnd). Работает из любого |
||||
* приложения. Хранится в ui_state ("radio_ptt_hotkeys", разделитель "|"). */ |
||||
class PttHotkeyManager : public QObject { |
||||
Q_OBJECT |
||||
public: |
||||
static PttHotkeyManager* instance(); |
||||
|
||||
void init(DbManager* db); |
||||
|
||||
/* Текущий список хоткеев (QKeySequence-строки). */ |
||||
QStringList hotkeys() const; |
||||
|
||||
/* Применить список хоткеев (без записи в БД — сохранение делает SettingsDialog). */ |
||||
void setHotkeys(const QStringList& seqs); |
||||
|
||||
/* Перечитать из ui_state и применить. */ |
||||
void reload(); |
||||
|
||||
static bool isSupported(); |
||||
|
||||
private: |
||||
PttHotkeyManager() = default; |
||||
void clearHotkeys(); |
||||
void applyHotkeys(const QStringList& seqs); |
||||
|
||||
DbManager* m_db = nullptr; |
||||
QList<QHotkey*> m_hotkeys; |
||||
QStringList m_seqs; |
||||
}; |
||||
@ -0,0 +1,153 @@
|
||||
#include "radio_audio_engine.h" |
||||
#include "sound_manager.h" |
||||
#include "../transport/gui_bridge.h" |
||||
|
||||
#include "miniaudio.h" |
||||
|
||||
extern "C" { |
||||
#include "radio/radio_audio.h" |
||||
#include "../../lib/debug_config.h" |
||||
} |
||||
|
||||
#include <cstring> |
||||
|
||||
static RadioAudioEngine* g_radioAudioEngine = nullptr; |
||||
|
||||
static void radioAudioDataCallback(ma_device* dev, void* out, const void* in, ma_uint32 frames) { |
||||
RadioAudioEngine* e = (RadioAudioEngine*)dev->pUserData; |
||||
if (e) e->dataCallback(in, out, frames); |
||||
} |
||||
|
||||
RadioAudioEngine* RadioAudioEngine::instance() { |
||||
if (!g_radioAudioEngine) g_radioAudioEngine = new RadioAudioEngine(); |
||||
return g_radioAudioEngine; |
||||
} |
||||
|
||||
int RadioAudioEngine::start(quint64 groupId) { |
||||
if (m_active) { |
||||
if (m_groupId == groupId) return 0; |
||||
stop(); |
||||
} |
||||
m_groupId = groupId; |
||||
|
||||
struct UTUN_INSTANCE* inst = gui_bridge_get_inst(); |
||||
if (!inst) { |
||||
DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "RadioAudio: no instance, cannot start"); |
||||
return -1; |
||||
} |
||||
if (radio_audio_init(inst) != 0) { |
||||
DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "RadioAudio: radio_audio_init failed"); |
||||
return -1; |
||||
} |
||||
if (radio_audio_start(groupId) != 0) { |
||||
DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "RadioAudio: radio_audio_start failed grp=%016llx", (unsigned long long)groupId); |
||||
return -1; |
||||
} |
||||
|
||||
m_captureBuf.assign(RADIO_AUDIO_FRAME_SAMPLES, 0); |
||||
m_capturePos = 0; |
||||
|
||||
if (initDevice() != 0) { |
||||
radio_audio_stop(); |
||||
return -1; |
||||
} |
||||
|
||||
m_active = true; |
||||
DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "RadioAudio: started grp=%016llx", (unsigned long long)groupId); |
||||
return 0; |
||||
} |
||||
|
||||
int RadioAudioEngine::initDevice() { |
||||
ma_context* ctx = SoundManager::instance()->context(); |
||||
if (!ctx) { |
||||
DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "RadioAudio: 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 = RADIO_AUDIO_SAMPLE_RATE; |
||||
config.periodSizeInFrames = 480; |
||||
config.dataCallback = radioAudioDataCallback; |
||||
config.pUserData = this; |
||||
|
||||
if (ma_device_init(ctx, &config, m_device) != MA_SUCCESS) { |
||||
DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "RadioAudio: device init failed, trying default"); |
||||
config.playback.pDeviceID = nullptr; |
||||
config.capture.pDeviceID = nullptr; |
||||
if (ma_device_init(ctx, &config, m_device) != MA_SUCCESS) { |
||||
DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "RadioAudio: 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, "RadioAudio: 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 RadioAudioEngine::stop() { |
||||
if (!m_active && !m_device && !m_deviceStarted) return; |
||||
m_active = false; |
||||
m_talking = false; |
||||
quint64 gid = m_groupId.load(); |
||||
|
||||
teardownDevice(); |
||||
radio_audio_stop(); |
||||
|
||||
m_groupId = 0; |
||||
DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "RadioAudio: stopped"); |
||||
} |
||||
|
||||
void RadioAudioEngine::talkBegin() { |
||||
if (!m_active) return; |
||||
m_talking = true; |
||||
radio_audio_talk_begin(m_groupId.load()); |
||||
} |
||||
|
||||
void RadioAudioEngine::talkEnd() { |
||||
if (!m_active) return; |
||||
m_talking = false; |
||||
radio_audio_talk_end(m_groupId.load()); |
||||
} |
||||
|
||||
void RadioAudioEngine::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 RadioAudioEngine::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 (сервисный слой). Только когда PTT зажата. */ |
||||
if (in16 && m_talking.load()) { |
||||
for (unsigned int i = 0; i < frames; i++) { |
||||
m_captureBuf[m_capturePos++] = in16[i]; |
||||
if (m_capturePos >= (int)m_captureBuf.size()) { |
||||
radio_audio_feed_pcm(m_groupId.load(), m_captureBuf.data(), (int)m_captureBuf.size()); |
||||
m_capturePos = 0; |
||||
} |
||||
} |
||||
} |
||||
|
||||
/* воспроизведение: микшированный PCM из сервисного слоя */ |
||||
int n = radio_audio_pull_pcm(m_groupId.load(), out16, (int)frames); |
||||
if (n < (int)frames) |
||||
std::memset(out16 + n, 0, (size_t)(frames - n) * sizeof(int16_t)); |
||||
} |
||||
@ -0,0 +1,53 @@
|
||||
#pragma once |
||||
|
||||
#include <QObject> |
||||
#include <atomic> |
||||
#include <cstdint> |
||||
#include <vector> |
||||
|
||||
struct ma_device; |
||||
|
||||
/* Аудио-контур рации (desktop): только miniaudio I/O + захват/воспроизведение.
|
||||
* Весь аудиопроцессинг (Opus, per-source декодеры, микшер) — в сервисном слое |
||||
* src/radio/radio_audio.c; сюда приходит/отсюда уходит финальный PCM. |
||||
* |
||||
* Потоки: |
||||
* - аудио-поток (miniaudio callback): capture → radio_audio_feed_pcm, |
||||
* radio_audio_pull_pcm → playback; |
||||
* - GUI-поток: start()/stop() по включению/выключению рации канала. */ |
||||
class RadioAudioEngine : public QObject { |
||||
Q_OBJECT |
||||
public: |
||||
static RadioAudioEngine* instance(); |
||||
|
||||
/* Включить прослушивание канала (после toggle рации). 0 = успех. */ |
||||
int start(quint64 groupId); |
||||
|
||||
/* Выключить прослушивание канала (toggle рации выключен). */ |
||||
void stop(); |
||||
|
||||
/* PTT: зажата / отпущена (только когда радио канала включено). */ |
||||
void talkBegin(); |
||||
void talkEnd(); |
||||
|
||||
bool isActive() const { return m_active.load(); } |
||||
quint64 groupId() const { return m_groupId.load(); } |
||||
|
||||
/* аудио-поток: miniaudio data callback (захват + воспроизведение) */ |
||||
void dataCallback(const void* in, void* out, unsigned int frames); |
||||
|
||||
private: |
||||
RadioAudioEngine() = default; |
||||
void teardownDevice(); |
||||
int initDevice(); |
||||
|
||||
std::atomic<bool> m_active{false}; |
||||
std::atomic<bool> m_talking{false}; |
||||
std::atomic<uint64_t> m_groupId{0}; |
||||
|
||||
ma_device* m_device = nullptr; |
||||
bool m_deviceStarted = false; |
||||
|
||||
std::vector<int16_t> m_captureBuf; /* 960 samples, аудио-поток */ |
||||
int m_capturePos = 0; |
||||
}; |
||||
@ -0,0 +1,68 @@
|
||||
#include "radiosettingspage.h" |
||||
#include "ptt_hotkey_manager.h" |
||||
#include "../db/db_manager.h" |
||||
|
||||
#include <QFormLayout> |
||||
#include <QLabel> |
||||
#include <QKeySequenceEdit> |
||||
|
||||
RadioSettingsPage::RadioSettingsPage(DbManager* db, QWidget* parent) |
||||
: QWidget(parent), m_db(db) { |
||||
auto* layout = new QVBoxLayout(this); |
||||
|
||||
auto* title = new QLabel("Рация (PTT)", this); |
||||
title->setStyleSheet("font-weight: bold; font-size: 13px;"); |
||||
layout->addWidget(title); |
||||
|
||||
auto* hint = new QLabel( |
||||
"Глобальные хоткеи передачи: удержание любой комбинации активирует PTT " |
||||
"из любого приложения (рация канала должна быть включена).", this); |
||||
hint->setWordWrap(true); |
||||
hint->setStyleSheet("color: gray; font-size: 11px;"); |
||||
layout->addWidget(hint); |
||||
|
||||
if (!PttHotkeyManager::isSupported()) { |
||||
auto* warn = new QLabel("Глобальные хоткеи не поддерживаются на этой платформе.", this); |
||||
warn->setStyleSheet("color: #e74c3c; font-size: 12px;"); |
||||
layout->addWidget(warn); |
||||
} |
||||
|
||||
auto* form = new QFormLayout(); |
||||
for (int i = 0; i < 3; i++) { |
||||
auto* edit = new QKeySequenceEdit(this); |
||||
edit->setStyleSheet( |
||||
"QKeySequenceEdit { border: 1px solid palette(mid); border-radius: 4px;" |
||||
" padding: 6px 10px; font-size: 13px; background: palette(base); }"); |
||||
m_edits.append(edit); |
||||
form->addRow(QString("PTT хоткей %1:").arg(i + 1), edit); |
||||
} |
||||
layout->addLayout(form); |
||||
layout->addStretch(1); |
||||
|
||||
loadFromDb(); |
||||
} |
||||
|
||||
void RadioSettingsPage::loadFromDb() { |
||||
QStringList seqs; |
||||
if (m_db && m_db->isOpen()) { |
||||
const QString raw = m_db->getUiState("radio_ptt_hotkeys"); |
||||
if (!raw.isEmpty()) |
||||
seqs = raw.split('|', Qt::SkipEmptyParts); |
||||
} |
||||
for (int i = 0; i < m_edits.size(); i++) |
||||
m_edits[i]->setKeySequence(i < seqs.size() ? QKeySequence(seqs[i]) : QKeySequence()); |
||||
} |
||||
|
||||
QStringList RadioSettingsPage::hotkeys() const { |
||||
QStringList out; |
||||
for (QKeySequenceEdit* e : m_edits) { |
||||
const QKeySequence seq = e->keySequence(); |
||||
if (!seq.isEmpty()) |
||||
out << seq.toString(); |
||||
} |
||||
return out; |
||||
} |
||||
|
||||
void RadioSettingsPage::apply() { |
||||
PttHotkeyManager::instance()->setHotkeys(hotkeys()); |
||||
} |
||||
@ -0,0 +1,29 @@
|
||||
#pragma once |
||||
|
||||
#include <QWidget> |
||||
#include <QStringList> |
||||
#include <QVector> |
||||
|
||||
class QKeySequenceEdit; |
||||
class DbManager; |
||||
|
||||
/* Страница настроек рации: глобальные PTT-хоткеи (до 3 комбинаций).
|
||||
* Удержание любой из них активирует передачу из любого приложения. */ |
||||
class RadioSettingsPage : public QWidget { |
||||
Q_OBJECT |
||||
public: |
||||
explicit RadioSettingsPage(DbManager* db, QWidget* parent = nullptr); |
||||
|
||||
/* Загрузить хоткеи из ui_state в поля ввода. */ |
||||
void loadFromDb(); |
||||
|
||||
/* Список непустых QKeySequence-строк из полей. */ |
||||
QStringList hotkeys() const; |
||||
|
||||
/* Применить текущие хоткеи (без записи в БД). */ |
||||
void apply(); |
||||
|
||||
private: |
||||
DbManager* m_db; |
||||
QVector<QKeySequenceEdit*> m_edits; |
||||
}; |
||||
@ -0,0 +1,86 @@
|
||||
// test_radio_audio.cpp — тест аудио-движка рации (jitter + микшер).
|
||||
//
|
||||
// Проверяет сервисный слой src/radio/radio_audio.c без полного UTUN_INSTANCE:
|
||||
// 1. single-source: подаём кодированные Opus-кадры через radio_audio_on_frame,
|
||||
// radio_audio_pull_pcm выдаёт декодированный ненулевой PCM (jitter+decode).
|
||||
// 2. mixing: два источника с большими амплитудами — сумма насыщается (soft-clip),
|
||||
// что доказывает микширование нескольких источников.
|
||||
//
|
||||
// Сборка/запуск: через CMake (tools/chatgui), линкуется utun_voice + utun.
|
||||
|
||||
#include "radio/radio_audio.h" |
||||
|
||||
extern "C" { |
||||
#include "../../lib/opus_codec.h" |
||||
} |
||||
|
||||
#include <cstdio> |
||||
#include <cstring> |
||||
#include <cstdlib> |
||||
#include <cmath> |
||||
|
||||
#define GID 0x1234567890abcdefULL |
||||
#define SRC_A 0xAAAAULL |
||||
#define SRC_B 0xBBBBULL |
||||
|
||||
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) |
||||
|
||||
/* закодировать N кадров постоянного PCM и подать через on_frame */ |
||||
static void push_const(uint64_t src, uint16_t stream, int value, int nframes) { |
||||
opus_codec_encoder_t* enc = opus_codec_encoder_create(48000, 1); |
||||
if (!enc) { FAIL("encoder create failed"); return; } |
||||
opus_codec_encoder_bitrate_set(enc, 32000); |
||||
|
||||
int16_t pcm[960]; |
||||
for (int i = 0; i < 960; i++) pcm[i] = (int16_t)value; |
||||
|
||||
for (int f = 0; f < nframes; f++) { |
||||
uint8_t opus[256]; |
||||
int len = opus_codec_encode(enc, pcm, 960, opus, (int)sizeof(opus)); |
||||
if (len <= 0) { FAIL("encode failed"); break; } |
||||
radio_audio_on_frame(NULL, GID, src, stream, (uint16_t)f, 0, opus, len, NULL); |
||||
} |
||||
opus_codec_encoder_destroy(enc); |
||||
} |
||||
|
||||
/* вытянуть pull-цикл, вернуть max |sample| */ |
||||
static int pull_max_abs(int npulls) { |
||||
int mx = 0; |
||||
int16_t out[960]; |
||||
for (int p = 0; p < npulls; p++) { |
||||
int n = radio_audio_pull_pcm(GID, out, 960); |
||||
(void)n; |
||||
for (int i = 0; i < 960; i++) { |
||||
int a = out[i] < 0 ? -out[i] : out[i]; |
||||
if (a > mx) mx = a; |
||||
} |
||||
} |
||||
return mx; |
||||
} |
||||
|
||||
int main() { |
||||
printf("=== test_radio_audio ===\n"); fflush(stdout); |
||||
|
||||
TEST("single-source: decode через jitter-буфер выдаёт ненулевой PCM"); { |
||||
radio_audio_start(GID); |
||||
push_const(SRC_A, 1, 10000, 5); |
||||
int mx = pull_max_abs(10); |
||||
radio_audio_stop(); |
||||
if (mx > 1000) OK(); else FAIL("max|pcm|=%d (ожидали > 1000)", mx); |
||||
} |
||||
|
||||
TEST("mixing: два источника складываются (soft-clip)"); { |
||||
radio_audio_start(GID); |
||||
push_const(SRC_A, 1, 20000, 5); |
||||
push_const(SRC_B, 1, 20000, 5); |
||||
int mx = pull_max_abs(10); |
||||
radio_audio_stop(); |
||||
if (mx > 30000) OK(); else FAIL("max|pcm|=%d (ожидали > 30000 — сумма двух источников)", mx); |
||||
} |
||||
|
||||
printf("\n%d/%d passed, %d failed\n", G_PASSED, G_TOTAL, G_FAILED); |
||||
return G_FAILED > 0 ? 1 : 0; |
||||
} |
||||
@ -0,0 +1,377 @@
|
||||
#include "qhotkey.h" |
||||
#include "qhotkey_p.h" |
||||
#include <QCoreApplication> |
||||
#include <QAbstractEventDispatcher> |
||||
#include <QMetaMethod> |
||||
#include <QThread> |
||||
#include <QDebug> |
||||
|
||||
Q_LOGGING_CATEGORY(logQHotkey, "QHotkey") |
||||
|
||||
void QHotkey::addGlobalMapping(const QKeySequence &shortcut, QHotkey::NativeShortcut nativeShortcut) |
||||
{ |
||||
#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) |
||||
const int key = shortcut[0].toCombined(); |
||||
#else |
||||
const int key = shortcut[0]; |
||||
#endif |
||||
|
||||
QMetaObject::invokeMethod(QHotkeyPrivate::instance(), "addMappingInvoked", Qt::QueuedConnection, |
||||
Q_ARG(Qt::Key, Qt::Key(key & ~Qt::KeyboardModifierMask)), |
||||
Q_ARG(Qt::KeyboardModifiers, Qt::KeyboardModifiers(key & Qt::KeyboardModifierMask)), |
||||
Q_ARG(QHotkey::NativeShortcut, nativeShortcut)); |
||||
} |
||||
|
||||
bool QHotkey::isPlatformSupported() |
||||
{ |
||||
return QHotkeyPrivate::isPlatformSupported(); |
||||
} |
||||
|
||||
QHotkey::QHotkey(QObject *parent) : |
||||
QObject(parent), |
||||
_keyCode(Qt::Key_unknown), |
||||
_modifiers(Qt::NoModifier), |
||||
_registered(false) |
||||
{} |
||||
|
||||
QHotkey::QHotkey(const QKeySequence &shortcut, bool autoRegister, QObject *parent) : |
||||
QHotkey(parent) |
||||
{ |
||||
setShortcut(shortcut, autoRegister); |
||||
} |
||||
|
||||
QHotkey::QHotkey(Qt::Key keyCode, Qt::KeyboardModifiers modifiers, bool autoRegister, QObject *parent) : |
||||
QHotkey(parent) |
||||
{ |
||||
setShortcut(keyCode, modifiers, autoRegister); |
||||
} |
||||
|
||||
QHotkey::QHotkey(QHotkey::NativeShortcut shortcut, bool autoRegister, QObject *parent) : |
||||
QHotkey(parent) |
||||
{ |
||||
setNativeShortcut(shortcut, autoRegister); |
||||
} |
||||
|
||||
QHotkey::~QHotkey() |
||||
{ |
||||
if(_registered) |
||||
QHotkeyPrivate::instance()->removeShortcut(this); |
||||
} |
||||
|
||||
QKeySequence QHotkey::shortcut() const |
||||
{ |
||||
if(_keyCode == Qt::Key_unknown) |
||||
return QKeySequence(); |
||||
|
||||
#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) |
||||
return QKeySequence((_keyCode | _modifiers).toCombined()); |
||||
#else |
||||
return QKeySequence(static_cast<int>(_keyCode | _modifiers)); |
||||
#endif |
||||
} |
||||
|
||||
Qt::Key QHotkey::keyCode() const |
||||
{ |
||||
return _keyCode; |
||||
} |
||||
|
||||
Qt::KeyboardModifiers QHotkey::modifiers() const |
||||
{ |
||||
return _modifiers; |
||||
} |
||||
|
||||
QHotkey::NativeShortcut QHotkey::currentNativeShortcut() const |
||||
{ |
||||
return _nativeShortcut; |
||||
} |
||||
|
||||
bool QHotkey::isRegistered() const |
||||
{ |
||||
return _registered; |
||||
} |
||||
|
||||
bool QHotkey::setShortcut(const QKeySequence &shortcut, bool autoRegister) |
||||
{ |
||||
if(shortcut.isEmpty()) |
||||
return resetShortcut(); |
||||
if(shortcut.count() > 1) { |
||||
qCWarning(logQHotkey, "Keysequences with multiple shortcuts are not allowed! " |
||||
"Only the first shortcut will be used!"); |
||||
} |
||||
|
||||
#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) |
||||
const int key = shortcut[0].toCombined(); |
||||
#else |
||||
const int key = shortcut[0]; |
||||
#endif |
||||
|
||||
return setShortcut(Qt::Key(key & ~Qt::KeyboardModifierMask), |
||||
Qt::KeyboardModifiers(key & Qt::KeyboardModifierMask), |
||||
autoRegister); |
||||
} |
||||
|
||||
bool QHotkey::setShortcut(Qt::Key keyCode, Qt::KeyboardModifiers modifiers, bool autoRegister) |
||||
{ |
||||
if(_registered) { |
||||
if(autoRegister) { |
||||
if(!QHotkeyPrivate::instance()->removeShortcut(this)) |
||||
return false; |
||||
} else |
||||
return false; |
||||
} |
||||
|
||||
if(keyCode == Qt::Key_unknown) { |
||||
_keyCode = Qt::Key_unknown; |
||||
_modifiers = Qt::NoModifier; |
||||
_nativeShortcut = NativeShortcut(); |
||||
return true; |
||||
} |
||||
|
||||
_keyCode = keyCode; |
||||
_modifiers = modifiers; |
||||
_nativeShortcut = QHotkeyPrivate::instance()->nativeShortcut(keyCode, modifiers); |
||||
if(_nativeShortcut.isValid()) { |
||||
if(autoRegister) |
||||
return QHotkeyPrivate::instance()->addShortcut(this); |
||||
return true; |
||||
} |
||||
|
||||
qCWarning(logQHotkey) << "Unable to map shortcut to native keys. Key:" << keyCode << "Modifiers:" << modifiers; |
||||
_keyCode = Qt::Key_unknown; |
||||
_modifiers = Qt::NoModifier; |
||||
_nativeShortcut = NativeShortcut(); |
||||
return false; |
||||
} |
||||
|
||||
bool QHotkey::resetShortcut() |
||||
{ |
||||
if(_registered && |
||||
!QHotkeyPrivate::instance()->removeShortcut(this)) { |
||||
return false; |
||||
} |
||||
|
||||
_keyCode = Qt::Key_unknown; |
||||
_modifiers = Qt::NoModifier; |
||||
_nativeShortcut = NativeShortcut(); |
||||
return true; |
||||
} |
||||
|
||||
bool QHotkey::setNativeShortcut(QHotkey::NativeShortcut nativeShortcut, bool autoRegister) |
||||
{ |
||||
if(_registered) { |
||||
if(autoRegister) { |
||||
if(!QHotkeyPrivate::instance()->removeShortcut(this)) |
||||
return false; |
||||
} else |
||||
return false; |
||||
} |
||||
|
||||
if(nativeShortcut.isValid()) { |
||||
_keyCode = Qt::Key_unknown; |
||||
_modifiers = Qt::NoModifier; |
||||
_nativeShortcut = nativeShortcut; |
||||
if(autoRegister) |
||||
return QHotkeyPrivate::instance()->addShortcut(this); |
||||
return true; |
||||
} |
||||
|
||||
_keyCode = Qt::Key_unknown; |
||||
_modifiers = Qt::NoModifier; |
||||
_nativeShortcut = NativeShortcut(); |
||||
return true; |
||||
} |
||||
|
||||
bool QHotkey::setRegistered(bool registered) |
||||
{ |
||||
if(_registered && !registered) |
||||
return QHotkeyPrivate::instance()->removeShortcut(this); |
||||
if(!_registered && registered) { |
||||
if(!_nativeShortcut.isValid()) |
||||
return false; |
||||
return QHotkeyPrivate::instance()->addShortcut(this); |
||||
} |
||||
return true; |
||||
} |
||||
|
||||
|
||||
|
||||
// ---------- QHotkeyPrivate implementation ----------
|
||||
|
||||
QHotkeyPrivate::QHotkeyPrivate() |
||||
{ |
||||
Q_ASSERT_X(qApp, Q_FUNC_INFO, "QHotkey requires QCoreApplication to be instantiated"); |
||||
qApp->eventDispatcher()->installNativeEventFilter(this); |
||||
} |
||||
|
||||
QHotkeyPrivate::~QHotkeyPrivate() |
||||
{ |
||||
if(!shortcuts.isEmpty()) |
||||
qCWarning(logQHotkey) << "QHotkeyPrivate destroyed with registered shortcuts!"; |
||||
if(qApp && qApp->eventDispatcher()) |
||||
qApp->eventDispatcher()->removeNativeEventFilter(this); |
||||
} |
||||
|
||||
QHotkey::NativeShortcut QHotkeyPrivate::nativeShortcut(Qt::Key keycode, Qt::KeyboardModifiers modifiers) |
||||
{ |
||||
Qt::ConnectionType conType = (QThread::currentThread() == thread() ? |
||||
Qt::DirectConnection : |
||||
Qt::BlockingQueuedConnection); |
||||
QHotkey::NativeShortcut res; |
||||
if(!QMetaObject::invokeMethod(this, "nativeShortcutInvoked", conType, |
||||
Q_RETURN_ARG(QHotkey::NativeShortcut, res), |
||||
Q_ARG(Qt::Key, keycode), |
||||
Q_ARG(Qt::KeyboardModifiers, modifiers))) { |
||||
return QHotkey::NativeShortcut(); |
||||
} |
||||
return res; |
||||
} |
||||
|
||||
bool QHotkeyPrivate::addShortcut(QHotkey *hotkey) |
||||
{ |
||||
if(hotkey->_registered) |
||||
return false; |
||||
|
||||
Qt::ConnectionType conType = (QThread::currentThread() == thread() ? |
||||
Qt::DirectConnection : |
||||
Qt::BlockingQueuedConnection); |
||||
bool res = false; |
||||
if(!QMetaObject::invokeMethod(this, "addShortcutInvoked", conType, |
||||
Q_RETURN_ARG(bool, res), |
||||
Q_ARG(QHotkey*, hotkey))) { |
||||
return false; |
||||
} |
||||
|
||||
if(res) |
||||
emit hotkey->registeredChanged(true); |
||||
return res; |
||||
} |
||||
|
||||
bool QHotkeyPrivate::removeShortcut(QHotkey *hotkey) |
||||
{ |
||||
if(!hotkey->_registered) |
||||
return false; |
||||
|
||||
Qt::ConnectionType conType = (QThread::currentThread() == thread() ? |
||||
Qt::DirectConnection : |
||||
Qt::BlockingQueuedConnection); |
||||
bool res = false; |
||||
if(!QMetaObject::invokeMethod(this, "removeShortcutInvoked", conType, |
||||
Q_RETURN_ARG(bool, res), |
||||
Q_ARG(QHotkey*, hotkey))) { |
||||
return false; |
||||
} |
||||
|
||||
if(res) |
||||
emit hotkey->registeredChanged(false); |
||||
return res; |
||||
} |
||||
|
||||
void QHotkeyPrivate::activateShortcut(QHotkey::NativeShortcut shortcut) |
||||
{ |
||||
QMetaMethod signal = QMetaMethod::fromSignal(&QHotkey::activated); |
||||
for(QHotkey *hkey : shortcuts.values(shortcut)) |
||||
signal.invoke(hkey, Qt::QueuedConnection); |
||||
} |
||||
|
||||
void QHotkeyPrivate::releaseShortcut(QHotkey::NativeShortcut shortcut) |
||||
{ |
||||
QMetaMethod signal = QMetaMethod::fromSignal(&QHotkey::released); |
||||
for(QHotkey *hkey : shortcuts.values(shortcut)) |
||||
signal.invoke(hkey, Qt::QueuedConnection); |
||||
} |
||||
|
||||
void QHotkeyPrivate::addMappingInvoked(Qt::Key keycode, Qt::KeyboardModifiers modifiers, QHotkey::NativeShortcut nativeShortcut) |
||||
{ |
||||
mapping.insert({keycode, modifiers}, nativeShortcut); |
||||
} |
||||
|
||||
bool QHotkeyPrivate::addShortcutInvoked(QHotkey *hotkey) |
||||
{ |
||||
QHotkey::NativeShortcut shortcut = hotkey->_nativeShortcut; |
||||
|
||||
if(!shortcuts.contains(shortcut)) { |
||||
if(!registerShortcut(shortcut)) { |
||||
qCWarning(logQHotkey) << QHotkey::tr("Failed to register %1. Error: %2").arg(hotkey->shortcut().toString(), error); |
||||
return false; |
||||
} |
||||
} |
||||
|
||||
shortcuts.insert(shortcut, hotkey); |
||||
hotkey->_registered = true; |
||||
return true; |
||||
} |
||||
|
||||
bool QHotkeyPrivate::removeShortcutInvoked(QHotkey *hotkey) |
||||
{ |
||||
QHotkey::NativeShortcut shortcut = hotkey->_nativeShortcut; |
||||
|
||||
if(shortcuts.remove(shortcut, hotkey) == 0) |
||||
return false; |
||||
hotkey->_registered = false; |
||||
emit hotkey->registeredChanged(true); |
||||
if(shortcuts.count(shortcut) == 0) { |
||||
if (!unregisterShortcut(shortcut)) { |
||||
qCWarning(logQHotkey) << QHotkey::tr("Failed to unregister %1. Error: %2").arg(hotkey->shortcut().toString(), error); |
||||
return false; |
||||
} |
||||
return true; |
||||
} |
||||
return true; |
||||
} |
||||
|
||||
QHotkey::NativeShortcut QHotkeyPrivate::nativeShortcutInvoked(Qt::Key keycode, Qt::KeyboardModifiers modifiers) |
||||
{ |
||||
if(mapping.contains({keycode, modifiers})) |
||||
return mapping.value({keycode, modifiers}); |
||||
|
||||
bool ok1 = false; |
||||
auto k = nativeKeycode(keycode, ok1); |
||||
bool ok2 = false; |
||||
auto m = nativeModifiers(modifiers, ok2); |
||||
if(ok1 && ok2) |
||||
return {k, m}; |
||||
return {}; |
||||
} |
||||
|
||||
|
||||
|
||||
QHotkey::NativeShortcut::NativeShortcut() : |
||||
key(), |
||||
modifier(), |
||||
valid(false) |
||||
{} |
||||
|
||||
QHotkey::NativeShortcut::NativeShortcut(quint32 key, quint32 modifier) : |
||||
key(key), |
||||
modifier(modifier), |
||||
valid(true) |
||||
{} |
||||
|
||||
bool QHotkey::NativeShortcut::isValid() const |
||||
{ |
||||
return valid; |
||||
} |
||||
|
||||
bool QHotkey::NativeShortcut::operator ==(QHotkey::NativeShortcut other) const |
||||
{ |
||||
return (key == other.key) && |
||||
(modifier == other.modifier) && |
||||
valid == other.valid; |
||||
} |
||||
|
||||
bool QHotkey::NativeShortcut::operator !=(QHotkey::NativeShortcut other) const |
||||
{ |
||||
return (key != other.key) || |
||||
(modifier != other.modifier) || |
||||
valid != other.valid; |
||||
} |
||||
|
||||
QHOTKEY_HASH_SEED qHash(QHotkey::NativeShortcut key) |
||||
{ |
||||
return qHash(key.key) ^ qHash(key.modifier); |
||||
} |
||||
|
||||
QHOTKEY_HASH_SEED qHash(QHotkey::NativeShortcut key, QHOTKEY_HASH_SEED seed) |
||||
{ |
||||
return qHash(key.key, seed) ^ qHash(key.modifier, seed); |
||||
} |
||||
@ -0,0 +1,130 @@
|
||||
#ifndef QHOTKEY_H |
||||
#define QHOTKEY_H |
||||
|
||||
#include <QObject> |
||||
#include <QKeySequence> |
||||
#include <QPair> |
||||
#include <QLoggingCategory> |
||||
|
||||
#ifdef QHOTKEY_SHARED |
||||
# ifdef QHOTKEY_LIBRARY |
||||
# define QHOTKEY_EXPORT Q_DECL_EXPORT |
||||
# else |
||||
# define QHOTKEY_EXPORT Q_DECL_IMPORT |
||||
# endif |
||||
#else |
||||
# define QHOTKEY_EXPORT |
||||
#endif |
||||
|
||||
#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) |
||||
#define QHOTKEY_HASH_SEED size_t |
||||
#else |
||||
#define QHOTKEY_HASH_SEED uint |
||||
#endif |
||||
|
||||
//! A class to define global, systemwide Hotkeys
|
||||
class QHOTKEY_EXPORT QHotkey : public QObject |
||||
{ |
||||
Q_OBJECT |
||||
//! @private
|
||||
friend class QHotkeyPrivate; |
||||
|
||||
//! Specifies whether this hotkey is currently registered or not
|
||||
Q_PROPERTY(bool registered READ isRegistered WRITE setRegistered NOTIFY registeredChanged) |
||||
//! Holds the shortcut this hotkey will be triggered on
|
||||
Q_PROPERTY(QKeySequence shortcut READ shortcut WRITE setShortcut RESET resetShortcut) |
||||
|
||||
public: |
||||
//! Defines shortcut with native keycodes
|
||||
class QHOTKEY_EXPORT NativeShortcut { |
||||
public: |
||||
//! The native keycode
|
||||
quint32 key; |
||||
//! The native modifiers
|
||||
quint32 modifier; |
||||
|
||||
//! Creates an invalid native shortcut
|
||||
NativeShortcut(); |
||||
//! Creates a valid native shortcut, with the given key and modifiers
|
||||
NativeShortcut(quint32 key, quint32 modifier = 0); |
||||
|
||||
//! Checks, whether this shortcut is valid or not
|
||||
bool isValid() const; |
||||
|
||||
//! Equality operator
|
||||
bool operator ==(NativeShortcut other) const; |
||||
//! Inequality operator
|
||||
bool operator !=(NativeShortcut other) const; |
||||
|
||||
private: |
||||
bool valid; |
||||
}; |
||||
|
||||
//! Adds a global mapping of a key sequence to a replacement native shortcut
|
||||
static void addGlobalMapping(const QKeySequence &shortcut, NativeShortcut nativeShortcut); |
||||
|
||||
//! Checks if global shortcuts are supported by the current platform
|
||||
static bool isPlatformSupported(); |
||||
|
||||
//! Default Constructor
|
||||
explicit QHotkey(QObject *parent = nullptr); |
||||
//! Constructs a hotkey with a shortcut and optionally registers it
|
||||
explicit QHotkey(const QKeySequence &shortcut, bool autoRegister = false, QObject *parent = nullptr); |
||||
//! Constructs a hotkey with a key and modifiers and optionally registers it
|
||||
explicit QHotkey(Qt::Key keyCode, Qt::KeyboardModifiers modifiers, bool autoRegister = false, QObject *parent = nullptr); |
||||
//! Constructs a hotkey from a native shortcut and optionally registers it
|
||||
explicit QHotkey(NativeShortcut shortcut, bool autoRegister = false, QObject *parent = nullptr); |
||||
~QHotkey() override; |
||||
|
||||
//! @readAcFn{QHotkey::registered}
|
||||
bool isRegistered() const; |
||||
//! @readAcFn{QHotkey::shortcut}
|
||||
QKeySequence shortcut() const; |
||||
//! @readAcFn{QHotkey::shortcut} - the key only
|
||||
Qt::Key keyCode() const; |
||||
//! @readAcFn{QHotkey::shortcut} - the modifiers only
|
||||
Qt::KeyboardModifiers modifiers() const; |
||||
|
||||
//! Get the current native shortcut
|
||||
NativeShortcut currentNativeShortcut() const; |
||||
|
||||
public Q_SLOTS: |
||||
//! @writeAcFn{QHotkey::registered}
|
||||
bool setRegistered(bool registered); |
||||
|
||||
//! @writeAcFn{QHotkey::shortcut}
|
||||
bool setShortcut(const QKeySequence &shortcut, bool autoRegister = false); |
||||
//! @writeAcFn{QHotkey::shortcut}
|
||||
bool setShortcut(Qt::Key keyCode, Qt::KeyboardModifiers modifiers, bool autoRegister = false); |
||||
//! @resetAcFn{QHotkey::shortcut}
|
||||
bool resetShortcut(); |
||||
|
||||
//! Set this hotkey to a native shortcut
|
||||
bool setNativeShortcut(QHotkey::NativeShortcut nativeShortcut, bool autoRegister = false); |
||||
|
||||
Q_SIGNALS: |
||||
//! Will be emitted if the shortcut is pressed
|
||||
void activated(QPrivateSignal); |
||||
|
||||
//! Will be emitted if the shortcut press is released
|
||||
void released(QPrivateSignal); |
||||
|
||||
//! @notifyAcFn{QHotkey::registered}
|
||||
void registeredChanged(bool registered); |
||||
|
||||
private: |
||||
Qt::Key _keyCode; |
||||
Qt::KeyboardModifiers _modifiers; |
||||
|
||||
NativeShortcut _nativeShortcut; |
||||
bool _registered; |
||||
}; |
||||
|
||||
QHOTKEY_HASH_SEED QHOTKEY_EXPORT qHash(QHotkey::NativeShortcut key); |
||||
QHOTKEY_HASH_SEED QHOTKEY_EXPORT qHash(QHotkey::NativeShortcut key, QHOTKEY_HASH_SEED seed); |
||||
|
||||
QHOTKEY_EXPORT Q_DECLARE_LOGGING_CATEGORY(logQHotkey) |
||||
|
||||
Q_DECLARE_METATYPE(QHotkey::NativeShortcut) |
||||
|
||||
#endif // QHOTKEY_H
|
||||
@ -0,0 +1,62 @@
|
||||
#ifndef QHOTKEY_P_H |
||||
#define QHOTKEY_P_H |
||||
|
||||
#include "qhotkey.h" |
||||
#include <QAbstractNativeEventFilter> |
||||
#include <QMultiHash> |
||||
#include <QMutex> |
||||
#include <QGlobalStatic> |
||||
|
||||
#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) |
||||
#define _NATIVE_EVENT_RESULT qintptr |
||||
#else |
||||
#define _NATIVE_EVENT_RESULT long |
||||
#endif |
||||
|
||||
class QHOTKEY_EXPORT QHotkeyPrivate : public QObject, public QAbstractNativeEventFilter |
||||
{ |
||||
Q_OBJECT |
||||
|
||||
public: |
||||
QHotkeyPrivate();//singleton!!!
|
||||
~QHotkeyPrivate(); |
||||
|
||||
static QHotkeyPrivate *instance(); |
||||
static bool isPlatformSupported(); |
||||
|
||||
QHotkey::NativeShortcut nativeShortcut(Qt::Key keycode, Qt::KeyboardModifiers modifiers); |
||||
|
||||
bool addShortcut(QHotkey *hotkey); |
||||
bool removeShortcut(QHotkey *hotkey); |
||||
|
||||
protected: |
||||
void activateShortcut(QHotkey::NativeShortcut shortcut); |
||||
void releaseShortcut(QHotkey::NativeShortcut shortcut); |
||||
|
||||
virtual quint32 nativeKeycode(Qt::Key keycode, bool &ok) = 0;//platform implement
|
||||
virtual quint32 nativeModifiers(Qt::KeyboardModifiers modifiers, bool &ok) = 0;//platform implement
|
||||
|
||||
virtual bool registerShortcut(QHotkey::NativeShortcut shortcut) = 0;//platform implement
|
||||
virtual bool unregisterShortcut(QHotkey::NativeShortcut shortcut) = 0;//platform implement
|
||||
|
||||
QString error; |
||||
|
||||
private: |
||||
QHash<QPair<Qt::Key, Qt::KeyboardModifiers>, QHotkey::NativeShortcut> mapping; |
||||
QMultiHash<QHotkey::NativeShortcut, QHotkey*> shortcuts; |
||||
|
||||
Q_INVOKABLE void addMappingInvoked(Qt::Key keycode, Qt::KeyboardModifiers modifiers, QHotkey::NativeShortcut nativeShortcut); |
||||
Q_INVOKABLE bool addShortcutInvoked(QHotkey *hotkey); |
||||
Q_INVOKABLE bool removeShortcutInvoked(QHotkey *hotkey); |
||||
Q_INVOKABLE QHotkey::NativeShortcut nativeShortcutInvoked(Qt::Key keycode, Qt::KeyboardModifiers modifiers); |
||||
}; |
||||
|
||||
#define NATIVE_INSTANCE(ClassName) \ |
||||
Q_GLOBAL_STATIC(ClassName, hotkeyPrivate) \
|
||||
\
|
||||
QHotkeyPrivate *QHotkeyPrivate::instance()\
|
||||
{\
|
||||
return hotkeyPrivate;\
|
||||
} |
||||
|
||||
#endif // QHOTKEY_P_H
|
||||
@ -0,0 +1,309 @@
|
||||
#include "qhotkey.h" |
||||
#include "qhotkey_p.h" |
||||
#include <qt_windows.h> |
||||
#include <algorithm> |
||||
#include <QDebug> |
||||
#include <QList> |
||||
#include <QTimer> |
||||
|
||||
#define HKEY_ID(nativeShortcut) (((nativeShortcut.key ^ (nativeShortcut.modifier << 8)) & 0x0FFF) | 0x7000) |
||||
|
||||
#if !defined(MOD_NOREPEAT) |
||||
#define MOD_NOREPEAT 0x4000 |
||||
#endif |
||||
|
||||
class QHotkeyPrivateWin : public QHotkeyPrivate |
||||
{ |
||||
public: |
||||
QHotkeyPrivateWin(); |
||||
// QAbstractNativeEventFilter interface
|
||||
bool nativeEventFilter(const QByteArray &eventType, void *message, _NATIVE_EVENT_RESULT *result) override; |
||||
|
||||
protected: |
||||
void pollForHotkeyRelease(); |
||||
// QHotkeyPrivate interface
|
||||
quint32 nativeKeycode(Qt::Key keycode, bool &ok) Q_DECL_OVERRIDE; |
||||
quint32 nativeModifiers(Qt::KeyboardModifiers modifiers, bool &ok) Q_DECL_OVERRIDE; |
||||
bool registerShortcut(QHotkey::NativeShortcut shortcut) Q_DECL_OVERRIDE; |
||||
bool unregisterShortcut(QHotkey::NativeShortcut shortcut) Q_DECL_OVERRIDE; |
||||
|
||||
private: |
||||
static QString formatWinError(DWORD winError); |
||||
QTimer pollTimer; |
||||
QList<QHotkey::NativeShortcut> polledShortcuts; |
||||
}; |
||||
NATIVE_INSTANCE(QHotkeyPrivateWin) |
||||
|
||||
QHotkeyPrivateWin::QHotkeyPrivateWin(){ |
||||
pollTimer.setInterval(50); |
||||
connect(&pollTimer, &QTimer::timeout, this, &QHotkeyPrivateWin::pollForHotkeyRelease); |
||||
} |
||||
|
||||
bool QHotkeyPrivate::isPlatformSupported() |
||||
{ |
||||
return true; |
||||
} |
||||
|
||||
bool QHotkeyPrivateWin::nativeEventFilter(const QByteArray &eventType, void *message, _NATIVE_EVENT_RESULT *result) |
||||
{ |
||||
Q_UNUSED(eventType) |
||||
Q_UNUSED(result) |
||||
|
||||
MSG* msg = static_cast<MSG*>(message); |
||||
if(msg->message == WM_HOTKEY) { |
||||
QHotkey::NativeShortcut shortcut = {HIWORD(msg->lParam), LOWORD(msg->lParam)}; |
||||
this->activateShortcut(shortcut); |
||||
if (this->polledShortcuts.empty()) |
||||
this->pollTimer.start(); |
||||
this->polledShortcuts.append(shortcut); |
||||
} |
||||
|
||||
return false; |
||||
} |
||||
|
||||
void QHotkeyPrivateWin::pollForHotkeyRelease() |
||||
{ |
||||
auto it = std::remove_if(this->polledShortcuts.begin(), this->polledShortcuts.end(), [this](const QHotkey::NativeShortcut &shortcut) { |
||||
bool pressed = (GetAsyncKeyState(shortcut.key) & (1 << 15)) != 0; |
||||
if (!pressed) |
||||
this->releaseShortcut(shortcut); |
||||
return !pressed; |
||||
}); |
||||
this->polledShortcuts.erase(it, this->polledShortcuts.end()); |
||||
if (this->polledShortcuts.empty()) |
||||
this->pollTimer.stop(); |
||||
} |
||||
|
||||
quint32 QHotkeyPrivateWin::nativeKeycode(Qt::Key keycode, bool &ok) |
||||
{ |
||||
ok = true; |
||||
if(keycode <= 0xFFFF) {//Try to obtain the key from it's "character"
|
||||
const SHORT vKey = VkKeyScanW(static_cast<WCHAR>(keycode)); |
||||
if(vKey > -1) |
||||
return LOBYTE(vKey); |
||||
} |
||||
|
||||
//find key from switch/case --> Only finds a very small subset of keys
|
||||
switch (keycode) |
||||
{ |
||||
case Qt::Key_Escape: |
||||
return VK_ESCAPE; |
||||
case Qt::Key_Tab: |
||||
case Qt::Key_Backtab: |
||||
return VK_TAB; |
||||
case Qt::Key_Backspace: |
||||
return VK_BACK; |
||||
case Qt::Key_Return: |
||||
case Qt::Key_Enter: |
||||
return VK_RETURN; |
||||
case Qt::Key_Insert: |
||||
return VK_INSERT; |
||||
case Qt::Key_Delete: |
||||
return VK_DELETE; |
||||
case Qt::Key_Pause: |
||||
return VK_PAUSE; |
||||
case Qt::Key_Print: |
||||
return VK_PRINT; |
||||
case Qt::Key_Clear: |
||||
return VK_CLEAR; |
||||
case Qt::Key_Home: |
||||
return VK_HOME; |
||||
case Qt::Key_End: |
||||
return VK_END; |
||||
case Qt::Key_Left: |
||||
return VK_LEFT; |
||||
case Qt::Key_Up: |
||||
return VK_UP; |
||||
case Qt::Key_Right: |
||||
return VK_RIGHT; |
||||
case Qt::Key_Down: |
||||
return VK_DOWN; |
||||
case Qt::Key_PageUp: |
||||
return VK_PRIOR; |
||||
case Qt::Key_PageDown: |
||||
return VK_NEXT; |
||||
case Qt::Key_CapsLock: |
||||
return VK_CAPITAL; |
||||
case Qt::Key_NumLock: |
||||
return VK_NUMLOCK; |
||||
case Qt::Key_ScrollLock: |
||||
return VK_SCROLL; |
||||
|
||||
case Qt::Key_F1: |
||||
return VK_F1; |
||||
case Qt::Key_F2: |
||||
return VK_F2; |
||||
case Qt::Key_F3: |
||||
return VK_F3; |
||||
case Qt::Key_F4: |
||||
return VK_F4; |
||||
case Qt::Key_F5: |
||||
return VK_F5; |
||||
case Qt::Key_F6: |
||||
return VK_F6; |
||||
case Qt::Key_F7: |
||||
return VK_F7; |
||||
case Qt::Key_F8: |
||||
return VK_F8; |
||||
case Qt::Key_F9: |
||||
return VK_F9; |
||||
case Qt::Key_F10: |
||||
return VK_F10; |
||||
case Qt::Key_F11: |
||||
return VK_F11; |
||||
case Qt::Key_F12: |
||||
return VK_F12; |
||||
case Qt::Key_F13: |
||||
return VK_F13; |
||||
case Qt::Key_F14: |
||||
return VK_F14; |
||||
case Qt::Key_F15: |
||||
return VK_F15; |
||||
case Qt::Key_F16: |
||||
return VK_F16; |
||||
case Qt::Key_F17: |
||||
return VK_F17; |
||||
case Qt::Key_F18: |
||||
return VK_F18; |
||||
case Qt::Key_F19: |
||||
return VK_F19; |
||||
case Qt::Key_F20: |
||||
return VK_F20; |
||||
case Qt::Key_F21: |
||||
return VK_F21; |
||||
case Qt::Key_F22: |
||||
return VK_F22; |
||||
case Qt::Key_F23: |
||||
return VK_F23; |
||||
case Qt::Key_F24: |
||||
return VK_F24; |
||||
|
||||
case Qt::Key_Menu: |
||||
return VK_APPS; |
||||
case Qt::Key_Help: |
||||
return VK_HELP; |
||||
case Qt::Key_MediaNext: |
||||
return VK_MEDIA_NEXT_TRACK; |
||||
case Qt::Key_MediaPrevious: |
||||
return VK_MEDIA_PREV_TRACK; |
||||
case Qt::Key_MediaPlay: |
||||
return VK_MEDIA_PLAY_PAUSE; |
||||
case Qt::Key_MediaStop: |
||||
return VK_MEDIA_STOP; |
||||
case Qt::Key_VolumeDown: |
||||
return VK_VOLUME_DOWN; |
||||
case Qt::Key_VolumeUp: |
||||
return VK_VOLUME_UP; |
||||
case Qt::Key_VolumeMute: |
||||
return VK_VOLUME_MUTE; |
||||
case Qt::Key_Mode_switch: |
||||
return VK_MODECHANGE; |
||||
case Qt::Key_Select: |
||||
return VK_SELECT; |
||||
case Qt::Key_Printer: |
||||
return VK_PRINT; |
||||
case Qt::Key_Execute: |
||||
return VK_EXECUTE; |
||||
case Qt::Key_Sleep: |
||||
return VK_SLEEP; |
||||
case Qt::Key_Period: |
||||
return VK_DECIMAL; |
||||
case Qt::Key_Play: |
||||
return VK_PLAY; |
||||
case Qt::Key_Cancel: |
||||
return VK_CANCEL; |
||||
|
||||
case Qt::Key_Forward: |
||||
return VK_BROWSER_FORWARD; |
||||
case Qt::Key_Refresh: |
||||
return VK_BROWSER_REFRESH; |
||||
case Qt::Key_Stop: |
||||
return VK_BROWSER_STOP; |
||||
case Qt::Key_Search: |
||||
return VK_BROWSER_SEARCH; |
||||
case Qt::Key_Favorites: |
||||
return VK_BROWSER_FAVORITES; |
||||
case Qt::Key_HomePage: |
||||
return VK_BROWSER_HOME; |
||||
|
||||
case Qt::Key_LaunchMail: |
||||
return VK_LAUNCH_MAIL; |
||||
case Qt::Key_LaunchMedia: |
||||
return VK_LAUNCH_MEDIA_SELECT; |
||||
case Qt::Key_Launch0: |
||||
return VK_LAUNCH_APP1; |
||||
case Qt::Key_Launch1: |
||||
return VK_LAUNCH_APP2; |
||||
|
||||
case Qt::Key_Massyo: |
||||
return VK_OEM_FJ_MASSHOU; |
||||
case Qt::Key_Touroku: |
||||
return VK_OEM_FJ_TOUROKU; |
||||
|
||||
default: |
||||
if(keycode <= 0xFFFF) |
||||
return static_cast<BYTE>(keycode); |
||||
else { |
||||
ok = false; |
||||
return 0; |
||||
} |
||||
} |
||||
} |
||||
|
||||
quint32 QHotkeyPrivateWin::nativeModifiers(Qt::KeyboardModifiers modifiers, bool &ok) |
||||
{ |
||||
quint32 nMods = 0; |
||||
if (modifiers & Qt::ShiftModifier) |
||||
nMods |= MOD_SHIFT; |
||||
if (modifiers & Qt::ControlModifier) |
||||
nMods |= MOD_CONTROL; |
||||
if (modifiers & Qt::AltModifier) |
||||
nMods |= MOD_ALT; |
||||
if (modifiers & Qt::MetaModifier) |
||||
nMods |= MOD_WIN; |
||||
ok = true; |
||||
return nMods; |
||||
} |
||||
|
||||
bool QHotkeyPrivateWin::registerShortcut(QHotkey::NativeShortcut shortcut) |
||||
{ |
||||
BOOL ok = RegisterHotKey(NULL, |
||||
HKEY_ID(shortcut), |
||||
shortcut.modifier + MOD_NOREPEAT, |
||||
shortcut.key); |
||||
if(ok) |
||||
return true; |
||||
else { |
||||
error = QHotkeyPrivateWin::formatWinError(::GetLastError()); |
||||
return false; |
||||
} |
||||
} |
||||
|
||||
bool QHotkeyPrivateWin::unregisterShortcut(QHotkey::NativeShortcut shortcut) |
||||
{ |
||||
BOOL ok = UnregisterHotKey(NULL, HKEY_ID(shortcut)); |
||||
if(ok) |
||||
return true; |
||||
else { |
||||
error = QHotkeyPrivateWin::formatWinError(::GetLastError()); |
||||
return false; |
||||
} |
||||
} |
||||
|
||||
QString QHotkeyPrivateWin::formatWinError(DWORD winError) |
||||
{ |
||||
wchar_t *buffer = NULL; |
||||
DWORD num = FormatMessageW(FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM, |
||||
NULL, |
||||
winError, |
||||
0, |
||||
(LPWSTR)&buffer, |
||||
0, |
||||
NULL); |
||||
if(buffer) { |
||||
QString res = QString::fromWCharArray(buffer, num); |
||||
LocalFree(buffer); |
||||
return res; |
||||
} else |
||||
return QString(); |
||||
} |
||||
@ -0,0 +1,271 @@
|
||||
#include "qhotkey.h" |
||||
#include "qhotkey_p.h" |
||||
|
||||
#if QT_VERSION >= QT_VERSION_CHECK(6, 2, 0) |
||||
#include <QGuiApplication> |
||||
#else |
||||
#include <QDebug> |
||||
#include <QX11Info> |
||||
#endif |
||||
|
||||
#include <QThreadStorage> |
||||
#include <QTimer> |
||||
#include <X11/Xlib.h> |
||||
#include <xcb/xcb.h> |
||||
|
||||
//compatibility to pre Qt 5.8
|
||||
#ifndef Q_FALLTHROUGH |
||||
#define Q_FALLTHROUGH() (void)0 |
||||
#endif |
||||
|
||||
class QHotkeyPrivateX11 : public QHotkeyPrivate |
||||
{ |
||||
public: |
||||
// QAbstractNativeEventFilter interface
|
||||
bool nativeEventFilter(const QByteArray &eventType, void *message, _NATIVE_EVENT_RESULT *result) override; |
||||
|
||||
protected: |
||||
// QHotkeyPrivate interface
|
||||
quint32 nativeKeycode(Qt::Key keycode, bool &ok) Q_DECL_OVERRIDE; |
||||
quint32 nativeModifiers(Qt::KeyboardModifiers modifiers, bool &ok) Q_DECL_OVERRIDE; |
||||
static QString getX11String(Qt::Key keycode); |
||||
bool registerShortcut(QHotkey::NativeShortcut shortcut) Q_DECL_OVERRIDE; |
||||
bool unregisterShortcut(QHotkey::NativeShortcut shortcut) Q_DECL_OVERRIDE; |
||||
|
||||
private: |
||||
static const QVector<quint32> specialModifiers; |
||||
static const quint32 validModsMask; |
||||
xcb_key_press_event_t prevHandledEvent; |
||||
xcb_key_press_event_t prevEvent; |
||||
|
||||
static QString formatX11Error(Display *display, int errorCode); |
||||
|
||||
class HotkeyErrorHandler { |
||||
public: |
||||
HotkeyErrorHandler(); |
||||
~HotkeyErrorHandler(); |
||||
|
||||
static bool hasError; |
||||
static QString errorString; |
||||
|
||||
private: |
||||
XErrorHandler prevHandler; |
||||
|
||||
static int handleError(Display *display, XErrorEvent *error); |
||||
}; |
||||
}; |
||||
NATIVE_INSTANCE(QHotkeyPrivateX11) |
||||
|
||||
bool QHotkeyPrivate::isPlatformSupported() |
||||
{ |
||||
#if QT_VERSION >= QT_VERSION_CHECK(6, 2, 0) |
||||
return qGuiApp->nativeInterface<QNativeInterface::QX11Application>(); |
||||
#else |
||||
return QX11Info::isPlatformX11(); |
||||
#endif |
||||
} |
||||
|
||||
const QVector<quint32> QHotkeyPrivateX11::specialModifiers = {0, Mod2Mask, LockMask, (Mod2Mask | LockMask)}; |
||||
const quint32 QHotkeyPrivateX11::validModsMask = ShiftMask | ControlMask | Mod1Mask | Mod4Mask; |
||||
|
||||
bool QHotkeyPrivateX11::nativeEventFilter(const QByteArray &eventType, void *message, _NATIVE_EVENT_RESULT *result) |
||||
{ |
||||
Q_UNUSED(eventType) |
||||
Q_UNUSED(result) |
||||
|
||||
auto *genericEvent = static_cast<xcb_generic_event_t *>(message); |
||||
if (genericEvent->response_type == XCB_KEY_PRESS) { |
||||
xcb_key_press_event_t keyEvent = *static_cast<xcb_key_press_event_t *>(message); |
||||
this->prevEvent = keyEvent; |
||||
if (this->prevHandledEvent.response_type == XCB_KEY_RELEASE) { |
||||
if(this->prevHandledEvent.time == keyEvent.time) return false; |
||||
} |
||||
this->activateShortcut({keyEvent.detail, keyEvent.state & QHotkeyPrivateX11::validModsMask}); |
||||
} else if (genericEvent->response_type == XCB_KEY_RELEASE) { |
||||
xcb_key_release_event_t keyEvent = *static_cast<xcb_key_release_event_t *>(message); |
||||
this->prevEvent = keyEvent; |
||||
QTimer::singleShot(50, [this, keyEvent] { |
||||
if(this->prevEvent.time == keyEvent.time && this->prevEvent.response_type == keyEvent.response_type && this->prevEvent.detail == keyEvent.detail){ |
||||
this->releaseShortcut({keyEvent.detail, keyEvent.state & QHotkeyPrivateX11::validModsMask}); |
||||
} |
||||
}); |
||||
this->prevHandledEvent = keyEvent; |
||||
} |
||||
|
||||
return false; |
||||
} |
||||
|
||||
QString QHotkeyPrivateX11::getX11String(Qt::Key keycode) |
||||
{ |
||||
switch(keycode){ |
||||
|
||||
case Qt::Key_MediaLast : |
||||
case Qt::Key_MediaPrevious : |
||||
return QStringLiteral("XF86AudioPrev"); |
||||
case Qt::Key_MediaNext : |
||||
return QStringLiteral("XF86AudioNext"); |
||||
case Qt::Key_MediaPause : |
||||
case Qt::Key_MediaPlay : |
||||
case Qt::Key_MediaTogglePlayPause : |
||||
return QStringLiteral("XF86AudioPlay"); |
||||
case Qt::Key_MediaRecord : |
||||
return QStringLiteral("XF86AudioRecord"); |
||||
case Qt::Key_MediaStop : |
||||
return QStringLiteral("XF86AudioStop"); |
||||
default : |
||||
return QKeySequence(keycode).toString(QKeySequence::NativeText); |
||||
} |
||||
} |
||||
|
||||
quint32 QHotkeyPrivateX11::nativeKeycode(Qt::Key keycode, bool &ok) |
||||
{ |
||||
QString keyString = getX11String(keycode); |
||||
|
||||
KeySym keysym = XStringToKeysym(keyString.toLatin1().constData()); |
||||
if (keysym == NoSymbol) { |
||||
//not found -> just use the key
|
||||
if(keycode <= 0xFFFF) |
||||
keysym = keycode; |
||||
else |
||||
return 0; |
||||
} |
||||
|
||||
#if QT_VERSION >= QT_VERSION_CHECK(6, 2, 0) |
||||
const QNativeInterface::QX11Application *x11Interface = qGuiApp->nativeInterface<QNativeInterface::QX11Application>(); |
||||
#else |
||||
const bool x11Interface = QX11Info::isPlatformX11(); |
||||
#endif |
||||
|
||||
if(x11Interface) { |
||||
#if QT_VERSION >= QT_VERSION_CHECK(6, 2, 0) |
||||
Display *display = x11Interface->display(); |
||||
#else |
||||
Display *display = QX11Info::display(); |
||||
#endif |
||||
auto res = XKeysymToKeycode(display, keysym); |
||||
if(res != 0) |
||||
ok = true; |
||||
return res; |
||||
} |
||||
return 0; |
||||
} |
||||
|
||||
quint32 QHotkeyPrivateX11::nativeModifiers(Qt::KeyboardModifiers modifiers, bool &ok) |
||||
{ |
||||
quint32 nMods = 0; |
||||
if (modifiers & Qt::ShiftModifier) |
||||
nMods |= ShiftMask; |
||||
if (modifiers & Qt::ControlModifier) |
||||
nMods |= ControlMask; |
||||
if (modifiers & Qt::AltModifier) |
||||
nMods |= Mod1Mask; |
||||
if (modifiers & Qt::MetaModifier) |
||||
nMods |= Mod4Mask; |
||||
ok = true; |
||||
return nMods; |
||||
} |
||||
|
||||
bool QHotkeyPrivateX11::registerShortcut(QHotkey::NativeShortcut shortcut) |
||||
{ |
||||
#if QT_VERSION >= QT_VERSION_CHECK(6, 2, 0) |
||||
const QNativeInterface::QX11Application *x11Interface = qGuiApp->nativeInterface<QNativeInterface::QX11Application>(); |
||||
Display *display = x11Interface->display(); |
||||
#else |
||||
const bool x11Interface = QX11Info::isPlatformX11(); |
||||
Display *display = QX11Info::display(); |
||||
#endif |
||||
|
||||
if(!display || !x11Interface) |
||||
return false; |
||||
|
||||
HotkeyErrorHandler errorHandler; |
||||
for(quint32 specialMod : QHotkeyPrivateX11::specialModifiers) { |
||||
XGrabKey(display, |
||||
shortcut.key, |
||||
shortcut.modifier | specialMod, |
||||
DefaultRootWindow(display), |
||||
True, |
||||
GrabModeAsync, |
||||
GrabModeAsync); |
||||
} |
||||
XSync(display, False); |
||||
|
||||
if(errorHandler.hasError) { |
||||
error = errorHandler.errorString; |
||||
this->unregisterShortcut(shortcut); |
||||
return false; |
||||
} |
||||
return true; |
||||
} |
||||
|
||||
bool QHotkeyPrivateX11::unregisterShortcut(QHotkey::NativeShortcut shortcut) |
||||
{ |
||||
#if QT_VERSION >= QT_VERSION_CHECK(6, 2, 0) |
||||
Display *display = qGuiApp->nativeInterface<QNativeInterface::QX11Application>()->display(); |
||||
#else |
||||
Display *display = QX11Info::display(); |
||||
#endif |
||||
|
||||
if(!display) |
||||
return false; |
||||
|
||||
HotkeyErrorHandler errorHandler; |
||||
for(quint32 specialMod : QHotkeyPrivateX11::specialModifiers) { |
||||
XUngrabKey(display, |
||||
shortcut.key, |
||||
shortcut.modifier | specialMod, |
||||
XDefaultRootWindow(display)); |
||||
} |
||||
XSync(display, False); |
||||
|
||||
if(HotkeyErrorHandler::hasError) { |
||||
error = HotkeyErrorHandler::errorString; |
||||
return false; |
||||
} |
||||
return true; |
||||
} |
||||
|
||||
QString QHotkeyPrivateX11::formatX11Error(Display *display, int errorCode) |
||||
{ |
||||
char errStr[256]; |
||||
XGetErrorText(display, errorCode, errStr, 256); |
||||
return QString::fromLatin1(errStr); |
||||
} |
||||
|
||||
|
||||
|
||||
// ---------- QHotkeyPrivateX11::HotkeyErrorHandler implementation ----------
|
||||
|
||||
bool QHotkeyPrivateX11::HotkeyErrorHandler::hasError = false; |
||||
QString QHotkeyPrivateX11::HotkeyErrorHandler::errorString; |
||||
|
||||
QHotkeyPrivateX11::HotkeyErrorHandler::HotkeyErrorHandler() |
||||
{ |
||||
prevHandler = XSetErrorHandler(&HotkeyErrorHandler::handleError); |
||||
} |
||||
|
||||
QHotkeyPrivateX11::HotkeyErrorHandler::~HotkeyErrorHandler() |
||||
{ |
||||
XSetErrorHandler(prevHandler); |
||||
hasError = false; |
||||
errorString.clear(); |
||||
} |
||||
|
||||
int QHotkeyPrivateX11::HotkeyErrorHandler::handleError(Display *display, XErrorEvent *error) |
||||
{ |
||||
switch (error->error_code) { |
||||
case BadAccess: |
||||
case BadValue: |
||||
case BadWindow: |
||||
if (error->request_code == 33 || //grab key
|
||||
error->request_code == 34) {// ungrab key
|
||||
hasError = true; |
||||
errorString = QHotkeyPrivateX11::formatX11Error(display, error->error_code); |
||||
return 1; |
||||
} |
||||
Q_FALLTHROUGH(); |
||||
// fall through
|
||||
default: |
||||
return 0; |
||||
} |
||||
} |
||||
Loading…
Reference in new issue