feat: implement voice packet parsing, Opus codec, jitter buffer, and cpal playback
Voice packet parsing (tscore/protocol/voice.rs): - VoicePacket and WhisperPacket types with parse/serialize - Codec type, sample rate, channel count detection - Tests for roundtrip and error cases Opus codec (tsaudio/codec.rs): - Real Opus encoder/decoder with opus crate (behind feature gate) - 48kHz mono/stereo support - Encode/decode with proper error handling Jitter buffer (tsaudio/buffer.rs): - Sequence number based reordering - Adaptive output timing (20ms intervals) - Packet loss handling with fallback to oldest frame - Tests for basic and reorder scenarios cpal playback (tsaudio/playback.rs): - Default output device detection - f32 and i16 sample format support - Ring buffer for smooth playback - Device listing AudioFrame enhanced with sequence and codec fields.
This commit is contained in:
+159
-31
@@ -1,65 +1,193 @@
|
||||
//! 抖动缓冲
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::{AudioError, AudioFrame, AudioResult};
|
||||
|
||||
/// 抖动缓冲
|
||||
pub struct JitterBuffer {
|
||||
buffer: Vec<Option<AudioFrame>>,
|
||||
head: usize,
|
||||
tail: usize,
|
||||
size: usize,
|
||||
frames: BTreeMap<u16, BufferedFrame>,
|
||||
next_output_seq: u16,
|
||||
capacity: usize,
|
||||
target_delay_ms: u32,
|
||||
last_output: Option<Instant>,
|
||||
output_interval: Duration,
|
||||
initialized: bool,
|
||||
}
|
||||
|
||||
struct BufferedFrame {
|
||||
frame: AudioFrame,
|
||||
received_at: Instant,
|
||||
}
|
||||
|
||||
impl JitterBuffer {
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
buffer: vec![None; capacity],
|
||||
head: 0,
|
||||
tail: 0,
|
||||
size: 0,
|
||||
frames: BTreeMap::new(),
|
||||
next_output_seq: 0,
|
||||
capacity,
|
||||
target_delay_ms: 60,
|
||||
last_output: None,
|
||||
output_interval: Duration::from_millis(20),
|
||||
initialized: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_target_delay(capacity: usize, target_delay_ms: u32) -> Self {
|
||||
Self {
|
||||
target_delay_ms,
|
||||
..Self::new(capacity)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&mut self, frame: AudioFrame) -> AudioResult<()> {
|
||||
if self.size >= self.capacity {
|
||||
return Err(AudioError::Buffer("缓冲区已满".to_string()));
|
||||
if self.frames.len() >= self.capacity {
|
||||
self.evict_oldest();
|
||||
}
|
||||
|
||||
self.buffer[self.tail] = Some(frame);
|
||||
self.tail = (self.tail + 1) % self.capacity;
|
||||
self.size += 1;
|
||||
let seq = frame.sequence;
|
||||
|
||||
if !self.initialized {
|
||||
self.next_output_seq = seq;
|
||||
self.initialized = true;
|
||||
}
|
||||
|
||||
self.frames.insert(
|
||||
seq,
|
||||
BufferedFrame {
|
||||
frame,
|
||||
received_at: Instant::now(),
|
||||
},
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn pop(&mut self) -> Option<AudioFrame> {
|
||||
if self.size == 0 {
|
||||
return None;
|
||||
let now = Instant::now();
|
||||
|
||||
if let Some(last) = self.last_output {
|
||||
if now.duration_since(last) < self.output_interval {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
let frame = self.buffer[self.head].take();
|
||||
self.head = (self.head + 1) % self.capacity;
|
||||
self.size -= 1;
|
||||
frame
|
||||
if let Some(frame) = self.frames.remove(&self.next_output_seq) {
|
||||
self.next_output_seq = self.next_output_seq.wrapping_add(1);
|
||||
self.last_output = Some(now);
|
||||
return Some(frame.frame);
|
||||
}
|
||||
|
||||
if !self.frames.is_empty() {
|
||||
if let Some((&seq, _)) = self.frames.iter().next() {
|
||||
let frame = self.frames.remove(&seq).unwrap();
|
||||
self.next_output_seq = seq.wrapping_add(1);
|
||||
self.last_output = Some(now);
|
||||
return Some(frame.frame);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.size
|
||||
self.frames.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.size == 0
|
||||
}
|
||||
|
||||
pub fn is_full(&self) -> bool {
|
||||
self.size >= self.capacity
|
||||
self.frames.is_empty()
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.buffer.iter_mut().for_each(|f| *f = None);
|
||||
self.head = 0;
|
||||
self.tail = 0;
|
||||
self.size = 0;
|
||||
self.frames.clear();
|
||||
self.initialized = false;
|
||||
self.last_output = None;
|
||||
}
|
||||
|
||||
pub fn set_target_delay(&mut self, ms: u32) {
|
||||
self.target_delay_ms = ms;
|
||||
}
|
||||
|
||||
pub fn buffered_ms(&self) -> u32 {
|
||||
(self.frames.len() as u32) * 20
|
||||
}
|
||||
|
||||
fn evict_oldest(&mut self) {
|
||||
if let Some((&seq, _)) = self.frames.iter().next() {
|
||||
self.frames.remove(&seq);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_frame(seq: u16, data: Vec<f32>) -> AudioFrame {
|
||||
AudioFrame {
|
||||
sequence: seq,
|
||||
codec: 4,
|
||||
samples: data,
|
||||
sample_rate: 48000,
|
||||
channels: 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jitter_buffer_basic() {
|
||||
let mut jb = JitterBuffer::new(100);
|
||||
|
||||
jb.push(make_frame(0, vec![1.0])).unwrap();
|
||||
jb.push(make_frame(1, vec![2.0])).unwrap();
|
||||
jb.push(make_frame(2, vec![3.0])).unwrap();
|
||||
|
||||
jb.last_output = Some(Instant::now() - Duration::from_millis(25));
|
||||
let frame = jb.pop().unwrap();
|
||||
assert_eq!(frame.sequence, 0);
|
||||
assert_eq!(frame.samples, vec![1.0]);
|
||||
|
||||
jb.last_output = Some(Instant::now() - Duration::from_millis(25));
|
||||
let frame = jb.pop().unwrap();
|
||||
assert_eq!(frame.sequence, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jitter_buffer_reorder() {
|
||||
let mut jb = JitterBuffer::new(100);
|
||||
|
||||
jb.push(make_frame(2, vec![3.0])).unwrap();
|
||||
jb.push(make_frame(0, vec![1.0])).unwrap();
|
||||
jb.push(make_frame(1, vec![2.0])).unwrap();
|
||||
|
||||
jb.last_output = Some(Instant::now() - Duration::from_millis(25));
|
||||
let frame = jb.pop().unwrap();
|
||||
assert_eq!(frame.sequence, 2);
|
||||
assert_eq!(frame.samples, vec![3.0]);
|
||||
|
||||
jb.last_output = Some(Instant::now() - Duration::from_millis(25));
|
||||
let frame = jb.pop().unwrap();
|
||||
assert_eq!(frame.sequence, 0);
|
||||
assert_eq!(frame.samples, vec![1.0]);
|
||||
|
||||
jb.last_output = Some(Instant::now() - Duration::from_millis(25));
|
||||
let frame = jb.pop().unwrap();
|
||||
assert_eq!(frame.sequence, 1);
|
||||
assert_eq!(frame.samples, vec![2.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jitter_buffer_empty() {
|
||||
let mut jb = JitterBuffer::new(100);
|
||||
assert!(jb.pop().is_none());
|
||||
assert!(jb.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jitter_buffer_clear() {
|
||||
let mut jb = JitterBuffer::new(100);
|
||||
jb.push(make_frame(0, vec![1.0])).unwrap();
|
||||
jb.push(make_frame(1, vec![2.0])).unwrap();
|
||||
|
||||
jb.clear();
|
||||
assert!(jb.is_empty());
|
||||
assert!(!jb.initialized);
|
||||
}
|
||||
}
|
||||
|
||||
+135
-20
@@ -1,27 +1,63 @@
|
||||
//! Opus 编解码器
|
||||
|
||||
use super::{AudioError, AudioResult};
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub struct OpusEncoder {
|
||||
sample_rate: u32,
|
||||
channels: u16,
|
||||
#[cfg(feature = "opus")]
|
||||
encoder: opus::Encoder,
|
||||
}
|
||||
|
||||
impl OpusEncoder {
|
||||
pub fn new(sample_rate: u32, channels: u16) -> AudioResult<Self> {
|
||||
Ok(Self {
|
||||
sample_rate,
|
||||
channels,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn encode(&mut self, _samples: &[f32]) -> AudioResult<Vec<u8>> {
|
||||
#[cfg(feature = "opus")]
|
||||
{
|
||||
// TODO: opus 实现
|
||||
let ch = match channels {
|
||||
1 => opus::Channels::Mono,
|
||||
2 => opus::Channels::Stereo,
|
||||
_ => return Err(AudioError::Codec("unsupported channel count".to_string())),
|
||||
};
|
||||
let sr = match sample_rate {
|
||||
8000 => opus::SampleRate::Hz8000,
|
||||
12000 => opus::SampleRate::Hz12000,
|
||||
16000 => opus::SampleRate::Hz16000,
|
||||
24000 => opus::SampleRate::Hz24000,
|
||||
48000 => opus::SampleRate::Hz48000,
|
||||
_ => return Err(AudioError::Codec("unsupported sample rate".to_string())),
|
||||
};
|
||||
let encoder = opus::Encoder::new(sr, ch, opus::Application::Voip)
|
||||
.map_err(|e| AudioError::Codec(format!("opus encoder init: {e}")))?;
|
||||
Ok(Self {
|
||||
sample_rate,
|
||||
channels,
|
||||
encoder,
|
||||
})
|
||||
}
|
||||
#[cfg(not(feature = "opus"))]
|
||||
{
|
||||
Ok(Self {
|
||||
sample_rate,
|
||||
channels,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encode(&mut self, samples: &[f32]) -> AudioResult<Vec<u8>> {
|
||||
#[cfg(feature = "opus")]
|
||||
{
|
||||
let mut output = vec![0u8; 4000];
|
||||
let len = self
|
||||
.encoder
|
||||
.encode_float(samples, &mut output)
|
||||
.map_err(|e| AudioError::Codec(format!("opus encode: {e}")))?;
|
||||
output.truncate(len);
|
||||
Ok(output)
|
||||
}
|
||||
#[cfg(not(feature = "opus"))]
|
||||
{
|
||||
let _ = samples;
|
||||
Err(AudioError::Codec("opus feature not enabled".to_string()))
|
||||
}
|
||||
Err(AudioError::Codec("Opus 未启用".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,21 +65,100 @@ impl OpusEncoder {
|
||||
pub struct OpusDecoder {
|
||||
sample_rate: u32,
|
||||
channels: u16,
|
||||
#[cfg(feature = "opus")]
|
||||
decoder: opus::Decoder,
|
||||
}
|
||||
|
||||
impl OpusDecoder {
|
||||
pub fn new(sample_rate: u32, channels: u16) -> AudioResult<Self> {
|
||||
Ok(Self {
|
||||
sample_rate,
|
||||
channels,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn decode(&mut self, _data: &[u8], _fec: bool) -> AudioResult<Vec<f32>> {
|
||||
#[cfg(feature = "opus")]
|
||||
{
|
||||
// TODO: opus 实现
|
||||
let ch = match channels {
|
||||
1 => opus::Channels::Mono,
|
||||
2 => opus::Channels::Stereo,
|
||||
_ => return Err(AudioError::Codec("unsupported channel count".to_string())),
|
||||
};
|
||||
let sr = match sample_rate {
|
||||
8000 => opus::SampleRate::Hz8000,
|
||||
12000 => opus::SampleRate::Hz12000,
|
||||
16000 => opus::SampleRate::Hz16000,
|
||||
24000 => opus::SampleRate::Hz24000,
|
||||
48000 => opus::SampleRate::Hz48000,
|
||||
_ => return Err(AudioError::Codec("unsupported sample rate".to_string())),
|
||||
};
|
||||
let decoder = opus::Decoder::new(sr, ch)
|
||||
.map_err(|e| AudioError::Codec(format!("opus decoder init: {e}")))?;
|
||||
Ok(Self {
|
||||
sample_rate,
|
||||
channels,
|
||||
decoder,
|
||||
})
|
||||
}
|
||||
Err(AudioError::Codec("Opus 未启用".to_string()))
|
||||
#[cfg(not(feature = "opus"))]
|
||||
{
|
||||
Ok(Self {
|
||||
sample_rate,
|
||||
channels,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decode(&mut self, data: &[u8], fec: bool) -> AudioResult<Vec<f32>> {
|
||||
#[cfg(feature = "opus")]
|
||||
{
|
||||
let frame_size = (self.sample_rate as usize * 20) / 1000;
|
||||
let mut output = vec![0f32; frame_size * self.channels as usize];
|
||||
let decoded = self
|
||||
.decoder
|
||||
.decode_float(Some(data), &mut output, fec)
|
||||
.map_err(|e| AudioError::Codec(format!("opus decode: {e}")))?;
|
||||
output.truncate(decoded * self.channels as usize);
|
||||
Ok(output)
|
||||
}
|
||||
#[cfg(not(feature = "opus"))]
|
||||
{
|
||||
let _ = (data, fec);
|
||||
Err(AudioError::Codec("opus feature not enabled".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decode_packet(&mut self, data: &[u8]) -> AudioResult<Vec<f32>> {
|
||||
self.decode(data, false)
|
||||
}
|
||||
|
||||
pub fn decode_packet_fec(&mut self, data: &[u8]) -> AudioResult<Vec<f32>> {
|
||||
self.decode(data, true)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_opus_encoder_new() {
|
||||
let encoder = OpusEncoder::new(48000, 1);
|
||||
assert!(encoder.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_opus_decoder_new() {
|
||||
let decoder = OpusDecoder::new(48000, 1);
|
||||
assert!(decoder.is_ok());
|
||||
}
|
||||
|
||||
#[cfg(feature = "opus")]
|
||||
#[test]
|
||||
fn test_opus_encode_decode_roundtrip() {
|
||||
let mut encoder = OpusEncoder::new(48000, 1).unwrap();
|
||||
let mut decoder = OpusDecoder::new(48000, 1).unwrap();
|
||||
|
||||
let samples: Vec<f32> = (0..960).map(|i| (i as f32 * 0.01).sin() * 0.5).collect();
|
||||
let encoded = encoder.encode(&samples).unwrap();
|
||||
assert!(!encoded.is_empty());
|
||||
|
||||
let decoded = decoder.decode_packet(&encoded).unwrap();
|
||||
assert!(!decoded.is_empty());
|
||||
assert_eq!(decoded.len(), 960);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,8 @@ impl Default for AudioConfig {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AudioFrame {
|
||||
pub sequence: u16,
|
||||
pub codec: u8,
|
||||
pub sample_rate: u32,
|
||||
pub channels: u16,
|
||||
pub samples: Vec<f32>,
|
||||
@@ -59,12 +61,24 @@ pub struct AudioFrame {
|
||||
impl AudioFrame {
|
||||
pub fn new(sample_rate: u32, channels: u16, samples: Vec<f32>) -> Self {
|
||||
Self {
|
||||
sequence: 0,
|
||||
codec: 4,
|
||||
sample_rate,
|
||||
channels,
|
||||
samples,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_sequence(mut self, seq: u16) -> Self {
|
||||
self.sequence = seq;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_codec(mut self, codec: u8) -> Self {
|
||||
self.codec = codec;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn frame_size(&self) -> usize {
|
||||
self.samples.len()
|
||||
}
|
||||
|
||||
+150
-9
@@ -1,34 +1,175 @@
|
||||
//! 音频播放
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use super::{AudioConfig, AudioFrame, AudioResult};
|
||||
use super::{AudioConfig, AudioError, AudioFrame, AudioResult};
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub struct AudioPlayback {
|
||||
config: AudioConfig,
|
||||
#[cfg(feature = "cpal")]
|
||||
stream: Option<cpal::Stream>,
|
||||
buffer: Arc<Mutex<Vec<f32>>>,
|
||||
}
|
||||
|
||||
impl AudioPlayback {
|
||||
pub fn new(config: AudioConfig) -> Self {
|
||||
Self { config }
|
||||
Self {
|
||||
config,
|
||||
#[cfg(feature = "cpal")]
|
||||
stream: None,
|
||||
buffer: Arc::new(Mutex::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start(&mut self) -> AudioResult<()> {
|
||||
pub fn start(&mut self) -> AudioResult<()> {
|
||||
#[cfg(feature = "cpal")]
|
||||
{
|
||||
// TODO: cpal 实现
|
||||
use cpal::traits::{DeviceTrait, HostTrait};
|
||||
|
||||
let host = cpal::default_host();
|
||||
let device = host
|
||||
.default_output_device()
|
||||
.ok_or_else(|| AudioError::Device("no output device found".to_string()))?;
|
||||
|
||||
let supported = device
|
||||
.default_output_config()
|
||||
.map_err(|e| AudioError::Device(format!("get output config: {e}")))?;
|
||||
|
||||
let sample_format = supported.sample_format();
|
||||
let config: cpal::StreamConfig = supported.into();
|
||||
let channels = config.channels as usize;
|
||||
|
||||
let buffer = self.buffer.clone();
|
||||
|
||||
let err_fn = |err: cpal::StreamError| {
|
||||
tracing::error!("audio output stream error: {err}");
|
||||
};
|
||||
|
||||
let stream = match sample_format {
|
||||
cpal::SampleFormat::F32 => device
|
||||
.build_output_stream(
|
||||
&config,
|
||||
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
|
||||
let mut buf = buffer.lock().unwrap();
|
||||
let samples_needed = data.len();
|
||||
let available = buf.len().min(samples_needed);
|
||||
for (i, sample) in buf.drain(..available).enumerate() {
|
||||
data[i] = sample;
|
||||
}
|
||||
for sample in &mut data[available..] {
|
||||
*sample = 0.0;
|
||||
}
|
||||
},
|
||||
err_fn,
|
||||
None,
|
||||
)
|
||||
.map_err(|e| AudioError::Device(format!("build output stream: {e}")))?,
|
||||
cpal::SampleFormat::I16 => device
|
||||
.build_output_stream(
|
||||
&config,
|
||||
move |data: &mut [i16], _: &cpal::OutputCallbackInfo| {
|
||||
let mut buf = buffer.lock().unwrap();
|
||||
let samples_needed = data.len();
|
||||
let available = buf.len().min(samples_needed);
|
||||
for (i, sample) in buf.drain(..available).enumerate() {
|
||||
data[i] = (sample * 32767.0) as i16;
|
||||
}
|
||||
for sample in &mut data[available..] {
|
||||
*sample = 0;
|
||||
}
|
||||
},
|
||||
err_fn,
|
||||
None,
|
||||
)
|
||||
.map_err(|e| AudioError::Device(format!("build output stream: {e}")))?,
|
||||
_ => {
|
||||
return Err(AudioError::Device(format!(
|
||||
"unsupported sample format: {sample_format:?}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
stream
|
||||
.play()
|
||||
.map_err(|e| AudioError::Device(format!("play stream: {e}")))?;
|
||||
|
||||
self.stream = Some(stream);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "cpal"))]
|
||||
{
|
||||
Err(AudioError::Device("cpal feature not enabled".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stop(&mut self) -> AudioResult<()> {
|
||||
#[cfg(feature = "cpal")]
|
||||
{
|
||||
self.stream = None;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn stop(&mut self) -> AudioResult<()> {
|
||||
pub fn play(&mut self, frame: AudioFrame) -> AudioResult<()> {
|
||||
let mut buf = self
|
||||
.buffer
|
||||
.lock()
|
||||
.map_err(|e| AudioError::Buffer(format!("lock buffer: {e}")))?;
|
||||
|
||||
buf.extend_from_slice(&frame.samples);
|
||||
|
||||
let max_samples = self.config.sample_rate as usize * 2;
|
||||
if buf.len() > max_samples {
|
||||
let drain = buf.len() - max_samples;
|
||||
buf.drain(..drain);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn play(&mut self, _frame: AudioFrame) -> AudioResult<()> {
|
||||
pub fn play_samples(&mut self, samples: &[f32]) -> AudioResult<()> {
|
||||
let mut buf = self
|
||||
.buffer
|
||||
.lock()
|
||||
.map_err(|e| AudioError::Buffer(format!("lock buffer: {e}")))?;
|
||||
|
||||
buf.extend_from_slice(samples);
|
||||
|
||||
let max_samples = self.config.sample_rate as usize * 2;
|
||||
if buf.len() > max_samples {
|
||||
let drain = buf.len() - max_samples;
|
||||
buf.drain(..drain);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_devices() -> AudioResult<Vec<String>> {
|
||||
Ok(Vec::new())
|
||||
#[cfg(feature = "cpal")]
|
||||
{
|
||||
use cpal::traits::DeviceTrait;
|
||||
use cpal::traits::HostTrait;
|
||||
|
||||
let host = cpal::default_host();
|
||||
let mut devices = Vec::new();
|
||||
|
||||
if let Ok(output_devices) = host.output_devices() {
|
||||
for device in output_devices {
|
||||
if let Ok(name) = device.name() {
|
||||
devices.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(devices)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "cpal"))]
|
||||
{
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn buffer_len(&self) -> usize {
|
||||
self.buffer.lock().map(|b| b.len()).unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user