diff --git a/daemon/codec.c b/daemon/codec.c index 68fd50e71..d89f5d479 100644 --- a/daemon/codec.c +++ b/daemon/codec.c @@ -2727,8 +2727,13 @@ static int packet_dtmf_event(struct codec_ssrc_handler *ch, struct codec_ssrc_ha { LOCK(&mp->media->dtmf_lock); - if (mp->media->dtmf_ts == packet->ts) + if (mp->media->dtmf_ts == packet->ts) { + // The event is deduplicated per media, but suppression is tracked per + // input handler. Clear it for every destination that saw this event. + if (input_ch->dtmf_start_ts == packet->ts) + input_ch->dtmf_start_ts = 0; return 1; // ignore already processed events + } int ret = dtmf_event_packet(mp, packet->payload, ch->handler->source_pt.clock_rate, packet->ts); if (G_UNLIKELY(ret == -1)) // error diff --git a/t/Makefile b/t/Makefile index db85b9593..ceef9579e 100644 --- a/t/Makefile +++ b/t/Makefile @@ -90,7 +90,7 @@ include ../lib/common.Makefile daemon-tests-bundle daemon-tests-dtls daemon-tests-rollback \ daemon-tests-rollback-redis daemon-tests-redis-srtp-index \ daemon-tests-recording daemon-tests-create daemon-tests-created-subscriptions daemon-tests-alias \ - daemon-tests-kernel-api \ + daemon-tests-kernel-api daemon-tests-dtmf-fanout \ daemon-tests-video daemon-tests-inject daemon-tests-dtx-dtmf TESTS := test-bitstr aes-crypt aead-aes-crypt test-const_str_hash.strhash @@ -145,7 +145,7 @@ daemon-tests: daemon-tests-main daemon-tests-jb daemon-tests-pubsub daemon-tests daemon-tests-recording daemon-tests-create daemon-tests-created-subscriptions \ daemon-tests-dtx daemon-tests-dtx-cn daemon-tests-dtx-no-shift daemon-tests-dtx-dtmf \ daemon-tests-alias \ - daemon-tests-kernel-api \ + daemon-tests-kernel-api daemon-tests-dtmf-fanout \ daemon-tests-video daemon-tests-inject daemon-test-deps: tests-preload.so @@ -184,6 +184,9 @@ daemon-tests-dtx: daemon-test-deps daemon-tests-dtx-dtmf: daemon-test-deps ./auto-test-helper "$@" perl -I../perl auto-daemon-tests-dtx-dtmf.pl +daemon-tests-dtmf-fanout: daemon-test-deps + ./auto-test-helper "$@" perl -I../perl auto-daemon-tests-dtmf-fanout.pl + daemon-tests-dtx-no-shift: daemon-test-deps ./auto-test-helper "$@" perl -I../perl auto-daemon-tests-dtx-no-shift.pl diff --git a/t/auto-daemon-tests-dtmf-fanout.pl b/t/auto-daemon-tests-dtmf-fanout.pl new file mode 100755 index 000000000..eca6f7c73 --- /dev/null +++ b/t/auto-daemon-tests-dtmf-fanout.pl @@ -0,0 +1,125 @@ +#!/usr/bin/perl + +use strict; +use warnings; +use NGCP::Rtpengine::Test; +use NGCP::Rtpclient::SRTP; +use NGCP::Rtpengine::AutoTest; +use Test::More; +use Time::HiRes qw(time sleep); +use IO::Select; + +plan skip_all => 'requires AMR-WB encoding; enable RTPENGINE_EXTENDED_TESTS' + unless $ENV{RTPENGINE_EXTENDED_TESTS}; + +autotest_start(qw(--config-file=none -t -1 -i 203.0.113.1 + -n 2223 -f -L 7 -E --dtx-delay=50)) or die; + +# Convert the AMR-WB fixture from auto-daemon-tests-dtx.pl to bandwidth-efficient RTP. +my $speech = pack('H*', 'f1f41182687c5cc09c5c400285a1dd3a9aa30199d9bb3d59db151b51531f16635f15711b14'); +my $pcm = "\0" x 1600; +my $wav = pack('a4Va4a4VvvVVvva4V', 'RIFF', 36 + length($pcm), 'WAVE', + 'fmt ', 16, 1, 1, 8000, 16000, 2, 16, 'data', length($pcm)) . $pcm; + +# The per-media event deduplication must not leave a later destination suppressed. +for my $order (['phone', 'browser'], ['browser', 'phone']) { + my $name = 'DTMF end fan-out: ' . join(', ', @$order); + my @tags = ('source', 'phone', 'browser', 'sink'); + my @codecs = ('AMR-WB/16000', 'AMR-WB/16000', 'opus/48000/2', 'PCMU/8000'); + my @socks = new_call([qw(198.51.100.10 5000)], [qw(198.51.100.10 5002)], + [qw(198.51.100.10 5004)], [qw(198.51.100.10 5006)]); + my @ports; + for my $i (0..3) { + my $pt = $i == 3 ? 0 : 104; + my $port = 5000 + $i * 2; + my $sdp = "v=0\r\no=- 1 1 IN IP4 198.51.100.10\r\ns=fanout\r\nt=0 0\r\n" + . "m=audio $port RTP/AVP $pt 105\r\nc=IN IP4 198.51.100.10\r\n" + . "a=rtpmap:$pt $codecs[$i]\r\na=rtpmap:105 telephone-event/16000\r\na=sendrecv\r\n"; + my $resp = rtpe_req('publish', $name, { + 'from-tag' => $tags[$i], + sdp => $sdp, + flags => ['bidirectional', 'detect DTMF'], + 'DTMF-security' => 'silence', + 'delay buffer' => 100, + 'audio-player' => 'off', + }); + ($ports[$i]) = $resp->{sdp} =~ /m=audio (\d+)/; + } + rtpe_req('block DTMF', $name, {'from-tag' => 'source', 'DTMF-security' => 'silence'}); + rtpe_req('mesh', $name, { + calls => [cid()], + tags => [{from => 'source', to => [@$order, 'sink']}], + flags => ['unsubscribe'], + }); + my $select = IO::Select->new(@socks[1..3]); + my $seq = 1000; + my $ts = 100000; + + # Prime every primary audio handler before the blocked event, as in a live call. + for my $i (0..19) { + snd($socks[0], $ports[0], rtp(104, $seq++, $ts, 0x5678, $speech)); + $ts += 320; + sleep(0.02); + } + my $drain_until = time() + 0.1; + while (time() < $drain_until) { + my @ready = $select->can_read(0.01); + for my $sock (@ready) { my $packet; $sock->recv($packet, 65535); } + } + + for my $burst (0..1) { + rtpe_req('block media', $name, {'from-tag' => 'source', flags => ['directional']}); + for my $i (1..2) { + rtpe_req('play media', $name, {'from-tag' => $tags[$i], blob => $wav, flags => ['block egress']}); + } + my $event_ts = $ts; + for my $i (0..9) { + snd($socks[0], $ports[0], rtp(105, $seq++, $event_ts, 0x5678, + pack('CCn', 10, 7, 320 * ($i + 1)))); + snd($socks[0], $ports[0], rtp(104, $seq++, $ts, 0x5678, $speech)); + $ts += 320; + sleep(0.02); + } + # Repeated end packets are deduplicated globally but must clear every leg. + for my $i (1..3) { + snd($socks[0], $ports[0], rtp(105, $seq++, $event_ts, 0x5678, pack('CCn', 10, 135, 3200))); + sleep(0.02); + } + sleep(0.15); + for my $i (1..2) { rtpe_req('stop media', $name, {'from-tag' => $tags[$i]}); } + rtpe_req('unblock media', $name, {'from-tag' => 'source', flags => ['directional']}); + my $start = time(); + my $next = $start; + my (%first, %count, %last, %gap); + while (time() < $start + 1.2) { + if (time() >= $next) { + snd($socks[0], $ports[0], rtp(104, $seq++, $ts, 0x5678, $speech)); + $ts += 320; + $next += 0.02; + } + for my $sock ($select->can_read(0.001)) { + my $packet; + $sock->recv($packet, 65535); + my ($pt, $out_seq, $out_ts, $ssrc) = unpack('x C n N N', $packet); + next unless $ssrc == 0x5678; + my $leg = $sock == $socks[1] ? 'phone' : $sock == $socks[2] ? 'browser' : 'sink'; + my $now = time(); + $first{$leg} //= $now - $start; + $count{$leg}++; + if (exists $last{$leg}) { + my $delta = $now - $last{$leg}; + $gap{$leg} = $delta if $delta > ($gap{$leg} // 0); + } + $last{$leg} = $now; + } + } + for my $leg ('phone', 'browser', 'sink') { + cmp_ok($count{$leg} // 0, '>=', 20, "$name burst $burst: $leg keeps receiving speech RTP"); + cmp_ok($first{$leg} // 10, '<', 0.4, "$name burst $burst: $leg resumes promptly after DTMF end"); + cmp_ok($gap{$leg} // 10, '<', 0.3, "$name burst $burst: $leg has no suppression gap"); + } + } + rtpe_req('delete', $name, {'from-tag' => 'source'}); +} + +done_testing();