Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 20 additions & 36 deletions mmif/utils/audio_document_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,28 +30,31 @@ def iter_audio_windows(
min_last_window_ms: int = 0,
) -> Iterator[tuple["numpy.ndarray", int, int]]:
"""
Decodes an audio document, or the audio track of a video, into windows.
Reads the audio of an audio or video document as windows of samples.

Samples are resampled to mono ``float32`` at ``sample_rate`` and decoded
incrementally, so memory use is bounded by one window plus its overlap.
The windows are those of :func:`~mmif.utils.timeunit_helper.iter_time_windows`
from the start of the audio stream, which can be later than zero. After
the last full window, one window runs from the next window start to the
end of the stream, unless it is shorter than ``min_last_window_ms``.
The audio is mixed down to mono and resampled to ``sample_rate``. The
file is decoded while the windows are read, so a long file is never held
in memory. Times are in ms on the media timeline: the first window
starts where the audio stream starts, which in a video can be later than
0. The last window ends at the end of the audio and can be shorter than
``window_ms``.

:param document: audio or video document with an audio stream
:param window_ms: window length in milliseconds
:param stride_ms: distance between window starts; defaults to ``window_ms``
:param window_ms: window length in ms
:param stride_ms: distance between window starts in ms; defaults to
``window_ms``, which gives back-to-back windows
:param sample_rate: output sample rate in Hz
:param min_last_window_ms: shortest final window to keep
:returns: ``(samples, start_ms, end_ms)`` triples
:param min_last_window_ms: a last window shorter than this is left out
:returns: ``(samples, start_ms, end_ms)`` per window, with ``samples`` a
1-D ``float32`` array
:raises ValueError: if a size is not positive, ``min_last_window_ms`` is
outside ``[0, window_ms]``, or the document has an unsupported type
:raises FileNotFoundError: if there is no document or no file
:raises RuntimeError: if the file cannot be opened, has no audio stream,
or fails to decode
:warns UserWarning: if the decoded audio ends more than
``TRUNCATION_TOLERANCE_MS`` before the duration the file states
:warns UserWarning: if the audio ends more than
``TRUNCATION_TOLERANCE_MS`` before the length the file states, which
usually means the file is truncated
"""
if window_ms <= 0:
raise ValueError('window_ms must be positive')
Expand Down Expand Up @@ -87,7 +90,6 @@ def _iter_audio_windows(document, window_ms, stride_ms, sample_rate, min_last_wi
chunks = [] # decoded samples from `buffer_start` on, in arrival order
buffer_start = 0
decoded_end = 0
window_samples = round(window_ms * sample_rate / 1000)
min_last_samples = max(1, round(min_last_window_ms * sample_rate / 1000))

def to_sample(ms):
Expand Down Expand Up @@ -160,22 +162,6 @@ def flush():
origin_s = frame_s if frame_s is not None else stream_start_s
origin_ms = round(origin_s * 1000)
windows = iter_time_windows(origin_ms, None, window_ms, stride_ms)
if frame_s is not None:
# a timestamp jump is a gap (filled with silence) or an
# overlap (dropped) on the sample timeline
frame_start = round((frame_s - origin_s) * sample_rate)
gap = frame_start - decoded_end - fifo.samples
if gap:
yield from flush()
while gap > 0:
append(numpy.zeros(min(gap, window_samples), numpy.float32))
yield from emit()
gap = frame_start - decoded_end
if gap < 0:
samples = frame.to_ndarray().mean(axis=0, dtype=numpy.float32)
append(samples[-gap:])
yield from emit()
continue
# the fifo checks pts against its own count from zero
frame.pts = None
fifo.write(frame)
Expand All @@ -202,12 +188,10 @@ def flush():

def _stated_end_s(av, container, stream, stream_start_s):
"""
The end of the audio stream according to the file's metadata, in
seconds, or ``None`` when the file does not state it. A container's own
duration counts only when it holds no other stream, since it covers the
longest stream (a video's audio can end before its picture). A WAV file
cut short states its shorter length, since FFmpeg takes it from the file
size, so its truncation cannot be seen here.
End of the audio stream in seconds as the file metadata states it, or
``None``. The container duration is used only when the file has no other
stream, because it is the length of the longest stream. A truncated WAV
file states its truncated length, so it is not detected.
"""
if stream.duration is not None and stream.time_base is not None:
return stream_start_s + float(stream.duration * stream.time_base)
Expand Down
26 changes: 14 additions & 12 deletions mmif/utils/av_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,12 @@ def _ffmpeg_op(
media_type: str,
) -> Iterator[None]:
"""
Turns failed PyAV operations into actionable ``RuntimeError`` exceptions.
Turns a PyAV error in the block (or an ``IndexError`` for a missing
stream) into a ``RuntimeError`` that names the document and the file and
gives the FFmpeg check command.

:param action: operation attempted, as a verb phrase
:param document: MMIF document being read
:param path: resolved local path of the media document
:param media_type: stream type being read, ``"audio"`` or ``"video"``
:raises RuntimeError: if the operation fails
:param action: verb for the message, such as ``'decode'``
:param media_type: ``"audio"`` or ``"video"``
"""
av = _check_av_dep()
try:
Expand All @@ -61,8 +60,11 @@ def _ffmpeg_op(

def _check_document(document: Document, document_types: tuple[object, ...]) -> None:
"""
Checks that there is a document and that it has one of
``document_types``.

:raises FileNotFoundError: if there is no document
:raises ValueError: if the document has an unsupported type
:raises ValueError: if the document has another type
"""
if document is None:
raise FileNotFoundError('The document does not exist.')
Expand All @@ -77,12 +79,12 @@ def _open_container_and_stream(
document_types: tuple[object, ...],
) -> tuple[Any, Any, str]:
"""
Opens a media container and selects its first stream of the requested type.
Opens the media file of a document. The caller must close the container.

:param document: MMIF document holding the media file
:param media_type: stream type to select, ``"audio"`` or ``"video"``
:param document_types: accepted MMIF document types
:returns: open PyAV container, selected stream, and resolved local path
:param media_type: ``"audio"`` or ``"video"``; the first stream of this
type is returned
:param document_types: document types to accept
:returns: PyAV container, stream, and local path of the file
:raises FileNotFoundError: if there is no document or no file
:raises ValueError: if the document has an unsupported type
:raises RuntimeError: if the container or requested stream cannot be read
Expand Down
Loading
Loading