def data_received(self, data): "Read fifo indefinitely and push data into queue" # TODO: Buffer needs to be only twice the size of the data # and could be handled without allocations/deallocations. # TODO: Use bytearray() self.buffer += data while len(self.buffer) >= self.audio_config.chunk_size: chunk = self.buffer[:self.audio_config.chunk_size] self.buffer = self.buffer[self.audio_config.chunk_size:] # Detect the end of current silence if self.silence_detect is True: if any(chunk): self.silence_detect = 0 print("Silence - end") now = time_machine.now() if not self.stream_time or self.stream_time < now: self.stream_time = now else: # Still silence continue else: # Heuristic detection of silence start if chunk[0] == 0 and chunk[-1] == 0: self.silence_detect += 1 else: self.silence_detect = 0 # Silence too long - stop transmission if self.silence_detect > self.SILENCE_TRESHOLD: if any(chunk): # Accurate check self.silence_detect = 0 else: print("Silence - start") self.silence_detect = True continue if self.stream_time is None: self.stream_time = time_machine.now() else: self.stream_time += self.audio_config.chunk_time self.sample_queue.put_nowait((self.stream_time, chunk)) # Warning - might happen on slow UDP output sink if self.sample_queue.qsize() > 600: s = "WARNING: Samples in queue: %d - slow UDP transmission or eager input." s = s % self.sample_queue.qsize() print(s) if self.stream_time is not None: diff = self.stream_time - time_machine.now() if diff < min(-self.audio_config.latency_ms / 2, -1): print("WARNING: Input underflow.") self.stream_time = None
def datagram_received(self, data, addr): "Handle incoming datagram - audio chunk, or status packet" header = data[:2] mark = data[2:4] chunk = data[4:] if header == Packetizer.HEADER_RAW_AUDIO: pass elif header == Packetizer.HEADER_COMPRESSED_AUDIO: try: chunk = zlib.decompress(chunk) except zlib.error: print("WARNING: Invalid compressed data - dropping") return elif header == Packetizer.HEADER_STATUS: # Status header! self._handle_status(data) return else: print("Invalid header!") return if self.chunk_queue.ignore_audio_packets != 0: self.chunk_queue.ignore_audio_packets -= 1 return mark = time_machine.to_absolute_timestamp(time_machine.now(), mark) item = (mark, chunk) # Count received audio-chunks self.chunk_queue.chunk_no += 1 self.chunk_queue.chunk_list.append((self.chunk_queue.CMD_AUDIO, item)) self.chunk_queue.chunk_available.set()
def _handle_status(self, data): if len(data) < (2 + 20): print("WARNING: Status header too short") (sender_timestamp, sender_chunk_no, rate, sample, channels, chunk_size, latency_ms) = struct.unpack('dIHBBHH', data[2:2 + 8 + 4 + 2 + 1 + 1 + 2 + 2]) q = self.chunk_queue # Handle timestamp now = time_machine.now() self.stats.network_latency = (now - sender_timestamp) # Handle audio configuration audio_config = AudioConfig(rate, sample, channels, latency_ms, sink_latency_ms=self.sink_latency_ms) audio_config.chunk_size = chunk_size if audio_config != self.audio_config: # If changed - sent further q.chunk_list.append((q.CMD_CFG, audio_config)) self.audio_config = audio_config # Handle dropped packets # If this is first status packet # or low sender_chunk_no indicates that sender was restarted if q.last_sender_chunk_no is None or sender_chunk_no < 1500: q.last_sender_chunk_no = sender_chunk_no q.chunk_no = 0 return # How many chunks were transmitted since previous status packet? chunks_sent = sender_chunk_no - q.last_sender_chunk_no dropped = chunks_sent - q.chunk_no q.last_sender_chunk_no = sender_chunk_no q.chunk_no = 0 self.stats.network_drops += dropped if dropped < 0: print("WARNING: More pkts received than sent! " "You are receiving multiple streams or duplicates.") elif dropped > 0: q.chunk_list.append((q.CMD_DROPS, dropped)) q.chunk_available.set()
async def packetize(self): "Read pre-chunked samples from queue and send them over UDP" start = time() # Numer of sent packets stat_pkts = 0 # Chunk number as seen by receivers chunk_no = 0 bytes_sent = 0 bytes_raw = 0 cancelled_compressions = 0 # Current speed measurement recent = 0 recent_bytes = 0 recent_start = time() # For local playback if self.chunk_queue is not None: self.chunk_queue.chunk_list.append((self.chunk_queue.CMD_CFG, self.audio_config)) while not self.stop: # Block until samples are read by the reader. stream_time, chunk = await self.reader.get_next_chunk() # Handle input flood, to keep us within timemarking range. now = time_machine.now() diff = stream_time - now if diff > 0.5: print("Waiting to synchronize input stream. Stream-real, difference is", diff) await asyncio.sleep(0.4) elif diff < -5: print("Input stream is lagging", diff) relative = stream_time future_ts, mark = time_machine.get_timemark(relative, self.audio_config.latency_s) if self.chunk_queue is not None: item = (future_ts, chunk) self.chunk_queue.chunk_list.append((self.chunk_queue.CMD_AUDIO, item)) self.chunk_queue.chunk_available.set() chunk_len = len(chunk) if self.compress is not False: chunk_compressed = zlib.compress(chunk, self.compress) if len(chunk_compressed) < chunk_len: # Go with compressed dgram = Packetizer.HEADER_COMPRESSED_AUDIO + mark + chunk_compressed else: # Cancel - compressed might not fit to packet dgram = Packetizer.HEADER_RAW_AUDIO + mark + chunk cancelled_compressions += 1 else: dgram = Packetizer.HEADER_RAW_AUDIO + mark + chunk dgram_len = len(dgram) chunk_no += 1 recent += 1 for destination in self.destinations: try: self.sock.sendto(dgram, destination) bytes_sent += dgram_len recent_bytes += dgram_len bytes_raw += chunk_len + 4 stat_pkts += 1 except OSError as ex: import errno if ex.errno == errno.EMSGSIZE: s = "WARNING: UDP datagram size (%d) is too big for your network MTU" s = s % len(dgram) print(s) new_size = self.reader.decrement_payload_size() print("Trying MTU detection. New payload size is %d" % new_size) break # Send small status datagram every 124 chunks - ~ 1 second # It's used to determine if some frames were lost on the network # and therefore if output buffer resync is required. # Contains the audio configuration too. if chunk_no % 124 == 0: dgram = self._create_status_packet(chunk_no) for destination in self.destinations: self.sock.sendto(dgram, destination) if recent >= 100: # Main status line now = time() took_total = now - start took_recent = now - recent_start s = ("STATE: dsts=%d total: pkts=%d kB=%d time=%d " "kB/s: avg=%.3f cur=%.3f") s = s % ( len(self.destinations), stat_pkts, bytes_sent / 1024, took_total, bytes_sent / took_total / 1024, recent_bytes / took_recent / 1024, ) if self.compress: s += ' compress_ratio=%.3f cancelled=%d' s = s % (bytes_sent / bytes_raw, cancelled_compressions) print(s) recent_start = now recent_bytes = 0 recent = 0 print("- Packetizer stop")
async def chunk_player(self): "Reads asynchronously chunks from the list and plays them" cnt = 0 # Chunk/s stat recent_start = time() recent = 0 mid_tolerance_s = self.tolerance_ms / 2 / 1000 one_ms = 1 / 1000.0 max_delay = 5 while not self.stop: if not self.chunk_queue.chunk_list: if self.audio_config is not None: print("Queue empty - waiting") self.chunk_queue.chunk_available.clear() await self.chunk_queue.chunk_available.wait() recent_start = time() recent = 0 if self.audio_config is not None: await asyncio.sleep(self.audio_config.latency_ms / 1000 / 4 ) print("Got stream flowing. q_len=%d" % len(self.chunk_queue.chunk_list)) continue cmd, item = self.chunk_queue.chunk_list.popleft() if cmd == self.chunk_queue.CMD_CFG: print("Got new configuration - opening audio stream") self.clear_state() self.audio_config = item if self.stream: self._close_stream() self._open_stream() # Calculate maximum sensible delay in given configuration max_delay = (2000 + self.audio_config.sink_latency_ms + self.audio_config.latency_ms) / 1000 print("Assuming maximum chunk delay of %.2fms in this setup" % (max_delay * 1000)) continue elif cmd == self.chunk_queue.CMD_DROPS: if item > 200: print("Recovering after a huge packet loss of %d packets" % item) self.clear_state() else: # Just slowly resync self.silence_to_insert += item continue # CMD_AUDIO if self.stream is None: # No output, no playing. continue mark, chunk = item desired_time = mark - self.audio_config.sink_latency_ms # 0) We got the next chunk to be played now = time_machine.now() # Negative when we're lagging behind. delay = desired_time - now self.stat_total_delay += delay recent += 1 cnt += 1 # Probabilistic drop of lagging chunks to get back on track. # Probability of drop is higher, the more chunk lags behind current # time. Similar to the RED algorithm in TCP congestion. if delay < -mid_tolerance_s: over = -delay - mid_tolerance_s prob = over / mid_tolerance_s if random.random() < prob: s = "Drop chunk: q_len=%2d delay=%.1fms < 0. tolerance=%.1fms: P=%.2f" s = s % (len(self.chunk_queue.chunk_list), delay * 1000, self.tolerance_ms, prob) print(s) self.stat_time_drops += 1 continue elif delay > max_delay: # Probably we hanged for so long time that the time recovering # mechanism rolled over. Recover print( "Huge recovery - delay of %.2f exceeds the max delay of %.2f" % (delay, max_delay)) self.clear_state() continue # If chunk is in the future - wait until it's within the tolerance elif delay > one_ms: to_wait = max(one_ms, delay - one_ms) await asyncio.sleep(to_wait) # Wait until we can write chunk into output buffer. This might # delay us too much - the probabilistic dropping mechanism will kick # in. times = 0 while True: buffer_space = self.stream.get_write_available() if buffer_space < self.chunk_frames: self.stat_output_delays += 1 await asyncio.sleep(one_ms) times += 1 if times > 200: print("Hey, the output is STUCK!") await asyncio.sleep(1) break continue self.stream.write(chunk) break # Main status line if recent > 200: frames_in_chunk = len(chunk) / self.audio_config.frame_size took = time() - recent_start chunks_per_s = recent / took if self.receiver is not None: network_latency = self.receiver.stat_network_latency network_drops = self.receiver.stat_network_drops else: network_latency = 0 network_drops = 0 s = ("STAT: chunks: q_len=%-3d bs=%4.1f " "ch/s=%5.1f " "net lat: %-5.1fms " "avg_delay=%-5.2f drops: time=%d net=%d out_delay=%d") s = s % ( len(self.chunk_queue.chunk_list), buffer_space / frames_in_chunk, chunks_per_s, 1000.0 * network_latency, 1000.0 * self.stat_total_delay / cnt, self.stat_time_drops, network_drops, self.stat_output_delays, ) print(s) recent = 0 recent_start = time() # Warnings if self.receiver is not None: if self.receiver.stat_network_latency > 1: print("WARNING: Your network latency seems HUGE. " "Are the clocks synchronised?") elif self.receiver.stat_network_latency <= -0.05: print("WARNING: You either exceeded the speed of " "light or have unsynchronised clocks") print("- Finishing chunk player")
async def _handle_cmd_audio(self, item): "Handle chunk playback" mid_tolerance_s = self.tolerance_ms / 2 / 1000 one_ms = 1/1000.0 mark, chunk = item desired_time = mark - self.audio_output.config.sink_latency_s # 0) We got the next chunk to be played now = time_machine.now() # Negative when we're lagging behind. delay = desired_time - now self.stats.total_delay += delay self.stats.total_chunks += 1 # Probabilistic drop of lagging chunks to get back on track. # Probability of drop is higher, the more chunk lags behind current # time. Similar to the RED algorithm in TCP congestion. if delay < -mid_tolerance_s: over = -delay - mid_tolerance_s prob = over / mid_tolerance_s if random.random() < prob: s = "Drop chunk: q_len=%2d delay=%.1fms < 0. tolerance=%.1fms: P=%.2f" s = s % (len(self.chunk_queue.chunk_list), delay * 1000, self.tolerance_ms, prob) print(s) self.stats.time_drops += 1 return elif delay > self.max_delay: # Probably we hanged for so long time that the time recovering # mechanism rolled over. Recover print("Huge recovery - delay of %.2f exceeds the max delay of %.2f" % ( delay, self.max_delay)) self.clear_state() return # If chunk is in the future - wait until it's within the tolerance elif delay > one_ms: to_wait = max(one_ms, delay - one_ms) await asyncio.sleep(to_wait) # Wait until we can write chunk into output buffer. This might # delay us too much - the probabilistic dropping mechanism will kick # in. times = 0 while True: buffer_space = self.audio_output.get_write_available() if buffer_space < self.audio_output.chunk_frames: self.stats.output_delays += 1 await asyncio.sleep(one_ms) times += 1 if times > 200: print("Hey, the output is STUCK!") await asyncio.sleep(1) break continue self.audio_output.write(chunk) return