Created
September 18, 2026 15:43
-
-
Save rene-d/a4f1f35a93676a6bf800cb4d50759dca to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| # -*- coding: utf-8 -*- | |
| """ | |
| Analyse des trames RTP (G.711) d'une capture .pcap / .pcapng. | |
| Aucune dependance externe : les formats pcap (classique) et pcapng sont | |
| decodes directement, ainsi que les couches Ethernet/VLAN/IPv4/IPv6/UDP. | |
| Hypothese de travail (cf. cahier des charges) : tout paquet UDP de la capture | |
| est cense transporter une trame RTP. L'entete RTP est malgre tout validee ; | |
| un paquet qui ne passe pas la validation est ignore et signale. | |
| Usage : | |
| python3 rtp_ana.py capture.pcap | |
| python3 rtp_ana.py capture.pcap --seuil 15 --limite 30 | |
| python3 rtp_ana.py capture.pcap --graphe-svg niveaux.svg --wav piste | |
| PROMPT: | |
| écris un script Python qui va lire un .pcap (sans module externe) | |
| et qui va analyser les trames RTP : | |
| - un paquet UDP de la capture est une trame RTP. vérifie quand même l'entête RTP. sinon ignore le paquet et signale le | |
| - les trames RTP sont en G.711 | |
| - nombre de trames | |
| - espacement temporel minimal, maximal | |
| - pour un espace temporel < 15ms, nombre de "paquets" de trames, et nombre de trames mini, maxi et moyen dans ces "paquets" | |
| - vérifie que le seqnum est continu par SSRC | |
| - vérifie que le timestamp est croissant par SSRC | |
| - durée (au sens capture) | |
| - durée (au sens timestamp RTP) | |
| ajoute la création d'un graphe des niveaux sonores, comme Audacity | |
| (ou wireshark) le ferait | |
| """ | |
| import argparse | |
| import contextlib | |
| import math | |
| import os | |
| import shutil | |
| import struct | |
| import sys | |
| import wave | |
| from array import array | |
| from collections import Counter | |
| # --------------------------------------------------------------------------- | |
| # Constantes | |
| # --------------------------------------------------------------------------- | |
| G711_CLOCK = 8000 # Hz, horloge RTP de G.711 (PCMU/PCMA) | |
| G711_PT = {0: "PCMU (G.711 u-law)", 8: "PCMA (G.711 A-law)"} | |
| DEFAULT_BURST_MS = 15.0 # seuil de regroupement en "paquets" de trames | |
| DEFAULT_BLOC_MS = 10.0 # resolution temporelle des graphes de niveau | |
| DEFAULT_PLANCHER_DB = -60.0 # bas de l'echelle du graphe des niveaux | |
| DEFAULT_SILENCE_DB = -50.0 # en dessous, un bloc est compte comme silencieux | |
| DEFAULT_MAX_DUREE = 3600.0 # duree maximale reconstruite par flux (s) | |
| LINKTYPE_NAMES = { | |
| 0: "NULL/Loopback", 1: "Ethernet", 9: "PPP", 12: "RAW IP", 101: "RAW IP", | |
| 105: "IEEE 802.11", 113: "Linux SLL", 127: "802.11 radiotap", | |
| 228: "IPv4 brut", 229: "IPv6 brut", 276: "Linux SLL2", | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Lecture des fichiers de capture | |
| # --------------------------------------------------------------------------- | |
| class CaptureError(Exception): | |
| pass | |
| def iter_pcap(f): | |
| """Itere sur un fichier pcap classique : (ts, linktype, octets, longueur_reelle).""" | |
| hdr = f.read(24) | |
| if len(hdr) < 24: | |
| raise CaptureError("fichier pcap tronque (entete globale incomplete)") | |
| magic = hdr[:4] | |
| if magic == b"\xd4\xc3\xb2\xa1": | |
| endian, div = "<", 1_000_000 | |
| elif magic == b"\xa1\xb2\xc3\xd4": | |
| endian, div = ">", 1_000_000 | |
| elif magic == b"\x4d\x3c\xb2\xa1": | |
| endian, div = "<", 1_000_000_000 | |
| elif magic == b"\xa1\xb2\x3c\x4d": | |
| endian, div = ">", 1_000_000_000 | |
| else: | |
| raise CaptureError("nombre magique pcap inconnu : %s" % magic.hex()) | |
| _vmaj, _vmin, _tz, _sig, _snap, linktype = struct.unpack(endian + "HHiIII", hdr[4:24]) | |
| linktype &= 0xFFFF # les bits hauts portent des drapeaux (FCS) | |
| while True: | |
| ph = f.read(16) | |
| if len(ph) < 16: | |
| break | |
| ts_s, ts_frac, caplen, origlen = struct.unpack(endian + "IIII", ph) | |
| data = f.read(caplen) | |
| if len(data) < caplen: | |
| break # dernier paquet tronque | |
| yield ts_s + ts_frac / div, linktype, data, origlen | |
| def _ng_options(buf, endian): | |
| """Itere sur les options d'un bloc pcapng : (code, valeur).""" | |
| off = 0 | |
| while off + 4 <= len(buf): | |
| code, olen = struct.unpack(endian + "HH", buf[off:off + 4]) | |
| off += 4 | |
| if code == 0: # opt_endofopt | |
| break | |
| val = buf[off:off + olen] | |
| off += (olen + 3) & ~3 # padding sur 4 octets | |
| yield code, val | |
| def iter_pcapng(f): | |
| """Itere sur un fichier pcapng : (ts, linktype, octets, longueur_reelle).""" | |
| endian = "<" | |
| linktypes, tsdiv = {}, {} | |
| if_index = 0 | |
| f.seek(0) | |
| while True: | |
| head = f.read(8) | |
| if len(head) < 8: | |
| break | |
| # 0x0A0D0D0A est un palindrome d'octets : reconnu quel que soit le boutisme | |
| btype = struct.unpack(endian + "I", head[:4])[0] | |
| if btype == 0x0A0D0D0A: # Section Header Block | |
| bom = f.read(4) | |
| if bom == b"\x4d\x3c\x2b\x1a": # 0x1A2B3C4D ecrit en petit-boutiste | |
| endian = "<" | |
| elif bom == b"\x1a\x2b\x3c\x4d": # ... en gros-boutiste | |
| endian = ">" | |
| else: | |
| raise CaptureError("byte-order magic pcapng invalide") | |
| blen = struct.unpack(endian + "I", head[4:8])[0] | |
| if blen < 16: | |
| raise CaptureError("bloc SHB invalide") | |
| f.read(blen - 12) # reste du bloc | |
| linktypes, tsdiv, if_index = {}, {}, 0 # nouvelle section | |
| continue | |
| blen = struct.unpack(endian + "I", head[4:8])[0] | |
| if blen < 12: | |
| raise CaptureError("bloc pcapng de taille invalide (%d)" % blen) | |
| rest = f.read(blen - 8) # corps + longueur totale repetee (4 o) | |
| if len(rest) < blen - 8: | |
| break | |
| body = rest[:-4] | |
| if btype == 0x00000001: # Interface Description Block | |
| lt = struct.unpack(endian + "H", body[0:2])[0] | |
| res = 6 | |
| for code, val in _ng_options(body[8:], endian): | |
| if code == 9 and val: # if_tsresol | |
| res = val[0] | |
| linktypes[if_index] = lt | |
| tsdiv[if_index] = (1 << (res & 0x7F)) if (res & 0x80) else 10 ** res | |
| if_index += 1 | |
| elif btype == 0x00000006: # Enhanced Packet Block | |
| iface, tsh, tsl, caplen, origlen = struct.unpack(endian + "IIIII", body[:20]) | |
| data = body[20:20 + caplen] | |
| ts = ((tsh << 32) | tsl) / tsdiv.get(iface, 1_000_000) | |
| yield ts, linktypes.get(iface, 1), data, origlen | |
| # Les autres blocs (SPB sans horodatage, NRB, ISB...) sont ignores. | |
| def iter_capture(path): | |
| """Ouvre la capture et renvoie (generateur, format).""" | |
| f = open(path, "rb") | |
| magic = f.read(4) | |
| f.seek(0) | |
| if magic == b"\x0a\x0d\x0d\x0a": | |
| return iter_pcapng(f), "pcapng" | |
| return iter_pcap(f), "pcap" | |
| # --------------------------------------------------------------------------- | |
| # Decodage des couches basses | |
| # --------------------------------------------------------------------------- | |
| def decode_link(linktype, data): | |
| """Renvoie (payload_ip, version_ip) ou (None, raison).""" | |
| if linktype == 1: # Ethernet | |
| if len(data) < 14: | |
| return None, "trame Ethernet trop courte" | |
| etype = struct.unpack("!H", data[12:14])[0] | |
| off = 14 | |
| while etype in (0x8100, 0x88A8, 0x9100, 0x9200): # VLAN empilees | |
| if len(data) < off + 4: | |
| return None, "en-tete VLAN tronquee" | |
| etype = struct.unpack("!H", data[off + 2:off + 4])[0] | |
| off += 4 | |
| if etype == 0x0800: | |
| return data[off:], 4 | |
| if etype == 0x86DD: | |
| return data[off:], 6 | |
| return None, "ethertype 0x%04x (non IP)" % etype | |
| if linktype == 0: # NULL / Loopback (BSD) | |
| if len(data) < 4: | |
| return None, "en-tete loopback tronquee" | |
| fam = struct.unpack("<I", data[:4])[0] | |
| if fam > 0xFFFF: | |
| fam = struct.unpack(">I", data[:4])[0] | |
| if fam == 2: | |
| return data[4:], 4 | |
| if fam in (24, 28, 30): | |
| return data[4:], 6 | |
| return None, "famille loopback %d (non IP)" % fam | |
| if linktype == 113: # Linux cooked v1 | |
| if len(data) < 16: | |
| return None, "en-tete SLL tronquee" | |
| etype = struct.unpack("!H", data[14:16])[0] | |
| if etype == 0x0800: | |
| return data[16:], 4 | |
| if etype == 0x86DD: | |
| return data[16:], 6 | |
| return None, "SLL protocole 0x%04x (non IP)" % etype | |
| if linktype == 276: # Linux cooked v2 | |
| if len(data) < 20: | |
| return None, "en-tete SLL2 tronquee" | |
| etype = struct.unpack("!H", data[0:2])[0] | |
| if etype == 0x0800: | |
| return data[20:], 4 | |
| if etype == 0x86DD: | |
| return data[20:], 6 | |
| return None, "SLL2 protocole 0x%04x (non IP)" % etype | |
| if linktype in (12, 101, 228, 229): # IP brute | |
| if not data: | |
| return None, "paquet vide" | |
| ver = data[0] >> 4 | |
| if ver in (4, 6): | |
| return data, ver | |
| return None, "IP brute de version %d" % ver | |
| return None, "couche liaison non geree (linktype %d)" % linktype | |
| def ip_str(raw): | |
| if len(raw) == 4: | |
| return ".".join(str(b) for b in raw) | |
| parts = [raw[i:i + 2].hex() for i in range(0, 16, 2)] | |
| return ":".join(p.lstrip("0") or "0" for p in parts) | |
| IPV6_EXT = {0, 43, 44, 51, 60, 135} | |
| def decode_ip_udp(payload, version): | |
| """Renvoie (src, dst, sport, dport, donnees_udp) ou (None, raison).""" | |
| if version == 4: | |
| if len(payload) < 20: | |
| return None, "en-tete IPv4 tronquee" | |
| ihl = (payload[0] & 0x0F) * 4 | |
| if ihl < 20 or len(payload) < ihl: | |
| return None, "IHL IPv4 invalide" | |
| total_len = struct.unpack("!H", payload[2:4])[0] | |
| proto = payload[9] | |
| frag = struct.unpack("!H", payload[6:8])[0] | |
| src, dst = ip_str(payload[12:16]), ip_str(payload[16:20]) | |
| if (frag & 0x1FFF) != 0: | |
| return None, "fragment IPv4 non initial" | |
| if proto != 17: | |
| return None, "protocole IP %d (non UDP)" % proto | |
| end = total_len if 0 < total_len <= len(payload) else len(payload) | |
| body = payload[ihl:end] | |
| else: | |
| if len(payload) < 40: | |
| return None, "en-tete IPv6 tronquee" | |
| plen = struct.unpack("!H", payload[4:6])[0] | |
| nxt = payload[6] | |
| src, dst = ip_str(payload[8:24]), ip_str(payload[24:40]) | |
| off = 40 | |
| end = min(40 + plen, len(payload)) if plen else len(payload) | |
| while nxt in IPV6_EXT: | |
| if nxt == 44: # fragment | |
| return None, "fragment IPv6" | |
| if len(payload) < off + 8: | |
| return None, "en-tete d'extension IPv6 tronquee" | |
| ext_len = (payload[off + 1] + 1) * 8 | |
| nxt = payload[off] | |
| off += ext_len | |
| if nxt != 17: | |
| return None, "next header IPv6 %d (non UDP)" % nxt | |
| body = payload[off:end] | |
| if len(body) < 8: | |
| return None, "en-tete UDP tronquee" | |
| sport, dport, ulen, _cksum = struct.unpack("!HHHH", body[:8]) | |
| data = body[8:8 + ulen - 8] if 8 <= ulen <= len(body) else body[8:] | |
| return (src, dst, sport, dport, data), None | |
| # --------------------------------------------------------------------------- | |
| # Decodage RTP | |
| # --------------------------------------------------------------------------- | |
| def parse_rtp(buf): | |
| """Valide et decode une entete RTP. Renvoie (dict, None) ou (None, raison).""" | |
| if len(buf) < 12: | |
| return None, "charge utile UDP < 12 octets (entete RTP impossible)" | |
| b0, b1 = buf[0], buf[1] | |
| version = b0 >> 6 | |
| if version != 2: | |
| return None, "version RTP = %d (attendu 2)" % version | |
| padding = (b0 >> 5) & 1 | |
| extension = (b0 >> 4) & 1 | |
| cc = b0 & 0x0F | |
| marker = b1 >> 7 | |
| pt = b1 & 0x7F | |
| if 72 <= pt <= 76: | |
| return None, "paquet RTCP (PT=%d)" % pt | |
| seq, ts, ssrc = struct.unpack("!HII", buf[2:12]) | |
| hlen = 12 + 4 * cc | |
| if len(buf) < hlen: | |
| return None, "entete tronquee (CC=%d)" % cc | |
| if extension: | |
| if len(buf) < hlen + 4: | |
| return None, "extension RTP tronquee" | |
| ext_words = struct.unpack("!H", buf[hlen + 2:hlen + 4])[0] | |
| hlen += 4 + 4 * ext_words | |
| if len(buf) < hlen: | |
| return None, "extension RTP declaree plus longue que le paquet" | |
| plen = len(buf) - hlen | |
| pad_len = 0 | |
| if padding: | |
| if plen < 1: | |
| return None, "bit P positionne sans octet de bourrage" | |
| pad_len = buf[-1] | |
| if pad_len == 0 or pad_len > plen: | |
| return None, "longueur de bourrage RTP invalide (%d)" % pad_len | |
| plen -= pad_len | |
| return { | |
| "seq": seq, "ts": ts, "ssrc": ssrc, "pt": pt, "marker": marker, | |
| "cc": cc, "ext": extension, "hlen": hlen, "plen": plen, | |
| "payload": buf[hlen:hlen + plen], | |
| }, None | |
| # --------------------------------------------------------------------------- | |
| # Decodage G.711 -> PCM 16 bits | |
| # --------------------------------------------------------------------------- | |
| PLEINE_ECHELLE = 32768.0 # reference 0 dBov pour du PCM 16 bits | |
| DB_MINIMUM = -90.0 # valeur rendue pour un silence numerique parfait | |
| def _table_ulaw(): | |
| """Table de decodage G.711 u-law (PT 0) -> PCM 16 bits signe.""" | |
| table = [] | |
| for octet in range(256): | |
| u = ~octet & 0xFF | |
| signe = u & 0x80 | |
| exposant = (u >> 4) & 0x07 | |
| mantisse = u & 0x0F | |
| val = (((mantisse << 3) + 0x84) << exposant) - 0x84 | |
| table.append(-val if signe else val) | |
| return table | |
| def _table_alaw(): | |
| """Table de decodage G.711 A-law (PT 8) -> PCM 16 bits signe.""" | |
| table = [] | |
| for octet in range(256): | |
| a = octet ^ 0x55 | |
| signe = a & 0x80 | |
| exposant = (a >> 4) & 0x07 | |
| mantisse = a & 0x0F | |
| if exposant == 0: | |
| val = (mantisse << 4) + 8 | |
| elif exposant == 1: | |
| val = (mantisse << 4) + 0x108 | |
| else: | |
| val = ((mantisse << 4) + 0x108) << (exposant - 1) | |
| # en A-law le bit de signe vaut 1 pour les valeurs positives | |
| table.append(val if signe else -val) | |
| return table | |
| # Tables d'octets : b"".join(map(table.__getitem__, payload)) reconstruit | |
| # directement un buffer exploitable par array("h").frombytes(). Les deux tables | |
| # reproduisent les valeurs de reference de l'UIT-T G.711 (u-law 0xFF -> 0, | |
| # 0x00 -> -32124 ; A-law 0xD5 -> +8, 0x2A -> -32256) et rendent environ 39 dB | |
| # de rapport signal/bruit sur un aller-retour a pleine echelle. | |
| ULAW_OCTETS = [struct.pack("h", v) for v in _table_ulaw()] | |
| ALAW_OCTETS = [struct.pack("h", v) for v in _table_alaw()] | |
| TABLES_G711 = {0: ULAW_OCTETS, 8: ALAW_OCTETS} | |
| def db_ov(amplitude): | |
| """Amplitude lineaire (unites PCM) -> niveau en dBov.""" | |
| if amplitude <= 0: | |
| return DB_MINIMUM | |
| val = 20.0 * math.log10(amplitude / PLEINE_ECHELLE) | |
| return val if val > DB_MINIMUM else DB_MINIMUM | |
| # --------------------------------------------------------------------------- | |
| # Reconstruction du signal : la "piste" audio d'un flux | |
| # --------------------------------------------------------------------------- | |
| class OptionsAudio: | |
| """Parametres de reconstruction / de rendu des niveaux sonores.""" | |
| def __init__(self, actif=True, bloc_ms=10.0, max_secondes=3600.0, pcm=False): | |
| self.actif = actif | |
| self.bloc_ms = bloc_ms | |
| self.max_secondes = max_secondes | |
| self.pcm = pcm # conserver les echantillons (export WAV) | |
| class Piste: | |
| """Signal audio d'un flux, replace sur l'echelle de temps de l'horodatage RTP. | |
| Les echantillons ne sont pas archives : chaque trame est decodee puis | |
| reduite a des agregats par bloc de `bloc_ms` (mini, maxi, somme des carres, | |
| nombre d'echantillons). C'est la resolution des graphes -- 10 ms par defaut, | |
| soit deux blocs par trame G.711 de 20 ms. | |
| Un bloc sans echantillon est un "trou" : trame perdue, flux en pause, ou | |
| charge utile non decodable. Les graphes le distinguent d'un vrai silence. | |
| """ | |
| def __init__(self, t_origine, clock=G711_CLOCK, bloc_ms=10.0, | |
| max_secondes=3600.0, garder_pcm=False): | |
| self.t_origine = t_origine # date de capture de la 1re trame | |
| self.clock = clock | |
| self.bloc = max(1, int(round(clock * bloc_ms / 1000.0))) | |
| self.bloc_ms = 1000.0 * self.bloc / clock | |
| self.max_blocs = max(1, int(max_secondes * clock / self.bloc)) | |
| self.bmin = array("h") | |
| self.bmax = array("h") | |
| self.bsq = array("d") | |
| self.bn = array("l") | |
| self.n_blocs = 0 | |
| self.pcm = array("h") if garder_pcm else None | |
| self.echantillons = 0 | |
| self.trames = 0 | |
| self.non_decodees = Counter() # par payload type | |
| self.hors_plage = 0 # offset negatif (desequencement initial) | |
| self.debordements = 0 # au-dela de --graphe-max-duree | |
| # -- alimentation ------------------------------------------------------ | |
| def _etendre(self, n_blocs): | |
| manque = n_blocs - self.n_blocs | |
| if manque <= 0: | |
| return | |
| self.bmin.extend([0] * manque) | |
| self.bmax.extend([0] * manque) | |
| self.bsq.extend([0.0] * manque) | |
| self.bn.extend([0] * manque) | |
| self.n_blocs = n_blocs | |
| def _placer_pcm(self, offset, ech): | |
| besoin = offset + len(ech) | |
| if len(self.pcm) < besoin: | |
| self.pcm.frombytes(bytes(2 * (besoin - len(self.pcm)))) # silence | |
| self.pcm[offset:besoin] = ech | |
| def ajouter(self, offset, payload, pt): | |
| """Decode une trame et l'inscrit a `offset` echantillons de l'origine.""" | |
| table = TABLES_G711.get(pt) | |
| if table is None: | |
| self.non_decodees[pt] += 1 | |
| return | |
| if not payload: | |
| return | |
| if offset < 0: | |
| self.hors_plage += 1 | |
| return | |
| fin = offset + len(payload) | |
| if (fin - 1) // self.bloc + 1 > self.max_blocs: | |
| self.debordements += 1 | |
| return | |
| ech = array("h") | |
| ech.frombytes(b"".join(map(table.__getitem__, payload))) | |
| self._etendre((fin - 1) // self.bloc + 1) | |
| self.trames += 1 | |
| self.echantillons += len(ech) | |
| if self.pcm is not None: | |
| self._placer_pcm(offset, ech) | |
| i, n = 0, len(ech) | |
| while i < n: | |
| b = (offset + i) // self.bloc | |
| j = min(n, (b + 1) * self.bloc - offset) | |
| seg = ech[i:j] | |
| mn, mx = min(seg), max(seg) | |
| if self.bn[b]: | |
| if mn < self.bmin[b]: | |
| self.bmin[b] = mn | |
| if mx > self.bmax[b]: | |
| self.bmax[b] = mx | |
| else: | |
| self.bmin[b] = mn | |
| self.bmax[b] = mx | |
| self.bsq[b] += sum(v * v for v in seg) | |
| self.bn[b] += j - i | |
| i = j | |
| # -- restitution ------------------------------------------------------- | |
| @property | |
| def vide(self): | |
| return self.echantillons == 0 | |
| @property | |
| def duree(self): | |
| return self.n_blocs * self.bloc / float(self.clock) | |
| @property | |
| def t_fin(self): | |
| return self.t_origine + self.duree | |
| def enveloppe(self, n_cols, t_debut, t_fin): | |
| """Reduit la piste a `n_cols` colonnes sur la fenetre [t_debut, t_fin]. | |
| Chaque colonne vaut None (hors du flux), False (dans le flux mais sans | |
| echantillon : trou) ou un triplet (mini, maxi, rms) normalise sur | |
| [-1, +1]. | |
| """ | |
| if t_fin <= t_debut or self.n_blocs == 0: | |
| return [None] * n_cols | |
| par_sec = float(self.clock) / self.bloc | |
| etendue = t_fin - t_debut | |
| cols = [] | |
| for c in range(n_cols): | |
| ta = t_debut + etendue * c / n_cols | |
| tb = t_debut + etendue * (c + 1) / n_cols | |
| b0 = int(math.floor((ta - self.t_origine) * par_sec)) | |
| b1 = int(math.ceil((tb - self.t_origine) * par_sec)) | |
| if b1 <= b0: | |
| b1 = b0 + 1 | |
| if b1 <= 0 or b0 >= self.n_blocs: | |
| cols.append(None) | |
| continue | |
| b0 = max(0, b0) | |
| b1 = min(self.n_blocs, b1) | |
| mn, mx, sq, n = 0, 0, 0.0, 0 | |
| for b in range(b0, b1): | |
| if not self.bn[b]: | |
| continue | |
| if n == 0: | |
| mn, mx = self.bmin[b], self.bmax[b] | |
| else: | |
| if self.bmin[b] < mn: | |
| mn = self.bmin[b] | |
| if self.bmax[b] > mx: | |
| mx = self.bmax[b] | |
| sq += self.bsq[b] | |
| n += self.bn[b] | |
| if n == 0: | |
| cols.append(False) # trou a l'interieur du flux | |
| else: | |
| cols.append((mn / PLEINE_ECHELLE, mx / PLEINE_ECHELLE, | |
| math.sqrt(sq / n) / PLEINE_ECHELLE)) | |
| return cols | |
| def niveaux_blocs(self): | |
| """Niveau RMS de chaque bloc porteur d'echantillons, en dBov.""" | |
| return [db_ov(math.sqrt(self.bsq[b] / self.bn[b])) | |
| for b in range(self.n_blocs) if self.bn[b]] | |
| def stats(self, seuil_silence_db=-50.0): | |
| """Indicateurs de niveau sonore du flux.""" | |
| if self.vide: | |
| return None | |
| sq = sum(self.bsq[b] for b in range(self.n_blocs) if self.bn[b]) | |
| crete = max(max(abs(self.bmin[b]), abs(self.bmax[b])) | |
| for b in range(self.n_blocs) if self.bn[b]) | |
| niveaux = sorted(self.niveaux_blocs()) | |
| actifs = [d for d in niveaux if d >= seuil_silence_db] | |
| rms_actif = None | |
| if actifs: | |
| energie = sum(10 ** (d / 10.0) for d in actifs) / len(actifs) | |
| rms_actif = 10.0 * math.log10(energie) | |
| def p(q): | |
| return niveaux[min(len(niveaux) - 1, int(q * (len(niveaux) - 1)))] | |
| return { | |
| "rms_db": db_ov(math.sqrt(sq / self.echantillons)), | |
| "rms_actif_db": rms_actif, | |
| "crete_db": db_ov(crete), | |
| "crete_lin": crete / PLEINE_ECHELLE, | |
| "bruit_db": p(0.10), | |
| "median_db": p(0.50), | |
| "p95_db": p(0.95), | |
| "plage_db": p(0.95) - p(0.10), | |
| "blocs": len(niveaux), | |
| "blocs_actifs": len(actifs), | |
| "silence_pct": 100.0 * (len(niveaux) - len(actifs)) / len(niveaux), | |
| "trous": self.n_blocs - len(niveaux), | |
| "trous_ms": (self.n_blocs - len(niveaux)) * self.bloc_ms, | |
| "duree": self.duree, | |
| "seuil_silence_db": seuil_silence_db, | |
| "sature": crete >= 32700, | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Graphes en mode texte (facon Audacity : forme d'onde + niveaux) | |
| # --------------------------------------------------------------------------- | |
| JEU_ASCII = {"crete": "|", "rms": "#", "zero": "-", "trou": "x", "vide": " "} | |
| JEU_UNICODE = {"crete": "▒", "rms": "█", "zero": "─", | |
| "trou": "×", "vide": " "} | |
| def _pas_joli(etendue, cibles=8): | |
| """Pas d'axe 1/2/5 x 10^n couvrant `etendue` en environ `cibles` graduations.""" | |
| if etendue <= 0: | |
| return 1.0 | |
| brut = etendue / float(cibles) | |
| exp = math.floor(math.log10(brut)) | |
| base = brut / (10.0 ** exp) | |
| for m in (1.0, 2.0, 5.0): | |
| if base <= m: | |
| return m * (10.0 ** exp) | |
| return 10.0 ** (exp + 1) | |
| def _fmt_t(t, pas): | |
| if pas >= 1: | |
| return "%.0f" % t | |
| if pas >= 0.1: | |
| return "%.1f" % t | |
| return "%.2f" % t | |
| def _axe_temps(largeur, t_debut, t_fin, marge, base=0.0): | |
| """Deux lignes de texte : graduations et etiquettes de l'axe des temps. | |
| Les dates sont affichees relativement a `base` (debut de capture). | |
| """ | |
| etendue = t_fin - t_debut | |
| pas = _pas_joli(etendue, max(4, largeur // 14)) | |
| ticks = [" "] * largeur | |
| lbl = [" "] * (largeur + 16) | |
| k = math.ceil((t_debut - base) / pas) | |
| while base + k * pas <= t_fin + 1e-9: | |
| t = base + k * pas | |
| x = int(round((t - t_debut) / etendue * (largeur - 1))) if etendue else 0 | |
| k += 1 | |
| if not 0 <= x < largeur: | |
| continue | |
| ticks[x] = "+" | |
| texte = _fmt_t(t - base, pas) | |
| deb = max(0, min(len(lbl) - len(texte) - 1, x - len(texte) // 2)) | |
| if all(lbl[deb + i] == " " for i in range(len(texte) + 1)): | |
| for i, ch in enumerate(texte): | |
| lbl[deb + i] = ch | |
| return (marge + "".join(ticks).replace(" ", "-"), | |
| marge + "".join(lbl).rstrip() + " s") | |
| def graphe_forme_onde(piste, largeur, hauteur, t_debut, t_fin, | |
| indent="", unicode_=False, gain=None, base=0.0): | |
| """Forme d'onde facon Audacity : enveloppe crete + coeur RMS, 0 au centre.""" | |
| jeu = JEU_UNICODE if unicode_ else JEU_ASCII | |
| if hauteur % 2 == 0: | |
| hauteur += 1 | |
| demi = hauteur // 2 | |
| cols = piste.enveloppe(largeur, t_debut, t_fin) | |
| pics = [max(abs(c[0]), abs(c[1])) for c in cols if c] | |
| if not pics: | |
| print(indent + "aucun echantillon audio dans cette fenetre.") | |
| return | |
| if gain is None: | |
| gain = 1.0 / max(pics) if max(pics) > 0 else 1.0 | |
| gain = min(gain, 512.0) | |
| grille = [[jeu["vide"]] * largeur for _ in range(hauteur)] | |
| for x, col in enumerate(cols): | |
| if col is None: | |
| continue | |
| if col is False: | |
| grille[demi][x] = jeu["trou"] | |
| continue | |
| mn, mx, rms = col[0] * gain, col[1] * gain, col[2] * gain | |
| r_haut = max(0, demi - int(round(min(1.0, max(0.0, mx)) * demi))) | |
| r_bas = min(hauteur - 1, demi + int(round(min(1.0, max(0.0, -mn)) * demi))) | |
| for r in range(r_haut, r_bas + 1): | |
| grille[r][x] = jeu["crete"] | |
| r_haut = max(0, demi - int(round(min(1.0, rms) * demi))) | |
| r_bas = min(hauteur - 1, demi + int(round(min(1.0, rms) * demi))) | |
| for r in range(r_haut, r_bas + 1): | |
| grille[r][x] = jeu["rms"] | |
| marge = indent + " " * 8 | |
| for r, ligne in enumerate(grille): | |
| if r == demi: | |
| etiq = "%7s " % "0" | |
| ligne = [jeu["zero"] if ch == jeu["vide"] else ch for ch in ligne] | |
| elif r == 0: | |
| etiq = "%7s " % ("%+.2f" % (1.0 / gain)) | |
| elif r == hauteur - 1: | |
| etiq = "%7s " % ("%+.2f" % (-1.0 / gain)) | |
| else: | |
| etiq = " " * 8 | |
| print(indent + etiq + "".join(ligne)) | |
| for ligne in _axe_temps(largeur, t_debut, t_fin, marge, base): | |
| print(ligne) | |
| print(marge + "echelle verticale : +/- %.3f de la pleine echelle (crete a %+.1f dBov)" | |
| % (1.0 / gain, db_ov(max(pics) * PLEINE_ECHELLE))) | |
| return gain | |
| def graphe_niveaux(piste, largeur, hauteur, t_debut, t_fin, plancher=-60.0, | |
| indent="", unicode_=False, base=0.0): | |
| """Niveau sonore en dBov au fil du temps : barres crete + barres RMS.""" | |
| jeu = JEU_UNICODE if unicode_ else JEU_ASCII | |
| cols = piste.enveloppe(largeur, t_debut, t_fin) | |
| if not any(c for c in cols): | |
| print(indent + "aucun echantillon audio dans cette fenetre.") | |
| return | |
| grille = [[jeu["vide"]] * largeur for _ in range(hauteur)] | |
| def ligne_de(db): | |
| """0 dBov -> rangee 0 (haut) ; plancher -> rangee hauteur-1 (bas).""" | |
| f = (0.0 - db) / (0.0 - plancher) | |
| return max(0, min(hauteur - 1, int(round(f * (hauteur - 1))))) | |
| for x, col in enumerate(cols): | |
| if col is None: | |
| continue | |
| if col is False: | |
| grille[hauteur - 1][x] = jeu["trou"] | |
| continue | |
| crete = db_ov(max(abs(col[0]), abs(col[1])) * PLEINE_ECHELLE) | |
| rms = db_ov(col[2] * PLEINE_ECHELLE) | |
| if crete > plancher: | |
| for r in range(ligne_de(crete), hauteur): | |
| grille[r][x] = jeu["crete"] | |
| if rms > plancher: | |
| for r in range(ligne_de(rms), hauteur): | |
| grille[r][x] = jeu["rms"] | |
| pas_db = _pas_joli(-plancher, min(6, hauteur - 1)) | |
| def fmt_db(d): | |
| return "0 dB" if not d else "%+.0f dB" % d | |
| etiquettes = {} | |
| db = 0.0 | |
| while db >= plancher: | |
| etiquettes[ligne_de(db)] = fmt_db(db) | |
| db -= pas_db | |
| etiquettes[hauteur - 1] = fmt_db(plancher) | |
| marge = indent + " " * 8 | |
| for r, ligne in enumerate(grille): | |
| etiq = "%7s " % etiquettes.get(r, "") | |
| print(indent + etiq + "".join(ligne)) | |
| for ligne in _axe_temps(largeur, t_debut, t_fin, marge, base): | |
| print(ligne) | |
| # --------------------------------------------------------------------------- | |
| # Graphe SVG (aucune dependance : le SVG est du texte) | |
| # --------------------------------------------------------------------------- | |
| # Palette : rampe bleue ordinale (crete clair / RMS fonce), rouge d'etat pour | |
| # les trous. Les deux modes clair et sombre sont choisis pour leur surface. | |
| SVG_STYLE = """ | |
| .fond{fill:#fcfcfb;} | |
| .piste{fill:#f4f4f1;} | |
| text{font-family:system-ui,-apple-system,"Segoe UI",sans-serif;fill:#52514e;} | |
| .titre{fill:#0b0b0b;font-size:17px;font-weight:600;} | |
| .sstitre{font-size:12px;} | |
| .flux{fill:#0b0b0b;font-size:13px;font-weight:600;} | |
| .note{fill:#898781;font-size:11px;} | |
| .tick{fill:#898781;font-size:10.5px;font-variant-numeric:tabular-nums;} | |
| .grille{stroke:#e1e0d9;stroke-width:1;fill:none;} | |
| .axe,.zero{stroke:#c3c2b7;stroke-width:1;fill:none;} | |
| .c-crete{fill:#86b6ef;} | |
| .c-rms{fill:#1c5cab;} | |
| .c-trou{fill:#d03b3b;opacity:.18;} | |
| .b-trou{fill:#d03b3b;} | |
| .survol{fill:#0b0b0b;fill-opacity:0;} | |
| .survol:hover{fill-opacity:.07;} | |
| @media (prefers-color-scheme: dark){ | |
| .fond{fill:#1a1a19;} | |
| .piste{fill:#0d0d0d;} | |
| text{fill:#c3c2b7;} | |
| .titre,.flux{fill:#ffffff;} | |
| .note,.tick{fill:#898781;} | |
| .grille{stroke:#2c2c2a;} | |
| .axe,.zero{stroke:#383835;} | |
| .c-crete{fill:#256abf;} | |
| .c-rms{fill:#86b6ef;} | |
| .c-trou{fill:#e66767;opacity:.22;} | |
| .b-trou{fill:#e66767;} | |
| .survol{fill:#ffffff;} | |
| } | |
| """ | |
| def _esc(s): | |
| return (str(s).replace("&", "&").replace("<", "<").replace(">", ">")) | |
| def _sous_chemins(points, ferme_a): | |
| """Chemin SVG : une sous-courbe par plage continue de colonnes renseignees. | |
| `points` : liste de (x, y) ou None. `ferme_a` : ordonnee de la ligne de base. | |
| """ | |
| morceaux, courant = [], [] | |
| for pt in list(points) + [None]: | |
| if pt is None: | |
| if len(courant) >= 1: | |
| d = ["M %.1f %.1f" % (courant[0][0], ferme_a)] | |
| d += ["L %.1f %.2f" % (x, y) for x, y in courant] | |
| d.append("L %.1f %.1f Z" % (courant[-1][0], ferme_a)) | |
| morceaux.append(" ".join(d)) | |
| courant = [] | |
| else: | |
| courant.append(pt) | |
| return " ".join(morceaux) | |
| def _enveloppe_chemin(cols, x0, dx, y_zero, demi, gain): | |
| """Polygone mini/maxi de la forme d'onde, une sous-courbe par plage continue.""" | |
| morceaux, courant = [], [] | |
| for i, col in enumerate(list(cols) + [None]): | |
| if not col: | |
| if len(courant) >= 1: | |
| haut = ["%.1f %.2f" % (x, yh) for x, yh, _ in courant] | |
| bas = ["%.1f %.2f" % (x, yb) for x, _, yb in reversed(courant)] | |
| morceaux.append("M " + " L ".join(haut) + " L " + " L ".join(bas) + " Z") | |
| courant = [] | |
| continue | |
| x = x0 + (i + 0.5) * dx | |
| mx = max(-1.0, min(1.0, col[1] * gain)) | |
| mn = max(-1.0, min(1.0, col[0] * gain)) | |
| yh = y_zero - mx * demi | |
| yb = y_zero - mn * demi | |
| if yb - yh < 0.7: # toujours visible | |
| yh, yb = (yh + yb) / 2 - 0.35, (yh + yb) / 2 + 0.35 | |
| courant.append((x, yh, yb)) | |
| return " ".join(morceaux) | |
| def _plages_trous(cols): | |
| """Regroupe les colonnes sans echantillon en plages (debut, fin_exclue).""" | |
| plages, debut = [], None | |
| for i, col in enumerate(list(cols) + [None]): | |
| manque = col is False | |
| if manque and debut is None: | |
| debut = i | |
| elif not manque and debut is not None: | |
| plages.append((debut, i)) | |
| debut = None | |
| return plages | |
| def svg_niveaux(r, chemin, largeur_px=1180, plancher=-60.0, max_colonnes=1200): | |
| """Ecrit un SVG : forme d'onde + niveau sonore (dBov) pour chaque flux.""" | |
| pistes = [(f, f.audio) for f in sorted(r["flux"].values(), key=lambda x: -x.n) | |
| if f.audio is not None and not f.audio.vide] | |
| if not pistes: | |
| return None | |
| ML, MR = 78, 26 | |
| H_ONDE, H_NIV, H_ETIQ, H_AXE, ECART = 118, 104, 24, 30, 26 | |
| largeur = max(320, largeur_px - ML - MR) | |
| n_cols = min(max_colonnes, largeur) | |
| dx = largeur / float(n_cols) | |
| t0 = min(p.t_origine for _, p in pistes) | |
| t1 = max(p.t_fin for _, p in pistes) | |
| if t1 <= t0: | |
| t1 = t0 + 1.0 | |
| base = r["premiere_cap"] or t0 | |
| etendue = t1 - t0 | |
| y = 34 | |
| corps = [] | |
| corps.append('<text class="titre" x="%d" y="%d">Niveaux sonores RTP / G.711</text>' | |
| % (ML, y)) | |
| y += 19 | |
| corps.append('<text class="sstitre" x="%d" y="%d">%s · %d flux · ' | |
| 'resolution %.0f ms · 0 dBov = pleine echelle PCM 16 bits</text>' | |
| % (ML, y, _esc(os.path.basename(r["fichier"])), len(pistes), | |
| pistes[0][1].bloc_ms)) | |
| y += 24 | |
| # Legende (2 series + l'etat "trou") : l'identite ne passe jamais par la | |
| # seule couleur, chaque pastille porte son libelle. | |
| lx = ML | |
| for classe, libelle in (("c-crete", "crete"), ("c-rms", "RMS"), | |
| ("b-trou", "trame manquante")): | |
| corps.append('<rect class="%s" x="%d" y="%d" width="11" height="11" rx="2"/>' | |
| % (classe, lx, y - 9)) | |
| corps.append('<text class="note" x="%d" y="%d">%s</text>' | |
| % (lx + 16, y, libelle)) | |
| lx += 22 + 7 * len(libelle) | |
| y += 18 | |
| pas_t = _pas_joli(etendue, max(4, largeur // 110)) | |
| for flux, piste in pistes: | |
| cols = piste.enveloppe(n_cols, t0, t1) | |
| pics = [max(abs(c[0]), abs(c[1])) for c in cols if c] | |
| gain = min(512.0, 1.0 / max(pics)) if pics and max(pics) > 0 else 1.0 | |
| trous = _plages_trous(cols) | |
| st = piste.stats() | |
| y += H_ETIQ | |
| corps.append('<text class="flux" x="%d" y="%d">%s</text>' | |
| % (ML, y - 8, _esc(flux.label))) | |
| corps.append('<text class="note" x="%d" y="%d" text-anchor="end">' | |
| 'RMS %+.1f dBov · crete %+.1f dBov · silence %.0f %% ' | |
| '· manquant %.0f ms</text>' | |
| % (ML + largeur, y - 8, st["rms_db"], st["crete_db"], | |
| st["silence_pct"], st["trous_ms"])) | |
| # --- piste 1 : forme d'onde (amplitude lineaire) --- | |
| yo = y | |
| y_zero = yo + H_ONDE / 2.0 | |
| demi = H_ONDE / 2.0 - 6 | |
| corps.append('<rect class="piste" x="%d" y="%.1f" width="%.1f" height="%d" rx="3"/>' | |
| % (ML, yo, largeur, H_ONDE)) | |
| for a, b in trous: | |
| corps.append('<rect class="c-trou" x="%.1f" y="%.1f" width="%.1f" height="%d"/>' | |
| % (ML + a * dx, yo, max(1.0, (b - a) * dx), H_ONDE)) | |
| corps.append('<path class="c-crete" d="%s"/>' | |
| % _enveloppe_chemin(cols, ML, dx, y_zero, demi, gain)) | |
| corps.append('<path class="c-rms" d="%s"/>' | |
| % _enveloppe_chemin([(-c[2], c[2], c[2]) if c else c for c in cols], | |
| ML, dx, y_zero, demi, gain)) | |
| corps.append('<line class="zero" x1="%d" y1="%.1f" x2="%.1f" y2="%.1f"/>' | |
| % (ML, y_zero, ML + largeur, y_zero)) | |
| amp = 1.0 / gain | |
| for val, yy in ((amp, y_zero - demi), (0.0, y_zero), (-amp, y_zero + demi)): | |
| corps.append('<text class="tick" x="%d" y="%.1f" text-anchor="end">%s</text>' | |
| % (ML - 8, yy + 3.5, "%+.2f" % val if val else "0")) | |
| y += H_ONDE + 20 | |
| # --- piste 2 : niveau en dBov --- | |
| yn = y | |
| corps.append('<rect class="piste" x="%d" y="%.1f" width="%.1f" height="%d" rx="3"/>' | |
| % (ML, yn, largeur, H_NIV)) | |
| def ydb(d, _yn=yn): | |
| return _yn + H_NIV * (0.0 - max(plancher, min(0.0, d))) / (0.0 - plancher) | |
| pas_db = _pas_joli(-plancher, 4) | |
| d = 0.0 | |
| while d >= plancher - 1e-9: | |
| corps.append('<line class="grille" x1="%d" y1="%.1f" x2="%.1f" y2="%.1f"/>' | |
| % (ML, ydb(d), ML + largeur, ydb(d))) | |
| corps.append('<text class="tick" x="%d" y="%.1f" text-anchor="end">' | |
| '%s dB</text>' | |
| % (ML - 8, ydb(d) + 3.5, "0" if not d else "%+.0f" % d)) | |
| d -= pas_db | |
| for a, b in trous: | |
| corps.append('<rect class="c-trou" x="%.1f" y="%.1f" width="%.1f" height="%d"/>' | |
| % (ML + a * dx, yn, max(1.0, (b - a) * dx), H_NIV)) | |
| pc = [(ML + (i + 0.5) * dx, | |
| ydb(db_ov(max(abs(c[0]), abs(c[1])) * PLEINE_ECHELLE))) if c else None | |
| for i, c in enumerate(cols)] | |
| pr = [(ML + (i + 0.5) * dx, ydb(db_ov(c[2] * PLEINE_ECHELLE))) if c else None | |
| for i, c in enumerate(cols)] | |
| corps.append('<path class="c-crete" d="%s"/>' % _sous_chemins(pc, yn + H_NIV)) | |
| corps.append('<path class="c-rms" d="%s"/>' % _sous_chemins(pr, yn + H_NIV)) | |
| y += H_NIV | |
| # --- axe des temps, commun aux deux pistes --- | |
| corps.append('<line class="axe" x1="%d" y1="%.1f" x2="%.1f" y2="%.1f"/>' | |
| % (ML, y, ML + largeur, y)) | |
| k = math.ceil((t0 - base) / pas_t) | |
| while base + k * pas_t <= t1 + 1e-9: | |
| t = base + k * pas_t | |
| k += 1 | |
| x = ML + (t - t0) / etendue * largeur | |
| corps.append('<line class="axe" x1="%.1f" y1="%.1f" x2="%.1f" y2="%.1f"/>' | |
| % (x, y, x, y + 4)) | |
| corps.append('<text class="tick" x="%.1f" y="%.1f" text-anchor="middle">%s</text>' | |
| % (x, y + 16, _fmt_t(t - base, pas_t))) | |
| corps.append('<text class="note" x="%.1f" y="%.1f">s</text>' | |
| % (ML + largeur + 6, y + 16)) | |
| corps.append('<text class="note" x="%d" y="%.1f">echelle verticale : ' | |
| '+/- %.3f de la pleine echelle</text>' % (ML, y + 27, amp)) | |
| # --- couche de survol : infobulles natives, par tranche de temps --- | |
| n_tr = min(300, n_cols) | |
| pas_tr = n_cols / float(n_tr) | |
| for k in range(n_tr): | |
| a, b = int(k * pas_tr), max(int(k * pas_tr) + 1, int((k + 1) * pas_tr)) | |
| lot = [c for c in cols[a:b] if c] | |
| if not lot: | |
| continue | |
| crete = max(max(abs(c[0]), abs(c[1])) for c in lot) | |
| rms = math.sqrt(sum(c[2] ** 2 for c in lot) / len(lot)) | |
| corps.append('<rect class="survol" x="%.1f" y="%.1f" width="%.2f" ' | |
| 'height="%.1f"><title>t = %.2f a %.2f s · ' | |
| 'crete %+.1f dBov · RMS %+.1f dBov</title></rect>' | |
| % (ML + a * dx, yo, (b - a) * dx, y - yo, | |
| t0 + etendue * a / n_cols - base, | |
| t0 + etendue * b / n_cols - base, | |
| db_ov(crete * PLEINE_ECHELLE), | |
| db_ov(rms * PLEINE_ECHELLE))) | |
| y += H_AXE + ECART | |
| hauteur = int(y + 8) | |
| out = ['<?xml version="1.0" encoding="UTF-8"?>', | |
| '<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" ' | |
| 'viewBox="0 0 %d %d" role="img" aria-label="Niveaux sonores des flux RTP">' | |
| % (largeur_px, hauteur, largeur_px, hauteur), | |
| "<style>%s</style>" % SVG_STYLE, | |
| '<rect class="fond" width="100%" height="100%"/>'] | |
| out += corps | |
| out.append("</svg>") | |
| with open(chemin, "w") as fh: | |
| fh.write("\n".join(out) + "\n") | |
| return chemin | |
| # --------------------------------------------------------------------------- | |
| # Export WAV (pour ouvrir la piste reconstruite dans Audacity) | |
| # --------------------------------------------------------------------------- | |
| def exporter_wav(r, prefixe): | |
| """Ecrit un WAV PCM 16 bits mono par flux. | |
| La piste est celle de l'echelle de temps RTP : les trames manquantes y | |
| sont comblees par du silence, pour que la duree du WAV corresponde a la | |
| duree du flux. Renvoie [(nom, duree, part de silence ajoute)]. | |
| """ | |
| ecrits = [] | |
| for f in sorted(r["flux"].values(), key=lambda x: -x.n): | |
| piste = f.audio | |
| if piste is None or piste.pcm is None or not len(piste.pcm): | |
| continue | |
| ech = piste.pcm | |
| if sys.byteorder == "big": | |
| ech = array("h", ech) | |
| ech.byteswap() | |
| nom = "%s-%08X-%s_%d-%s_%d.wav" % ( | |
| prefixe, f.ssrc, f.src.replace(":", "_"), f.sport, | |
| f.dst.replace(":", "_"), f.dport) | |
| with contextlib.closing(wave.open(nom, "wb")) as w: | |
| w.setnchannels(1) | |
| w.setsampwidth(2) | |
| w.setframerate(piste.clock) | |
| w.writeframes(ech.tobytes()) | |
| comble = max(0, len(ech) - piste.echantillons) / float(piste.clock) | |
| ecrits.append((nom, len(ech) / float(piste.clock), comble)) | |
| return ecrits | |
| # --------------------------------------------------------------------------- | |
| # Statistiques | |
| # --------------------------------------------------------------------------- | |
| def mean(values): | |
| return sum(values) / len(values) if values else 0.0 | |
| def median(values): | |
| if not values: | |
| return 0.0 | |
| s = sorted(values) | |
| n = len(s) | |
| return s[n // 2] if n % 2 else (s[n // 2 - 1] + s[n // 2]) / 2 | |
| def stdev(values): | |
| if len(values) < 2: | |
| return 0.0 | |
| m = mean(values) | |
| return (sum((v - m) ** 2 for v in values) / (len(values) - 1)) ** 0.5 | |
| def group_bursts(deltas_ms, seuil_ms): | |
| """Regroupe des trames consecutives separees de moins de `seuil_ms`. | |
| `deltas_ms` contient les n-1 ecarts entre n trames consecutives. | |
| Renvoie la liste des tailles de groupes (en nombre de trames). | |
| """ | |
| if not deltas_ms: | |
| return [] | |
| groupes, courant = [], 1 | |
| for d in deltas_ms: | |
| if d < seuil_ms: | |
| courant += 1 | |
| else: | |
| groupes.append(courant) | |
| courant = 1 | |
| groupes.append(courant) | |
| return groupes | |
| class Flux: | |
| """Un flux RTP = un SSRC vu sur un 5-uplet IP/UDP donne.""" | |
| def __init__(self, key, opts_audio=None): | |
| self.src, self.sport, self.dst, self.dport, self.ssrc = key | |
| self.opts_audio = opts_audio | |
| self.audio = None # Piste, creee a la premiere trame | |
| self.n = 0 | |
| self.first_cap = None | |
| self.last_cap = None | |
| self.deltas = [] # ecarts d'arrivee (ms) | |
| self.pts = Counter() | |
| self.plens = Counter() | |
| self.markers = 0 | |
| # sequence | |
| self.prev_seq = None | |
| self.seq_cycles = 0 | |
| self.first_seq_ext = None | |
| self.last_seq_ext = None | |
| self.seen_seq = set() | |
| self.seen16 = set() | |
| self.dupes = 0 | |
| self.reorders = 0 | |
| self.gaps = [] # (seq_precedent, seq_recu, nb_perdus, ts_capture) | |
| self.wraps_seq = 0 | |
| # horodatage RTP | |
| self.prev_ts = None | |
| self.ts_cycles = 0 | |
| self.first_ts_ext = None | |
| self.last_ts_ext = None | |
| self.ts_decroissants = [] # (ts_precedent, ts_recu, ts_capture) | |
| self.ts_egaux = 0 | |
| self.ts_deltas = Counter() | |
| self.wraps_ts = 0 | |
| @property | |
| def label(self): | |
| return "SSRC 0x%08X %s:%d -> %s:%d" % ( | |
| self.ssrc, self.src, self.sport, self.dst, self.dport) | |
| def add(self, t, rtp): | |
| self.n += 1 | |
| if self.first_cap is None: | |
| self.first_cap = t | |
| else: | |
| self.deltas.append((t - self.last_cap) * 1000.0) | |
| self.last_cap = t | |
| self.pts[rtp["pt"]] += 1 | |
| self.plens[rtp["plen"]] += 1 | |
| self.markers += rtp["marker"] | |
| # --- numero de sequence (16 bits, avec rebouclage) --- | |
| seq = rtp["seq"] | |
| if self.prev_seq is None: | |
| self.first_seq_ext = self.last_seq_ext = seq | |
| else: | |
| diff = (seq - self.prev_seq) & 0xFFFF | |
| if diff == 0: | |
| self.dupes += 1 | |
| elif diff < 0x8000: # en avant | |
| if seq < self.prev_seq: | |
| self.seq_cycles += 1 | |
| self.wraps_seq += 1 | |
| if diff > 1: | |
| self.gaps.append((self.prev_seq, seq, diff - 1, self.last_cap)) | |
| else: # en arriere : desequencement | |
| self.reorders += 1 | |
| seq_ext = (self.seq_cycles << 16) | seq | |
| if self.last_seq_ext is None or seq_ext > self.last_seq_ext: | |
| self.last_seq_ext = seq_ext | |
| if seq_ext in self.seen_seq: | |
| pass | |
| self.seen_seq.add(seq_ext) | |
| self.seen16.add(seq) | |
| self.prev_seq = seq | |
| # --- horodatage RTP (32 bits, avec rebouclage) --- | |
| ts = rtp["ts"] | |
| if self.prev_ts is None: | |
| self.first_ts_ext = self.last_ts_ext = ts | |
| else: | |
| diff = (ts - self.prev_ts) & 0xFFFFFFFF | |
| if diff == 0: | |
| self.ts_egaux += 1 | |
| elif diff < 0x80000000: # croissant | |
| if ts < self.prev_ts: | |
| self.ts_cycles += 1 | |
| self.wraps_ts += 1 | |
| self.ts_deltas[diff] += 1 | |
| else: | |
| self.ts_decroissants.append((self.prev_ts, ts, self.last_cap)) | |
| ts_ext = (self.ts_cycles << 32) | ts | |
| if self.last_ts_ext is None or ts_ext > self.last_ts_ext: | |
| self.last_ts_ext = ts_ext | |
| self.prev_ts = ts | |
| # --- signal audio, replace sur l'echelle de temps RTP --- | |
| o = self.opts_audio | |
| if o is not None and o.actif: | |
| if self.audio is None: | |
| self.audio = Piste(t, G711_CLOCK, o.bloc_ms, o.max_secondes, o.pcm) | |
| self.audio.ajouter(ts_ext - self.first_ts_ext, | |
| rtp.get("payload", b""), rtp["pt"]) | |
| # --- indicateurs derives --- | |
| @property | |
| def duree_capture(self): | |
| if self.first_cap is None or self.n < 2: | |
| return 0.0 | |
| return self.last_cap - self.first_cap | |
| @property | |
| def duree_rtp(self): | |
| if self.first_ts_ext is None or self.n < 2: | |
| return 0.0 | |
| return (self.last_ts_ext - self.first_ts_ext) / float(G711_CLOCK) | |
| def trous_reels(self): | |
| """Pour chaque discontinuite : (prev, seq, manquants, recus_plus_tard, t).""" | |
| out = [] | |
| for prev, seq, nb, t in self.gaps: | |
| tardifs = sum(1 for k in range(1, nb + 1) if ((prev + k) & 0xFFFF) in self.seen16) | |
| out.append((prev, seq, nb, tardifs, t)) | |
| return out | |
| @property | |
| def attendus(self): | |
| """Nombre de trames attendues d'apres l'etendue des seqnum.""" | |
| if self.first_seq_ext is None: | |
| return 0 | |
| return self.last_seq_ext - self.first_seq_ext + 1 | |
| @property | |
| def perdus(self): | |
| return max(0, self.attendus - len(self.seen_seq)) | |
| # --------------------------------------------------------------------------- | |
| # Analyse | |
| # --------------------------------------------------------------------------- | |
| DEFAUTS_GRAPHE = { | |
| "actif": True, "largeur": 100, "hauteur": 17, "hauteur_niveaux": 12, | |
| "plancher": DEFAULT_PLANCHER_DB, "seuil_silence": DEFAULT_SILENCE_DB, | |
| "unicode": False, | |
| } | |
| def analyse(path, seuil_ms, limite, opts_audio=None, graphe=None): | |
| it, fmt = iter_capture(path) | |
| if opts_audio is None: | |
| opts_audio = OptionsAudio() | |
| total = 0 | |
| linktypes = Counter() | |
| non_ip = Counter() | |
| non_udp = Counter() | |
| udp_total = 0 | |
| rtp_ok = 0 | |
| rejets = Counter() | |
| exemples_rejets = [] | |
| trames = [] # (ts_capture, cle_flux, rtp) pour l'analyse globale | |
| flux = {} | |
| premiere_cap = derniere_cap = None | |
| for ts, linktype, data, _origlen in it: | |
| total += 1 | |
| linktypes[linktype] += 1 | |
| if premiere_cap is None: | |
| premiere_cap = ts | |
| derniere_cap = ts | |
| ip_payload, info = decode_link(linktype, data) | |
| if ip_payload is None: | |
| non_ip[info] += 1 | |
| continue | |
| res, raison = decode_ip_udp(ip_payload, info) | |
| if res is None: | |
| non_udp[raison] += 1 | |
| continue | |
| src, dst, sport, dport, udp_data = res | |
| udp_total += 1 | |
| rtp, raison = parse_rtp(udp_data) | |
| if rtp is None: | |
| rejets[raison] += 1 | |
| if len(exemples_rejets) < limite: | |
| exemples_rejets.append((total, ts, "%s:%d -> %s:%d" % (src, sport, dst, dport), | |
| len(udp_data), raison)) | |
| continue | |
| rtp_ok += 1 | |
| key = (src, sport, dst, dport, rtp["ssrc"]) | |
| f = flux.get(key) | |
| if f is None: | |
| f = flux[key] = Flux(key, opts_audio) | |
| f.add(ts, rtp) | |
| trames.append((ts, key, rtp)) | |
| return { | |
| "fichier": path, "format": fmt, "total": total, "linktypes": linktypes, | |
| "non_ip": non_ip, "non_udp": non_udp, "udp_total": udp_total, | |
| "rtp_ok": rtp_ok, "rejets": rejets, "exemples_rejets": exemples_rejets, | |
| "flux": flux, "trames": trames, | |
| "premiere_cap": premiere_cap, "derniere_cap": derniere_cap, | |
| "seuil_ms": seuil_ms, "limite": limite, | |
| "audio": opts_audio, "graphe": dict(DEFAUTS_GRAPHE, **(graphe or {})), | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Restitution | |
| # --------------------------------------------------------------------------- | |
| def titre(s): | |
| print() | |
| print("=" * 78) | |
| print(s) | |
| print("=" * 78) | |
| def fmt_duree(s): | |
| if s < 1: | |
| return "%.3f s (%.1f ms)" % (s, s * 1000) | |
| m, sec = divmod(s, 60) | |
| if m < 1: | |
| return "%.3f s" % s | |
| return "%.3f s (%dm %.3fs)" % (s, int(m), sec) | |
| def stats_ecarts(deltas, seuil_ms, indent=""): | |
| if not deltas: | |
| print(indent + "moins de 2 trames : pas d'ecart mesurable.") | |
| return | |
| print(indent + "ecart minimal : %9.3f ms" % min(deltas)) | |
| print(indent + "ecart maximal : %9.3f ms" % max(deltas)) | |
| print(indent + "ecart moyen : %9.3f ms" % mean(deltas)) | |
| print(indent + "ecart median : %9.3f ms" % median(deltas)) | |
| print(indent + "ecart type : %9.3f ms" % stdev(deltas)) | |
| sous = [d for d in deltas if d < seuil_ms] | |
| print(indent + "ecarts < %g ms : %d / %d (%.1f %%)" % ( | |
| seuil_ms, len(sous), len(deltas), 100.0 * len(sous) / len(deltas))) | |
| def rapport_bursts(deltas, seuil_ms, indent=""): | |
| groupes = group_bursts(deltas, seuil_ms) | |
| if not groupes: | |
| print(indent + "pas assez de trames.") | |
| return | |
| paquets = [g for g in groupes if g >= 2] | |
| isolees = sum(1 for g in groupes if g == 1) | |
| print(indent + "groupes au total : %d (dont %d trames isolees)" % (len(groupes), isolees)) | |
| print(indent + "\"paquets\" (>= 2 trames) : %d" % len(paquets)) | |
| if paquets: | |
| print(indent + " trames par paquet mini : %d" % min(paquets)) | |
| print(indent + " trames par paquet maxi : %d" % max(paquets)) | |
| print(indent + " trames par paquet moyen : %.2f" % mean(paquets)) | |
| print(indent + " trames par paquet median: %.1f" % median(paquets)) | |
| print(indent + " trames regroupees : %d / %d (%.1f %%)" % ( | |
| sum(paquets), sum(groupes), 100.0 * sum(paquets) / sum(groupes))) | |
| else: | |
| print(indent + " aucun groupe de 2 trames ou plus sous le seuil.") | |
| def afficher(r): | |
| seuil = r["seuil_ms"] | |
| limite = r["limite"] | |
| flux = r["flux"] | |
| titre("FICHIER") | |
| print("chemin : %s" % r["fichier"]) | |
| print("format : %s" % r["format"]) | |
| print("taille : %d octets" % os.path.getsize(r["fichier"])) | |
| couches = ", ".join("%s (%d paquets)" % (LINKTYPE_NAMES.get(lt, "linktype %d" % lt), n) | |
| for lt, n in r["linktypes"].most_common()) | |
| print("couches liaison : %s" % (couches or "-")) | |
| titre("PAQUETS") | |
| print("paquets captures : %d" % r["total"]) | |
| print("paquets UDP : %d" % r["udp_total"]) | |
| print("trames RTP valides : %d" % r["rtp_ok"]) | |
| print("paquets UDP ignores : %d" % sum(r["rejets"].values())) | |
| print("paquets non UDP/non IP : %d" % (sum(r["non_ip"].values()) + sum(r["non_udp"].values()))) | |
| if r["non_ip"] or r["non_udp"]: | |
| print("\n Paquets ecartes avant UDP :") | |
| for raison, n in (r["non_ip"] + r["non_udp"]).most_common(): | |
| print(" %-55s %6d" % (raison, n)) | |
| if r["rejets"]: | |
| print("\n /!\\ Paquets UDP qui ne sont PAS des trames RTP valides :") | |
| for raison, n in r["rejets"].most_common(): | |
| print(" %-55s %6d" % (raison, n)) | |
| print("\n Details (%d premiers) :" % min(limite, len(r["exemples_rejets"]))) | |
| print(" %-8s %-14s %-42s %7s %s" % ("paquet", "t (s)", "flux", "udp", "raison")) | |
| t0 = r["premiere_cap"] or 0 | |
| for idx, ts, flow, ln, raison in r["exemples_rejets"]: | |
| print(" #%-7d %-14.6f %-42s %7d %s" % (idx, ts - t0, flow, ln, raison)) | |
| else: | |
| print("\n Tous les paquets UDP portent une entete RTP valide.") | |
| if not flux: | |
| titre("RESULTAT") | |
| print("Aucune trame RTP exploitable dans cette capture.") | |
| return | |
| # ---------------- Flux ---------------- | |
| titre("FLUX RTP DETECTES (%d)" % len(flux)) | |
| print("%-12s %-21s %-21s %8s %10s %9s" % ("SSRC", "source", "destination", "trames", "PT", "duree(s)")) | |
| for f in sorted(flux.values(), key=lambda x: -x.n): | |
| pt, _ = f.pts.most_common(1)[0] | |
| pt_lbl = "%d%s" % (pt, "" if pt in G711_PT else "*") | |
| print("0x%08X %-21s %-21s %8d %10s %9.3f" % ( | |
| f.ssrc, "%s:%d" % (f.src, f.sport), "%s:%d" % (f.dst, f.dport), | |
| f.n, pt_lbl, f.duree_capture)) | |
| hors_g711 = sorted({pt for f in flux.values() for pt in f.pts if pt not in G711_PT}) | |
| if hors_g711: | |
| print("\n /!\\ (*) types de charge utile non G.711 rencontres : %s" % | |
| ", ".join(str(p) for p in hors_g711)) | |
| print(" l'horloge RTP de %d Hz (G.711) est tout de meme appliquee." % G711_CLOCK) | |
| # ---------------- Nombre de trames ---------------- | |
| titre("NOMBRE DE TRAMES") | |
| print("trames RTP totales : %d" % r["rtp_ok"]) | |
| for f in sorted(flux.values(), key=lambda x: -x.n): | |
| pt, _ = f.pts.most_common(1)[0] | |
| taille, n_taille = f.plens.most_common(1)[0] | |
| print(" %-46s %7d trames | PT %-3d %-22s | charge utile dominante %d o (%.1f ms) x%d" % ( | |
| f.label, f.n, pt, G711_PT.get(pt, "non G.711"), taille, | |
| 1000.0 * taille / G711_CLOCK, n_taille)) | |
| # ---------------- Espacement temporel ---------------- | |
| titre("ESPACEMENT TEMPOREL (horodatage de capture)") | |
| globaux = [] | |
| trames = r["trames"] | |
| for i in range(1, len(trames)): | |
| globaux.append((trames[i][0] - trames[i - 1][0]) * 1000.0) | |
| if len(flux) > 1: | |
| print("Toutes trames RTP confondues, flux entrelaces (%d ecarts) :" % len(globaux)) | |
| stats_ecarts(globaux, seuil, " ") | |
| for f in sorted(flux.values(), key=lambda x: -x.n): | |
| print("\n%s (%d ecarts) :" % (f.label, len(f.deltas))) | |
| stats_ecarts(f.deltas, seuil, " ") | |
| else: | |
| stats_ecarts(globaux, seuil, " ") | |
| # ---------------- Bursts ---------------- | |
| titre("REGROUPEMENT EN \"PAQUETS\" DE TRAMES (ecart < %g ms)" % seuil) | |
| print("Un \"paquet\" est une suite maximale de trames consecutives dont chaque") | |
| print("ecart avec la precedente est strictement inferieur a %g ms.\n" % seuil) | |
| print("Toutes trames RTP confondues :") | |
| rapport_bursts(globaux, seuil, " ") | |
| if len(flux) > 1: | |
| for f in sorted(flux.values(), key=lambda x: -x.n): | |
| print("\n%s :" % f.label) | |
| rapport_bursts(f.deltas, seuil, " ") | |
| # ---------------- Sequence ---------------- | |
| titre("CONTINUITE DES NUMEROS DE SEQUENCE (par SSRC)") | |
| for f in sorted(flux.values(), key=lambda x: -x.n): | |
| t0 = r["premiere_cap"] or 0 | |
| ok = not f.gaps and not f.dupes and not f.reorders and f.perdus == 0 | |
| print("\n%s" % f.label) | |
| print(" seq premiere/derniere : %d -> %d (etendue %d, rebouclages 16 bits : %d)" % ( | |
| f.first_seq_ext & 0xFFFF, f.last_seq_ext & 0xFFFF, f.attendus, f.wraps_seq)) | |
| print(" trames recues : %d attendues : %d manquantes : %d (%.2f %%)" % ( | |
| f.n, f.attendus, f.perdus, | |
| 100.0 * f.perdus / f.attendus if f.attendus else 0.0)) | |
| print(" doublons : %d" % f.dupes) | |
| print(" desequencements : %d" % f.reorders) | |
| print(" discontinuites : %d" % len(f.gaps)) | |
| if ok: | |
| print(" => OK : sequence continue, sans trou ni doublon ni desequencement.") | |
| else: | |
| print(" => ANOMALIE(S) detectee(s).") | |
| for prev, seq, nb, tardifs, ts in f.trous_reels()[:limite]: | |
| suite = "" | |
| if tardifs: | |
| suite = " dont %d recue(s) plus tard (desequencement)" % tardifs | |
| print(" t=%10.6f s : saut %d -> %d (%d trame(s) manquante(s)%s)" % ( | |
| ts - t0, prev, seq, nb, suite)) | |
| if len(f.gaps) > limite: | |
| print(" ... %d discontinuite(s) supplementaire(s)" % (len(f.gaps) - limite)) | |
| # ---------------- Timestamps ---------------- | |
| titre("CROISSANCE DES HORODATAGES RTP (par SSRC)") | |
| for f in sorted(flux.values(), key=lambda x: -x.n): | |
| t0 = r["premiere_cap"] or 0 | |
| print("\n%s" % f.label) | |
| print(" timestamp premier/dernier : %u -> %u (rebouclages 32 bits : %d)" % ( | |
| f.first_ts_ext & 0xFFFFFFFF, f.last_ts_ext & 0xFFFFFFFF, f.wraps_ts)) | |
| print(" progressions decroissantes: %d" % len(f.ts_decroissants)) | |
| print(" timestamps identiques : %d" % f.ts_egaux) | |
| if f.ts_deltas: | |
| communs = ", ".join("%d (x%d, %.1f ms)" % (d, n, 1000.0 * d / G711_CLOCK) | |
| for d, n in f.ts_deltas.most_common(5)) | |
| print(" increments les plus vus : %s" % communs) | |
| if not f.ts_decroissants and not f.ts_egaux: | |
| print(" => OK : horodatage RTP strictement croissant.") | |
| elif not f.ts_decroissants: | |
| print(" => OK : horodatage croissant au sens large (%d repetitions)." % f.ts_egaux) | |
| else: | |
| print(" => ANOMALIE : horodatage non croissant.") | |
| for prev, cur, ts in f.ts_decroissants[:limite]: | |
| print(" t=%10.6f s : %u -> %u (recul de %u)" % ( | |
| ts - t0, prev, cur, (prev - cur) & 0xFFFFFFFF)) | |
| if len(f.ts_decroissants) > limite: | |
| print(" ... %d cas supplementaire(s)" % (len(f.ts_decroissants) - limite)) | |
| # ---------------- Durees ---------------- | |
| titre("DUREES") | |
| d_cap_tot = (r["derniere_cap"] - r["premiere_cap"]) if r["total"] > 1 else 0.0 | |
| print("Capture complete (tous paquets) : %s" % fmt_duree(d_cap_tot)) | |
| if trames: | |
| d_rtp_cap = trames[-1][0] - trames[0][0] | |
| print("Premiere -> derniere trame RTP : %s" % fmt_duree(d_rtp_cap)) | |
| print() | |
| print("%-46s %14s %14s %10s" % ("flux", "duree capture", "duree RTP", "ecart")) | |
| for f in sorted(flux.values(), key=lambda x: -x.n): | |
| dc, dr = f.duree_capture, f.duree_rtp | |
| print("%-46s %11.3f s %11.3f s %8.1f ms" % (f.label, dc, dr, (dc - dr) * 1000.0)) | |
| print() | |
| print("Rappel : la duree RTP est calculee sur l'horloge G.711 de %d Hz," % G711_CLOCK) | |
| print("soit (dernier timestamp - premier timestamp) / %d, hors duree de la" % G711_CLOCK) | |
| print("derniere trame. L'ecart capture/RTP traduit la gigue et les pertes.") | |
| for f in sorted(flux.values(), key=lambda x: -x.n): | |
| taille, _ = f.plens.most_common(1)[0] | |
| if taille: | |
| audio = f.n * taille / float(G711_CLOCK) | |
| print(" %s : %d trames x %d o = %.3f s d'audio effectivement capture." % ( | |
| f.label, f.n, taille, audio)) | |
| # ---------------- Niveaux sonores ---------------- | |
| if r["audio"].actif: | |
| rapport_niveaux(r) | |
| def rapport_niveaux(r): | |
| """Rubrique "niveaux sonores" : chiffres, puis graphes facon Audacity.""" | |
| g = r["graphe"] | |
| flux = sorted(r["flux"].values(), key=lambda x: -x.n) | |
| pistes = [(f, f.audio) for f in flux if f.audio is not None and not f.audio.vide] | |
| titre("NIVEAUX SONORES (charge utile G.711 decodee)") | |
| if not pistes: | |
| print("Aucune charge utile G.711 decodable (PT 0 / PT 8) : pas de graphe.") | |
| pts = sorted({pt for f in flux if f.audio for pt in f.audio.non_decodees}) | |
| if pts: | |
| print("Types de charge utile rencontres et non decodes : %s" | |
| % ", ".join(str(p) for p in pts)) | |
| return | |
| print("Le signal est reconstitue sur l'echelle de temps de l'horodatage RTP :") | |
| print("chaque trame est replacee a sa position theorique, un trou de sequence") | |
| print("laisse donc un trou dans la piste (il n'est pas comble par du silence).") | |
| print("Reference : 0 dBov = pleine echelle PCM 16 bits. Resolution : %.0f ms." | |
| % pistes[0][1].bloc_ms) | |
| print("Seuil de silence retenu : %g dBov.\n" % g["seuil_silence"]) | |
| print("%-46s %9s %9s %9s %9s %8s %9s" % ( | |
| "flux", "RMS", "RMS actif", "crete", "bruit p10", "silence", "manquant")) | |
| for f, piste in pistes: | |
| st = piste.stats(g["seuil_silence"]) | |
| print("%-46s %8.1f%s %8s%s %8.1f%s %8.1f%s %7.1f%% %7.0f ms" % ( | |
| f.label, st["rms_db"], "dB", | |
| ("%.1f" % st["rms_actif_db"]) if st["rms_actif_db"] is not None else "-", | |
| "dB", st["crete_db"], "dB", st["bruit_db"], "dB", | |
| st["silence_pct"], st["trous_ms"])) | |
| print() | |
| for f, piste in pistes: | |
| st = piste.stats(g["seuil_silence"]) | |
| print("%s" % f.label) | |
| print(" duree de piste : %.3f s (%d blocs de %.0f ms, %d sans echantillon)" | |
| % (st["duree"], piste.n_blocs, piste.bloc_ms, st["trous"])) | |
| print(" trames decodees : %d (%d echantillons, %.3f s d'audio)" | |
| % (piste.trames, piste.echantillons, | |
| piste.echantillons / float(piste.clock))) | |
| print(" niveau median / p95 : %+.1f dBov / %+.1f dBov (plage utile %.1f dB)" | |
| % (st["median_db"], st["p95_db"], st["plage_db"])) | |
| print(" crete : %+.1f dBov (%.3f de la pleine echelle)%s" | |
| % (st["crete_db"], st["crete_lin"], | |
| " /!\\ SATURATION" if st["sature"] else "")) | |
| if piste.non_decodees: | |
| print(" trames non decodees : %s" % ", ".join( | |
| "PT %d x%d" % (pt, n) for pt, n in piste.non_decodees.most_common())) | |
| if piste.hors_plage: | |
| print(" trames hors piste : %d (anterieures au premier horodatage)" | |
| % piste.hors_plage) | |
| if piste.debordements: | |
| print(" trames ignorees : %d (au-dela de --graphe-max-duree)" | |
| % piste.debordements) | |
| if st["crete_db"] <= g["seuil_silence"]: | |
| print(" => flux silencieux sur toute sa duree.") | |
| elif st["silence_pct"] >= 90: | |
| print(" => quasi silencieux : %.1f %% des blocs sous %g dBov." | |
| % (st["silence_pct"], g["seuil_silence"])) | |
| elif st["sature"]: | |
| print(" => ANOMALIE : le signal atteint la pleine echelle (ecretage).") | |
| else: | |
| print(" => niveau nominal.") | |
| print() | |
| if g["actif"]: | |
| for f, piste in pistes: | |
| t0, t1 = piste.t_origine, piste.t_fin | |
| base = r["premiere_cap"] or t0 | |
| titre("FORME D'ONDE ET NIVEAUX -- %s" % f.label) | |
| print("fenetre : t = %.3f s -> %.3f s (origine = debut de capture)\n" | |
| % (t0 - base, t1 - base)) | |
| print("Forme d'onde (amplitude, '%s' = crete, '%s' = RMS, '%s' = trame manquante) :" | |
| % (("▒", "█", "×") if g["unicode"] else ("|", "#", "x"))) | |
| graphe_forme_onde(piste, g["largeur"], g["hauteur"], t0, t1, | |
| unicode_=g["unicode"], base=base) | |
| print() | |
| print("Niveau sonore (dBov, plancher %g dB) :" % g["plancher"]) | |
| graphe_niveaux(piste, g["largeur"], g["hauteur_niveaux"], t0, t1, | |
| plancher=g["plancher"], unicode_=g["unicode"], base=base) | |
| print() | |
| # --------------------------------------------------------------------------- | |
| def main(argv=None): | |
| ap = argparse.ArgumentParser( | |
| description="Analyse des trames RTP (G.711) d'une capture pcap/pcapng.") | |
| ap.add_argument("capture", help="fichier .pcap ou .pcapng") | |
| ap.add_argument("--seuil", type=float, default=DEFAULT_BURST_MS, metavar="MS", | |
| help="seuil de regroupement en paquets de trames (defaut : %g ms)" | |
| % DEFAULT_BURST_MS) | |
| ap.add_argument("--limite", type=int, default=20, metavar="N", | |
| help="nombre maximal d'anomalies detaillees par rubrique (defaut : 20)") | |
| gr = ap.add_argument_group("graphe des niveaux sonores (G.711 decode)") | |
| gr.add_argument("--sans-graphe", action="store_true", | |
| help="ne pas decoder l'audio ni tracer les niveaux") | |
| gr.add_argument("--sans-graphe-texte", action="store_true", | |
| help="garder les statistiques de niveau, sans les graphes ASCII") | |
| gr.add_argument("--graphe-largeur", type=int, default=0, metavar="N", | |
| help="largeur des graphes en caracteres (defaut : largeur du terminal)") | |
| gr.add_argument("--graphe-hauteur", type=int, default=17, metavar="N", | |
| help="hauteur de la forme d'onde en lignes (defaut : 17)") | |
| gr.add_argument("--graphe-plancher", type=float, default=DEFAULT_PLANCHER_DB, | |
| metavar="DB", | |
| help="bas de l'echelle du graphe des niveaux (defaut : %g dBov)" | |
| % DEFAULT_PLANCHER_DB) | |
| gr.add_argument("--graphe-silence", type=float, default=DEFAULT_SILENCE_DB, | |
| metavar="DB", | |
| help="seuil de detection du silence (defaut : %g dBov)" | |
| % DEFAULT_SILENCE_DB) | |
| gr.add_argument("--graphe-resolution", type=float, default=DEFAULT_BLOC_MS, | |
| metavar="MS", | |
| help="resolution temporelle des niveaux (defaut : %g ms)" | |
| % DEFAULT_BLOC_MS) | |
| gr.add_argument("--graphe-max-duree", type=float, default=DEFAULT_MAX_DUREE, | |
| metavar="S", | |
| help="duree maximale reconstruite par flux (defaut : %g s)" | |
| % DEFAULT_MAX_DUREE) | |
| gr.add_argument("--graphe-unicode", action="store_true", | |
| help="tracer les graphes ASCII avec des pleins Unicode") | |
| gr.add_argument("--graphe-svg", metavar="FICHIER", | |
| help="ecrire aussi le graphe au format SVG") | |
| gr.add_argument("--graphe-svg-largeur", type=int, default=1180, metavar="PX", | |
| help="largeur du SVG en pixels (defaut : 1180)") | |
| gr.add_argument("--wav", metavar="PREFIXE", | |
| help="exporter la piste reconstruite de chaque flux en WAV") | |
| args = ap.parse_args(argv) | |
| audio_actif = not args.sans_graphe | |
| opts_audio = OptionsAudio(actif=audio_actif, | |
| bloc_ms=max(1.0, args.graphe_resolution), | |
| max_secondes=args.graphe_max_duree, | |
| pcm=bool(args.wav) and audio_actif) | |
| largeur = args.graphe_largeur or max( | |
| 40, min(160, shutil.get_terminal_size((100, 24)).columns - 10)) | |
| graphe = { | |
| "actif": audio_actif and not args.sans_graphe_texte, | |
| "largeur": largeur, | |
| "hauteur": max(5, args.graphe_hauteur), | |
| "hauteur_niveaux": max(5, args.graphe_hauteur - 5), | |
| "plancher": min(-6.0, args.graphe_plancher), | |
| "seuil_silence": args.graphe_silence, | |
| "unicode": args.graphe_unicode, | |
| } | |
| try: | |
| r = analyse(args.capture, args.seuil, args.limite, opts_audio, graphe) | |
| except FileNotFoundError: | |
| print("erreur : fichier introuvable : %s" % args.capture, file=sys.stderr) | |
| return 2 | |
| except CaptureError as e: | |
| print("erreur : capture illisible : %s" % e, file=sys.stderr) | |
| return 2 | |
| afficher(r) | |
| if audio_actif and args.graphe_svg: | |
| titre("EXPORT SVG") | |
| chemin = svg_niveaux(r, args.graphe_svg, args.graphe_svg_largeur, | |
| graphe["plancher"]) | |
| if chemin: | |
| print("graphe ecrit : %s (%d octets)" % (chemin, os.path.getsize(chemin))) | |
| else: | |
| print("rien a tracer : aucune charge utile G.711 decodee.") | |
| if audio_actif and args.wav: | |
| titre("EXPORT WAV") | |
| fichiers = exporter_wav(r, args.wav) | |
| if fichiers: | |
| print("Pistes reconstruites (PCM 16 bits, %d Hz, mono) ;" % G711_CLOCK) | |
| print("les trames manquantes sont comblees par du silence :") | |
| print(" %-60s %10s %12s" % ("fichier", "duree", "dont comble")) | |
| for nom, duree, comble in fichiers: | |
| print(" %-60s %8.3f s %10.3f s" % (nom, duree, comble)) | |
| if comble > 0.25 * duree: | |
| print(" /!\\ %.0f %% de silence ajoute : l'horodatage RTP de ce flux" | |
| % (100.0 * comble / duree)) | |
| print(" comporte un bond important (voir --graphe-max-duree).") | |
| else: | |
| print("rien a exporter : aucune charge utile G.711 decodee.") | |
| print() | |
| return 0 | |
| if __name__ == "__main__": | |
| sys.exit(main()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment