You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
478 lines
19 KiB
478 lines
19 KiB
# ADN DMR Peer Server - playback (echo) use case
|
|
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
|
|
#
|
|
# Derived from ADN DMR Server / FreeDMR / HBlink. Original license:
|
|
###############################################################################
|
|
# Copyright (C) 2020 Simon Adlem, G7RZU <g7rzu@gb7fr.org.uk>
|
|
# Copyright (C) 2016-2019 Cortney T. Buffington, N0MJS <n0mjs@me.com>
|
|
#
|
|
# This program is free software; you can redistribute it and/or modify
|
|
# it under the terms of the GNU General Public License as published by
|
|
# the Free Software Foundation; either version 3 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with this program; if not, write to the Free Software Foundation,
|
|
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
|
###############################################################################
|
|
|
|
"""Port of playback.py dmrd_received: record group voice, then play back with new stream ID."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from collections.abc import Callable
|
|
from random import randint
|
|
from time import time
|
|
from typing import Any
|
|
|
|
from ..domain import HBPF_DATA_SYNC, HBPF_SLT_VHEAD, HBPF_SLT_VTERM, HBPF_VOICE, bytes_4, int_id
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Legacy playback.py: sleep(2) before playback, sleep(0.06) between packets.
|
|
_PLAYBACK_DELAY_S = 2.0
|
|
_PACKET_INTERVAL_S = 0.06
|
|
# Match bridge stream_trimmer_loop RX idle (routing_use_cases / legacy bridge_master).
|
|
_RECORD_IDLE_S = 5.0
|
|
# HBP ingress source timeout (bridge_master.py / routing_use_cases dmrd_received ~2183).
|
|
_SOURCE_MAX_S = 180.0
|
|
|
|
|
|
class PlaybackUseCases:
|
|
"""Legacy playback class behaviour; playback is scheduled on the reactor (non-blocking)."""
|
|
|
|
def __init__(
|
|
self,
|
|
system_name: str,
|
|
*,
|
|
call_later: Callable[..., Any],
|
|
get_protocol: Any = None,
|
|
) -> None:
|
|
self._system = system_name
|
|
self._call_later = call_later
|
|
self._get_protocol = get_protocol
|
|
self.STATUS: dict[str, Any] = {}
|
|
self.CALL_DATA: list[bytes] = []
|
|
self._rx_stream_by_slot: dict[int, bytes] = {}
|
|
self._record_stream: bytes = b""
|
|
self._seen_record_streams: set[bytes] = set()
|
|
self._ignored_streams_logged: set[bytes] = set()
|
|
self._record_ctx: dict[str, Any] = {}
|
|
self._recording_active = False
|
|
self._last_record_time = 0.0
|
|
self._playback_busy = False
|
|
self._playback_packets: list[bytes] = []
|
|
self._playback_index = 0
|
|
self._playback_stream_id = b""
|
|
self._playback_ta_from_stream = b""
|
|
self._delay_call: Any = None
|
|
self._packet_call: Any = None
|
|
self._idle_call: Any = None
|
|
self._max_call: Any = None
|
|
|
|
def dmrd_received(
|
|
self,
|
|
system_name: str,
|
|
peer_id: bytes,
|
|
rf_src: bytes,
|
|
dst_id: bytes,
|
|
seq: int,
|
|
slot: int,
|
|
call_type: str,
|
|
frame_type: int,
|
|
dtype_vseq: int,
|
|
stream_id: bytes,
|
|
data: bytes,
|
|
ingress_pkt_time: float | None = None,
|
|
) -> None:
|
|
"""Port of playback.dmrd_received (playback.py lines 114-161)."""
|
|
if call_type != "group":
|
|
logger.debug(
|
|
"(%s) Playback ignore non-group call type=%s stream=%s",
|
|
self._system, call_type, int_id(stream_id),
|
|
)
|
|
return
|
|
|
|
# Legacy blocked the reactor during delay + playback; ignore new voice meanwhile.
|
|
if self._playback_busy:
|
|
logger.debug(
|
|
"(%s) Playback ignore stream %s (playback in progress)",
|
|
self._system, int_id(stream_id),
|
|
)
|
|
return
|
|
|
|
pkt_time = ingress_pkt_time if ingress_pkt_time is not None else time()
|
|
proto = self._get_protocol() if self._get_protocol else None
|
|
|
|
if self._recording_active and self.CALL_DATA and self._recording_exceeded_max(pkt_time):
|
|
self._on_record_max_duration(proto)
|
|
return
|
|
|
|
slot_status = getattr(proto, "STATUS", {}).get(slot, {}) if proto else {}
|
|
record_stream = self._record_stream or self._rx_stream_by_slot.get(
|
|
slot, slot_status.get("RX_STREAM_ID", b"\x00"),
|
|
)
|
|
is_vhead = frame_type == HBPF_DATA_SYNC and dtype_vseq == HBPF_SLT_VHEAD
|
|
idle_gap = pkt_time - self._last_record_time if self._last_record_time else 0.0
|
|
|
|
if stream_id != record_stream:
|
|
# Hotspot re-key mid-PTT: new VHEAD shortly after the last voice frame.
|
|
if (
|
|
self._recording_active
|
|
and self.CALL_DATA
|
|
and is_vhead
|
|
and stream_id not in self._seen_record_streams
|
|
and idle_gap < _RECORD_IDLE_S
|
|
):
|
|
prev = record_stream
|
|
self._record_stream = stream_id
|
|
self._rx_stream_by_slot[slot] = stream_id
|
|
self._seen_record_streams.add(stream_id)
|
|
logger.debug(
|
|
"(%s) Recording continues: stream %s -> %s (same PTT)",
|
|
self._system, int_id(prev), int_id(stream_id),
|
|
)
|
|
# Same PTT re-key: track new stream but do not store a second VHEAD.
|
|
self._last_record_time = pkt_time
|
|
self._record_ctx = {
|
|
"slot": slot,
|
|
"rf_src": rf_src,
|
|
"peer_id": peer_id,
|
|
"dst_id": dst_id,
|
|
}
|
|
self._schedule_record_idle(proto)
|
|
return
|
|
# New PTT while prior recording never got VTERM (user released, hotspot cut, etc.).
|
|
if self._recording_active and self.CALL_DATA and is_vhead and idle_gap >= _RECORD_IDLE_S:
|
|
logger.info(
|
|
"(%s) New PTT after %.1fs idle; discarding prior recording (%d packets)",
|
|
self._system, idle_gap, len(self.CALL_DATA),
|
|
)
|
|
self._reset_recording_state(slot)
|
|
self._begin_recording(slot, stream_id, pkt_time, rf_src, peer_id, dst_id, proto)
|
|
self._append_voice(data, pkt_time, proto, slot, rf_src, peer_id, dst_id)
|
|
return
|
|
if self._recording_active and self.CALL_DATA:
|
|
if stream_id not in self._ignored_streams_logged:
|
|
self._ignored_streams_logged.add(stream_id)
|
|
logger.debug(
|
|
"(%s) Ignoring stream %s (current recording stream %s)",
|
|
self._system, int_id(stream_id), int_id(record_stream),
|
|
)
|
|
return
|
|
if self.CALL_DATA:
|
|
logger.debug(
|
|
"(%s) Discarding stale recording (%d packets) before new stream %s",
|
|
self._system, len(self.CALL_DATA), int_id(stream_id),
|
|
)
|
|
self._reset_recording_state(slot)
|
|
self._begin_recording(slot, stream_id, pkt_time, rf_src, peer_id, dst_id, proto)
|
|
self._append_voice(data, pkt_time, proto, slot, rf_src, peer_id, dst_id)
|
|
return
|
|
|
|
if (
|
|
frame_type == HBPF_DATA_SYNC
|
|
and dtype_vseq == HBPF_SLT_VTERM
|
|
and self._recording_active
|
|
and self.CALL_DATA
|
|
and stream_id == record_stream
|
|
):
|
|
call_duration = pkt_time - self.STATUS.get("RX_START", pkt_time)
|
|
self.CALL_DATA.append(data)
|
|
logger.info("(%s) *END RECORDING* STREAM ID: %s", self._system, int_id(stream_id))
|
|
self._commit_recording(proto, call_duration)
|
|
return
|
|
|
|
if self._recording_active and self.CALL_DATA:
|
|
self._append_voice(data, pkt_time, proto, slot, rf_src, peer_id, dst_id)
|
|
|
|
def _append_voice(
|
|
self,
|
|
data: bytes,
|
|
pkt_time: float,
|
|
proto: Any,
|
|
slot: int,
|
|
rf_src: bytes,
|
|
peer_id: bytes,
|
|
dst_id: bytes,
|
|
) -> None:
|
|
self.CALL_DATA.append(data)
|
|
if proto is not None and hasattr(proto, "store_ta_from_voice_burst") and len(data) >= 53:
|
|
bits = data[15]
|
|
frame_type = (bits & 0x30) >> 4
|
|
vseq = bits & 0xF
|
|
if frame_type != HBPF_DATA_SYNC and vseq in (1, 2, 3, 4):
|
|
proto.store_ta_from_voice_burst(
|
|
peer_id, rf_src, self._record_stream, vseq, data[20:53],
|
|
)
|
|
self._last_record_time = pkt_time
|
|
self._record_ctx = {
|
|
"slot": slot,
|
|
"rf_src": rf_src,
|
|
"peer_id": peer_id,
|
|
"dst_id": dst_id,
|
|
}
|
|
self._schedule_record_idle(proto)
|
|
|
|
def _schedule_record_idle(self, proto: Any) -> None:
|
|
self._cancel_record_idle()
|
|
if not self._recording_active:
|
|
return
|
|
self._idle_call = self._call_later(_RECORD_IDLE_S, self._on_record_idle, proto)
|
|
|
|
def _cancel_record_idle(self) -> None:
|
|
if self._idle_call is not None and self._idle_call.active():
|
|
self._idle_call.cancel()
|
|
self._idle_call = None
|
|
|
|
def _on_record_idle(self, proto: Any) -> None:
|
|
self._idle_call = None
|
|
if not self._recording_active or not self.CALL_DATA or self._playback_busy:
|
|
return
|
|
call_duration = time() - self.STATUS.get("RX_START", time())
|
|
logger.info(
|
|
"(%s) *END RECORDING* idle timeout %.0fs (no VTERM) stream %s",
|
|
self._system, _RECORD_IDLE_S, int_id(self._record_stream),
|
|
)
|
|
self._commit_recording(proto, call_duration)
|
|
|
|
def _packet_is_vhead(self, data: bytes) -> bool:
|
|
if len(data) < 16:
|
|
return False
|
|
bits = data[15]
|
|
return ((bits & 0x30) >> 4) == HBPF_DATA_SYNC and (bits & 0xF) == HBPF_SLT_VHEAD
|
|
|
|
def _packet_is_vterm(self, data: bytes) -> bool:
|
|
if len(data) < 16:
|
|
return False
|
|
bits = data[15]
|
|
return ((bits & 0x30) >> 4) == HBPF_DATA_SYNC and (bits & 0xF) == HBPF_SLT_VTERM
|
|
|
|
def _make_vterm_packet(self, template: bytes, slot: int) -> bytes:
|
|
"""Build a voice terminator from the last recorded frame (idle/max end has no VTERM)."""
|
|
if len(template) < 53:
|
|
return template
|
|
pkt = bytearray(template)
|
|
ts_bit = 0x80 if slot == 2 else 0x00
|
|
pkt[15] = ts_bit | (HBPF_DATA_SYNC << 4) | HBPF_SLT_VTERM
|
|
pkt[4] = (int(pkt[4]) + 1) & 0xFF
|
|
return bytes(pkt)
|
|
|
|
def _ensure_vterm(self, recorded: list[bytes], slot: int) -> list[bytes]:
|
|
if not recorded or self._packet_is_vterm(recorded[-1]):
|
|
return recorded
|
|
logger.debug(
|
|
"(%s) Appending synthetic VTERM for playback (%d packets, no terminator from source)",
|
|
self._system, len(recorded),
|
|
)
|
|
return recorded + [self._make_vterm_packet(recorded[-1], slot)]
|
|
|
|
def _commit_recording(self, proto: Any, call_duration: float) -> None:
|
|
ctx = self._record_ctx
|
|
slot = ctx.get("slot", 1)
|
|
recorded = self._ensure_vterm(list(self.CALL_DATA), slot)
|
|
self._playback_ta_from_stream = self._record_stream
|
|
self._reset_recording_state(slot)
|
|
if not recorded:
|
|
return
|
|
self._playback_busy = True
|
|
self._delay_call = self._call_later(
|
|
_PLAYBACK_DELAY_S,
|
|
self._start_playback,
|
|
proto,
|
|
recorded,
|
|
ctx.get("rf_src", b"\x00\x00\x00"),
|
|
ctx.get("peer_id", b"\x00\x00\x00\x00"),
|
|
ctx.get("dst_id", b"\x00\x00\x00"),
|
|
slot,
|
|
call_duration,
|
|
)
|
|
|
|
def _begin_recording(
|
|
self,
|
|
slot: int,
|
|
stream_id: bytes,
|
|
pkt_time: float,
|
|
rf_src: bytes,
|
|
peer_id: bytes,
|
|
dst_id: bytes,
|
|
proto: Any,
|
|
) -> None:
|
|
self._recording_active = True
|
|
self._record_stream = stream_id
|
|
self._rx_stream_by_slot[slot] = stream_id
|
|
self._seen_record_streams = {stream_id}
|
|
self._ignored_streams_logged.clear()
|
|
self._last_record_time = pkt_time
|
|
self._record_ctx = {
|
|
"slot": slot,
|
|
"rf_src": rf_src,
|
|
"peer_id": peer_id,
|
|
"dst_id": dst_id,
|
|
}
|
|
self.STATUS["RX_START"] = pkt_time
|
|
logger.info(
|
|
"(%s) *START RECORDING* STREAM ID: %s SUB: %s REPEATER: %s TGID %s, TS %s",
|
|
self._system, int_id(stream_id), int_id(rf_src), int_id(peer_id), int_id(dst_id), slot,
|
|
)
|
|
self._schedule_record_max(proto)
|
|
self._schedule_record_idle(proto)
|
|
|
|
def _recording_exceeded_max(self, pkt_time: float) -> bool:
|
|
rx_start = self.STATUS.get("RX_START", 0.0)
|
|
return bool(rx_start) and (pkt_time - rx_start) >= _SOURCE_MAX_S
|
|
|
|
def _schedule_record_max(self, proto: Any) -> None:
|
|
self._cancel_record_max()
|
|
if not self._recording_active:
|
|
return
|
|
self._max_call = self._call_later(_SOURCE_MAX_S, self._on_record_max_duration, proto)
|
|
|
|
def _cancel_record_max(self) -> None:
|
|
if self._max_call is not None and self._max_call.active():
|
|
self._max_call.cancel()
|
|
self._max_call = None
|
|
|
|
def _on_record_max_duration(self, proto: Any) -> None:
|
|
self._max_call = None
|
|
if not self._recording_active or not self.CALL_DATA or self._playback_busy:
|
|
return
|
|
call_duration = time() - self.STATUS.get("RX_START", time())
|
|
logger.info(
|
|
"(%s) *END RECORDING* source timeout %.0fs (HBP max, no VTERM) stream %s",
|
|
self._system, _SOURCE_MAX_S, int_id(self._record_stream),
|
|
)
|
|
self._commit_recording(proto, call_duration)
|
|
|
|
def _reset_recording_state(self, slot: int) -> None:
|
|
self._cancel_record_idle()
|
|
self._cancel_record_max()
|
|
self.CALL_DATA = []
|
|
self._recording_active = False
|
|
self._record_stream = b""
|
|
self._seen_record_streams.clear()
|
|
self._ignored_streams_logged.clear()
|
|
self._last_record_time = 0.0
|
|
self._record_ctx = {}
|
|
self._rx_stream_by_slot.pop(slot, None)
|
|
|
|
def _start_playback(
|
|
self,
|
|
proto: Any,
|
|
recorded: list[bytes],
|
|
rf_src: bytes,
|
|
peer_id: bytes,
|
|
dst_id: bytes,
|
|
slot: int,
|
|
call_duration: float,
|
|
) -> None:
|
|
self._delay_call = None
|
|
if not proto or not recorded:
|
|
self._finish_playback()
|
|
return
|
|
|
|
self._playback_stream_id = bytes_4(randint(0x00, 0xFFFFFFFF))
|
|
if proto is not None and self._playback_ta_from_stream and hasattr(proto, "copy_ta_stream_buffer"):
|
|
proto.copy_ta_stream_buffer(self._playback_ta_from_stream, self._playback_stream_id)
|
|
self._playback_ta_from_stream = b""
|
|
logger.info(
|
|
"(%s) *START PLAYBACK* STREAM ID: %s SUB: %s REPEATER: %s TGID %s, TS %s, Duration: %.2f",
|
|
self._system,
|
|
int_id(self._playback_stream_id),
|
|
int_id(rf_src),
|
|
int_id(peer_id),
|
|
int_id(dst_id),
|
|
slot,
|
|
call_duration,
|
|
)
|
|
self._playback_packets = self._prepare_playback_packets(recorded)
|
|
self._playback_index = 0
|
|
self._send_next_packet(proto)
|
|
|
|
def _packet_is_voice_burst(self, data: bytes) -> bool:
|
|
if len(data) < 16:
|
|
return False
|
|
bits = data[15]
|
|
frame_type = (bits & 0x30) >> 4
|
|
vseq = bits & 0xF
|
|
return frame_type == HBPF_VOICE or (frame_type != HBPF_DATA_SYNC and vseq in (1, 2, 3, 4))
|
|
|
|
def _prepare_playback_packets(self, recorded: list[bytes]) -> list[bytes]:
|
|
"""Rewrite stream ID, drop mid-call VHEADs; preserve seq; new stream segment on seq wrap."""
|
|
out: list[bytes] = []
|
|
vhead_template: bytes | None = None
|
|
segment_sid = self._playback_stream_id
|
|
last_voice_seq: int | None = None
|
|
|
|
for i, pkt in enumerate(recorded):
|
|
if len(pkt) < 20:
|
|
continue
|
|
if i > 0 and self._packet_is_vhead(pkt):
|
|
logger.debug(
|
|
"(%s) Skipping mid-call VHEAD in playback (hotspot re-key)",
|
|
self._system,
|
|
)
|
|
continue
|
|
if self._packet_is_vhead(pkt) and vhead_template is None:
|
|
vhead_template = pkt
|
|
|
|
if self._packet_is_voice_burst(pkt) and last_voice_seq is not None:
|
|
curr_seq = pkt[4]
|
|
if curr_seq < last_voice_seq and last_voice_seq >= 200:
|
|
segment_sid = bytes_4(randint(0x00, 0xFFFFFFFF))
|
|
logger.info(
|
|
"(%s) Playback seq wrap (%s -> %s); new stream %s at recorded index %d",
|
|
self._system,
|
|
last_voice_seq,
|
|
curr_seq,
|
|
int_id(segment_sid),
|
|
i,
|
|
)
|
|
if vhead_template is not None:
|
|
vhead = bytearray(vhead_template[:16] + segment_sid + vhead_template[20:])
|
|
out.append(bytes(vhead))
|
|
|
|
out.append(pkt[:16] + segment_sid + pkt[20:])
|
|
if self._packet_is_voice_burst(pkt):
|
|
last_voice_seq = pkt[4]
|
|
|
|
return out
|
|
|
|
def _send_next_packet(self, proto: Any) -> None:
|
|
self._packet_call = None
|
|
if self._playback_index >= len(self._playback_packets):
|
|
logger.info(
|
|
"(%s) *END PLAYBACK* STREAM ID: %s",
|
|
self._system,
|
|
int_id(self._playback_stream_id),
|
|
)
|
|
self._finish_playback()
|
|
return
|
|
if proto:
|
|
proto.send_system(self._playback_packets[self._playback_index])
|
|
else:
|
|
logger.warning("(%s) Playback packet %d dropped (no protocol/send_system)", self._system, self._playback_index)
|
|
self._playback_index += 1
|
|
self._packet_call = self._call_later(_PACKET_INTERVAL_S, self._send_next_packet, proto)
|
|
|
|
def _finish_playback(self) -> None:
|
|
if self._delay_call is not None and self._delay_call.active():
|
|
self._delay_call.cancel()
|
|
if self._packet_call is not None and self._packet_call.active():
|
|
self._packet_call.cancel()
|
|
self._cancel_record_idle()
|
|
self._cancel_record_max()
|
|
self._playback_busy = False
|
|
self._playback_packets = []
|
|
self._playback_index = 0
|
|
self._playback_stream_id = b""
|
|
self._playback_ta_from_stream = b""
|
|
self._delay_call = None
|
|
self._packet_call = None
|