Files
2026-07-13 13:39:38 +08:00

507 lines
17 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Copyright 2024 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import asyncio
import enum
import io
import struct
import threading
from collections.abc import AsyncIterator
from concurrent.futures import ThreadPoolExecutor
from typing import cast
import av
import av.container
from livekit import rtc
from ...log import logger
from .. import aio
from ..audio import AudioByteStream
def _mime_to_av_format(mime: str | None) -> str | None:
"""Return the libav *container* shortname for a given MIMEtype.
If *mime* is *None* or not recognised, return *None* so that PyAV will
fall back to autodetection.
"""
if not mime:
return None
mime = mime.lower()
_TABLE: dict[str, str] = {
"audio/mpeg": "mp3",
"audio/mp3": "mp3",
"audio/x-mpeg": "mp3",
"audio/aac": "aac",
"audio/x-aac": "aac",
"audio/flac": "flac",
"audio/x-flac": "flac",
"audio/wav": "wav",
"audio/wave": "wav",
"audio/x-wav": "wav",
"audio/opus": "ogg",
"audio/ogg": "ogg",
"audio/webm": "webm",
"audio/mp4": "mp4",
}
return _TABLE.get(mime)
class StreamBuffer:
"""
A thread-safe buffer that behaves like an IO stream.
Allows writing from one thread and reading from another.
"""
_COMPACT_THRESHOLD = 5 * 1024 * 1024 # compact after 5MB consumed
def __init__(self) -> None:
self._bio = io.BytesIO()
self._lock = threading.Lock()
self._data_available = threading.Condition(self._lock)
self._eof = False
self._closed = False
self._write_pos = 0
self._read_pos = 0
def write(self, data: bytes) -> None:
"""Write data to the buffer from a writer thread."""
with self._data_available:
self._bio.seek(self._write_pos)
self._bio.write(data)
self._write_pos = self._bio.tell()
self._data_available.notify_all()
def read(self, size: int = -1) -> bytes:
"""Read data from the buffer in a reader thread."""
if size == 0:
return b""
with self._data_available:
while True:
if self._closed:
return b""
available = self._write_pos - self._read_pos
if available > 0:
self._bio.seek(self._read_pos)
if size < 0:
data = self._bio.read(available)
else:
data = self._bio.read(min(size, available))
self._read_pos = self._bio.tell()
if self._read_pos >= self._COMPACT_THRESHOLD:
remaining = self._bio.read()
self._bio = io.BytesIO(remaining)
self._bio.seek(0, io.SEEK_END)
self._write_pos = self._bio.tell()
self._read_pos = 0
return data if data else b""
if self._eof:
return b""
self._data_available.wait()
def end_input(self) -> None:
"""Signal that no more data will be written."""
with self._data_available:
self._eof = True
self._data_available.notify_all()
def close(self) -> None:
with self._data_available:
self._closed = True
self._data_available.notify_all()
self._bio.close()
class _WavState(enum.IntEnum):
RIFF_HEADER = 0
CHUNK_HEADER = 1
FMT_DATA = 2
SKIP_CHUNK_DATA = 3
STREAMING = 4
class _WavInlineDecoder:
"""Incremental WAV decoder that runs entirely on the event loop (no thread).
Processes WAV bytes via a state machine:
RIFF_HEADER → CHUNK_HEADER → FMT_DATA/SKIP_CHUNK_DATA → STREAMING.
Once in STREAMING state, subsequent push() calls feed bytes directly to
AudioByteStream → optional resampler → output channel.
Each push() may contain a complete WAV file (with its own headers). When a
new RIFF header is detected while already streaming, the current stream is
flushed and the state machine resets to parse the new file's headers.
"""
_RIFF_MAGIC = b"RIFF"
def __init__(
self,
output_ch: aio.Chan[rtc.AudioFrame],
sample_rate: int | None,
) -> None:
self._output_ch = output_ch
self._sample_rate = sample_rate
self._state = _WavState.RIFF_HEADER
self._hdr_buf = bytearray()
self._need = 12 # bytes needed for current header state
self._skip_remaining = 0
self._chunk_size = 0
# set after fmt is parsed
self._bstream: AudioByteStream | None = None
self._resampler: rtc.AudioResampler | None = None
self._wave_channels = 0
self._wave_rate = 0
def push(self, data: bytes) -> None:
if self._state == _WavState.STREAMING:
if len(data) >= 4 and data[:4] == self._RIFF_MAGIC:
self._flush_current()
self._reset_state()
else:
self._push_pcm(data)
return
buf = memoryview(data)
pos = 0
while pos < len(buf):
if self._state == _WavState.RIFF_HEADER:
pos = self._consume_riff(buf, pos)
elif self._state == _WavState.CHUNK_HEADER:
pos = self._consume_chunk_header(buf, pos)
elif self._state == _WavState.FMT_DATA:
pos = self._consume_fmt_data(buf, pos)
elif self._state == _WavState.SKIP_CHUNK_DATA:
pos = self._consume_skip(buf, pos)
elif self._state == _WavState.STREAMING:
# remainder after headers goes straight to PCM path
self._push_pcm(bytes(buf[pos:]))
return
def flush(self) -> None:
self._flush_current()
def _flush_current(self) -> None:
"""Flush AudioByteStream and resampler for the current WAV segment."""
if self._bstream is not None:
remaining = self._bstream.flush()
if self._resampler is not None:
for frame in remaining:
for resampled in self._resampler.push(frame):
self._output_ch.send_nowait(resampled)
for frame in self._resampler.flush():
if frame.samples_per_channel > 0:
self._output_ch.send_nowait(frame)
else:
for frame in remaining:
self._output_ch.send_nowait(frame)
def _reset_state(self) -> None:
"""Reset the state machine to parse a new WAV file."""
self._state = _WavState.RIFF_HEADER
self._hdr_buf.clear()
self._need = 12
self._skip_remaining = 0
self._chunk_size = 0
self._bstream = None
self._resampler = None
self._wave_channels = 0
self._wave_rate = 0
# -- state handlers -------------------------------------------------------
def _consume_riff(self, buf: memoryview, pos: int) -> int:
take = min(self._need - len(self._hdr_buf), len(buf) - pos)
self._hdr_buf.extend(buf[pos : pos + take])
pos += take
if len(self._hdr_buf) < self._need:
return pos
if self._hdr_buf[:4] != b"RIFF" or self._hdr_buf[8:12] != b"WAVE":
raise ValueError(f"Invalid WAV file: missing RIFF/WAVE: {bytes(self._hdr_buf)!r}")
self._hdr_buf.clear()
self._need = 8
self._state = _WavState.CHUNK_HEADER
return pos
def _consume_chunk_header(self, buf: memoryview, pos: int) -> int:
take = min(self._need - len(self._hdr_buf), len(buf) - pos)
self._hdr_buf.extend(buf[pos : pos + take])
pos += take
if len(self._hdr_buf) < self._need:
return pos
chunk_id, chunk_size = struct.unpack("<4sI", bytes(self._hdr_buf[:8]))
self._hdr_buf.clear()
self._chunk_size = chunk_size
if chunk_id == b"fmt ":
self._need = chunk_size
self._state = _WavState.FMT_DATA
elif chunk_id == b"data":
self._init_streaming()
self._state = _WavState.STREAMING
else:
self._skip_remaining = chunk_size
self._state = _WavState.SKIP_CHUNK_DATA
return pos
def _consume_fmt_data(self, buf: memoryview, pos: int) -> int:
take = min(self._need - len(self._hdr_buf), len(buf) - pos)
self._hdr_buf.extend(buf[pos : pos + take])
pos += take
if len(self._hdr_buf) < self._need:
return pos
fmt = bytes(self._hdr_buf[: self._chunk_size])
audio_format, channels, rate = struct.unpack("<HHI", fmt[:8])
if len(fmt) >= 16:
bits_per_sample = struct.unpack("<H", fmt[14:16])[0]
if bits_per_sample != 16:
raise ValueError(
f"Unsupported WAV bits per sample: {bits_per_sample}"
" (only 16-bit PCM supported)"
)
if audio_format != 1:
raise ValueError(f"Unsupported WAV audio format: {audio_format}")
self._wave_channels = channels
self._wave_rate = rate
self._hdr_buf.clear()
self._need = 8
self._state = _WavState.CHUNK_HEADER
return pos
def _consume_skip(self, buf: memoryview, pos: int) -> int:
take = min(self._skip_remaining, len(buf) - pos)
self._skip_remaining -= take
pos += take
if self._skip_remaining == 0:
self._hdr_buf.clear()
self._need = 8
self._state = _WavState.CHUNK_HEADER
return pos
# -- streaming helpers ----------------------------------------------------
def _init_streaming(self) -> None:
if self._wave_rate == 0:
raise ValueError("Invalid WAV file: data chunk before fmt chunk")
self._bstream = AudioByteStream(
sample_rate=self._wave_rate, num_channels=self._wave_channels
)
if self._sample_rate is not None and self._sample_rate != self._wave_rate:
self._resampler = rtc.AudioResampler(
input_rate=self._wave_rate,
output_rate=self._sample_rate,
num_channels=self._wave_channels,
)
def _push_pcm(self, data: bytes) -> None:
assert self._bstream is not None
if self._resampler is not None:
for frame in self._bstream.push(data):
for resampled in self._resampler.push(frame):
self._output_ch.send_nowait(resampled)
else:
for frame in self._bstream.push(data):
self._output_ch.send_nowait(frame)
class AudioStreamDecoder:
"""A class that can be used to decode audio stream into PCM AudioFrames.
Decoders are stateful, and it should not be reused across multiple streams. Each decoder
is designed to decode a single stream.
"""
def __init__(
self,
*,
sample_rate: int | None = 48000,
num_channels: int | None = 1,
format: str | None = None,
):
self._sample_rate = sample_rate
self._layout = "mono"
if num_channels == 2:
self._layout = "stereo"
self._mime_type = format.lower() if format else None
self._av_format = _mime_to_av_format(self._mime_type)
self._is_wav = self._av_format == "wav"
self._output_ch = aio.Chan[rtc.AudioFrame]()
self._closed = False
self._started = False
self._loop = asyncio.get_event_loop()
# lazy-initialized only for non-WAV codecs
self._input_buf: StreamBuffer | None = None
self._executor: ThreadPoolExecutor | None = None
# lazy-initialized only for WAV
self._wav_decoder: _WavInlineDecoder | None = None
def push(self, chunk: bytes) -> None:
if self._is_wav:
if self._wav_decoder is None:
self._wav_decoder = _WavInlineDecoder(self._output_ch, self._sample_rate)
try:
self._wav_decoder.push(chunk)
except Exception:
if not self._closed:
logger.exception("error decoding WAV audio")
self._output_ch.close()
self._closed = True
return
self._started = True
return
if self._input_buf is None:
self._input_buf = StreamBuffer()
self._executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="AudioDecoder")
self._input_buf.write(chunk)
if not self._started:
self._started = True
self._loop.run_in_executor(self._executor, self._decode_loop)
def end_input(self) -> None:
if self._is_wav:
if self._wav_decoder is not None and not self._closed:
try:
self._wav_decoder.flush()
except Exception:
logger.exception("error flushing WAV audio")
if not self._closed:
self._output_ch.close()
return
if self._input_buf is not None:
self._input_buf.end_input()
if not self._started:
self._output_ch.close()
def _decode_loop(self) -> None:
container: av.container.InputContainer | None = None
resampler: av.AudioResampler | None = None
try:
# open container in low-latency streaming mode
container = av.open(
self._input_buf,
mode="r",
format=self._av_format,
buffer_size=256,
options={
"probesize": "32",
"analyzeduration": "0",
"fflags": "nobuffer+flush_packets",
"flags": "low_delay",
"reorder_queue_size": "0",
"max_delay": "0",
"avioflags": "direct",
},
)
# explicitly disable internal buffering flags on the FFmpeg container
container.flags |= cast(
int, av.container.Flags.no_buffer.value | av.container.Flags.flush_packets.value
)
if len(container.streams.audio) == 0:
raise ValueError("no audio stream found")
audio_stream = container.streams.audio[0]
# Set up resampler only if needed
if self._sample_rate is not None or self._layout is not None:
resampler = av.AudioResampler(
format="s16", layout=self._layout, rate=self._sample_rate
)
for frame in container.decode(audio_stream):
if self._closed:
return
if resampler:
frames = resampler.resample(frame)
else:
frames = [frame]
for f in frames:
self._emit_av_frame(f)
# flush the resampler to get any remaining buffered samples
if resampler and not self._closed:
for f in resampler.resample(None):
self._emit_av_frame(f)
except Exception:
logger.exception("error decoding audio")
finally:
self._loop.call_soon_threadsafe(self._output_ch.close)
if container:
container.close()
def _emit_av_frame(self, f: av.AudioFrame) -> None:
self._loop.call_soon_threadsafe(
self._output_ch.send_nowait,
rtc.AudioFrame(
data=f.to_ndarray().tobytes(),
num_channels=len(f.layout.channels),
sample_rate=int(f.sample_rate),
samples_per_channel=f.samples,
),
)
def __aiter__(self) -> AsyncIterator[rtc.AudioFrame]:
return self
async def __anext__(self) -> rtc.AudioFrame:
return await self._output_ch.__anext__()
async def aclose(self) -> None:
if self._closed:
return
self.end_input()
self._closed = True
if self._input_buf is not None:
self._input_buf.close()
if not self._started:
return
try:
async for _ in self._output_ch:
pass
finally:
if self._executor is not None:
self._executor.shutdown(wait=False, cancel_futures=True)