1
0
mirror of https://github.com/azlux/pymumble synced 2024-11-23 13:56:26 +00:00
pymumble/pymumble_py3/soundoutput.py
Nicola C 2ee3f82744
UDP encryption fix (#133)
* Fixed encryption

Before the fix the whole last block encrypted were appended to the encrypted output.
This caused a wrong checksum calculation and resulted in a wrong tag prepended to the packet, which the Mumble server discarded most of the times (in some lucky cases the remaining length.

To fix this I took inspiration from: https://github.com/ianling/mumpy/blob/dev/mumpy/mumblecrypto.py

* Added a default decryption plain_length
* Renamed send_udp to the existing send_packet_udp
* Removed error-prone length parameter
2023-10-31 11:13:52 +01:00

222 lines
9.4 KiB
Python

# -*- coding: utf-8 -*-
from time import time
import struct
import threading
import socket
import opuslib
from .constants import *
from .errors import CodecNotSupportedError
from .tools import VarInt
from .messages import VoiceTarget
class SoundOutput:
"""
Class managing the sounds that must be sent to the server (best sent in a multiple of audio_per_packet samples)
The buffering is the responsibility of the caller, any partial sound will be sent without delay
"""
def __init__(self, mumble_object, audio_per_packet, bandwidth, stereo=False, opus_profile=PYMUMBLE_AUDIO_TYPE_OPUS_PROFILE):
"""
audio_per_packet=packet audio duration in sec
bandwidth=maximum total outgoing bandwidth
"""
self.mumble_object = mumble_object
self.Log = self.mumble_object.Log
self.pcm = []
self.lock = threading.Lock()
self.codec = None # codec currently requested by the server
self.encoder = None # codec instance currently used to encode
self.encoder_framesize = None # size of an audio frame for the current codec (OPUS=audio_per_packet, CELT=0.01s)
self.opus_profile = opus_profile
self.channels = 1 if not stereo else 2
self.audio_per_packet = 0
self.bandwidth = 0
self.set_audio_per_packet(audio_per_packet)
self.set_bandwidth(bandwidth)
self.codec_type = None # codec type number to be used in audio packets
self.target = 0 # target is not implemented yet, so always 0
self.sequence_start_time = 0 # time of sequence 1
self.sequence_last_time = 0 # time of the last emitted packet
self.sequence = 0 # current sequence
def send_audio(self):
"""send the available audio to the server, taking care of the timing"""
if not self.encoder or len(self.pcm) == 0: # no codec configured or no audio sent
return ()
samples = int(self.encoder_framesize * PYMUMBLE_SAMPLERATE * 2 * self.channels) # number of samples in an encoder frame
while len(self.pcm) > 0 and self.sequence_last_time + self.audio_per_packet <= time(): # audio to send and time to send it (since last packet)
current_time = time()
if self.sequence_last_time + PYMUMBLE_SEQUENCE_RESET_INTERVAL <= current_time: # waited enough, resetting sequence to 0
self.sequence = 0
self.sequence_start_time = current_time
self.sequence_last_time = current_time
elif self.sequence_last_time + (self.audio_per_packet * 2) <= current_time: # give some slack (2*audio_per_frame) before interrupting a continuous sequence
# calculating sequence after a pause
self.sequence = int((current_time - self.sequence_start_time) / PYMUMBLE_SEQUENCE_DURATION)
self.sequence_last_time = self.sequence_start_time + (self.sequence * PYMUMBLE_SEQUENCE_DURATION)
else: # continuous sound
self.sequence += int(self.audio_per_packet / PYMUMBLE_SEQUENCE_DURATION)
self.sequence_last_time = self.sequence_start_time + (self.sequence * PYMUMBLE_SEQUENCE_DURATION)
payload = bytearray() # content of the whole packet, without tcptunnel header
audio_encoded = 0 # audio time already in the packet
while len(self.pcm) > 0 and audio_encoded < self.audio_per_packet: # more audio to be sent and packet not full
self.lock.acquire()
to_encode = self.pcm.pop(0)
self.lock.release()
if len(to_encode) != samples: # pad to_encode if needed to match sample length
to_encode += b'\x00' * (samples - len(to_encode))
try:
encoded = self.encoder.encode(to_encode, len(to_encode) // (2 * self.channels))
except opuslib.exceptions.OpusError:
encoded = b''
audio_encoded += self.encoder_framesize
# create the audio frame header
if self.codec_type == PYMUMBLE_AUDIO_TYPE_OPUS:
frameheader = VarInt(len(encoded)).encode()
else:
frameheader = len(encoded)
if audio_encoded < self.audio_per_packet and len(self.pcm) > 0: # if not last frame for the packet, set the terminator bit
frameheader += (1 << 7)
frameheader = struct.pack('!B', frameheader)
payload += frameheader + encoded # add the frame to the packet
header = self.codec_type << 5 # encapsulate in audio packet
sequence = VarInt(self.sequence).encode()
udp_packet = struct.pack('!B', header | self.target) + sequence + payload
if self.mumble_object.positional:
udp_packet += struct.pack("fff", self.mumble_object.positional[0], self.mumble_object.positional[1], self.mumble_object.positional[2])
self.Log.debug("{PROTO} audio packet to send: sequence:{sequence}, type:{type}, length:{len}".format(
PROTO='UDP' if self.mumble_object.udp_active else 'TCP',
sequence=self.sequence,
type=self.codec_type,
len=len(udp_packet)
))
if self.mumble_object.udp_active:
self.mumble_object.send_packet_udp(udp_packet)
else:
tcp_packet = struct.pack("!HL", PYMUMBLE_MSG_TYPES_UDPTUNNEL, len(udp_packet)) + udp_packet # encapsulate in tcp tunnel
while len(tcp_packet) > 0:
sent = self.mumble_object.control_socket.send(tcp_packet)
if sent < 0:
raise socket.error("Server socket error")
tcp_packet = tcp_packet[sent:]
def get_audio_per_packet(self):
"""return the configured length of a audio packet (in ms)"""
return self.audio_per_packet
def set_audio_per_packet(self, audio_per_packet):
"""set the length of an audio packet (in ms)"""
self.audio_per_packet = audio_per_packet
self.create_encoder()
def get_bandwidth(self):
"""get the configured bandwidth for the audio output"""
return self.bandwidth
def set_bandwidth(self, bandwidth):
"""set the bandwidth for the audio output"""
self.bandwidth = bandwidth
self._set_bandwidth()
def _set_bandwidth(self):
"""do the calculation of the overhead and configure the actual bitrate for the codec"""
if self.encoder:
overhead_per_packet = 20 # IP header in bytes
overhead_per_packet += (3 * int(self.audio_per_packet / self.encoder_framesize)) # overhead per frame
if self.mumble_object.udp_active:
overhead_per_packet += 12 # UDP header
else:
overhead_per_packet += 20 # TCP header
overhead_per_packet += 6 # TCPTunnel encapsulation
overhead_per_second = int(overhead_per_packet * 8 / self.audio_per_packet) # in bits
self.Log.debug(
"Bandwidth is {bandwidth}, downgrading to {bitrate} due to the protocol overhead".format(bandwidth=self.bandwidth, bitrate=self.bandwidth - overhead_per_second))
self.encoder.bitrate = self.bandwidth - overhead_per_second
def add_sound(self, pcm):
"""add sound to be sent (in PCM 16 bits signed format)"""
if len(pcm) % 2 != 0: # check that the data is align on 16 bits
raise Exception("pcm data must be 16 bits")
samples = int(self.encoder_framesize * PYMUMBLE_SAMPLERATE * 2 * self.channels) # number of samples in an encoder frame
self.lock.acquire()
if len(self.pcm) and len(self.pcm[-1]) < samples:
initial_offset = samples - len(self.pcm[-1])
self.pcm[-1] += pcm[:initial_offset]
else:
initial_offset = 0
for i in range(initial_offset, len(pcm), samples):
self.pcm.append(pcm[i:i + samples])
self.lock.release()
def clear_buffer(self):
self.lock.acquire()
self.pcm = []
self.lock.release()
def get_buffer_size(self):
"""return the size of the unsent buffer in sec"""
return sum(len(chunk) for chunk in self.pcm) / 2. / PYMUMBLE_SAMPLERATE / self.channels
def set_default_codec(self, codecversion):
"""Set the default codec to be used to send packets"""
self.codec = codecversion
self.create_encoder()
def create_encoder(self):
"""create the encoder instance, and set related constants"""
if not self.codec:
return ()
if self.codec.opus:
self.encoder = opuslib.Encoder(PYMUMBLE_SAMPLERATE, self.channels, self.opus_profile)
self.encoder_framesize = self.audio_per_packet
self.codec_type = PYMUMBLE_AUDIO_TYPE_OPUS
else:
raise CodecNotSupportedError('')
self._set_bandwidth()
def set_whisper(self, target_id, channel=False):
if not target_id:
return
if type(target_id) is int:
target_id = [target_id]
self.target = 2
if channel:
self.target = 1
cmd = VoiceTarget(self.target, target_id)
self.mumble_object.execute_command(cmd)
def remove_whisper(self):
self.target = 0
cmd = VoiceTarget(self.target, [])
self.mumble_object.execute_command(cmd)