reactor-runtime 2.3.2__tar.gz → 2.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/PKG-INFO +2 -2
  2. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/pyproject.toml +2 -2
  3. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/__init__.py +2 -0
  4. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/interface/__init__.py +6 -0
  5. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/interface/driver/pipeline_executor.py +24 -6
  6. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/interface/internal/input_buffer.py +33 -20
  7. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/interface/internal/reactor_core.py +11 -3
  8. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/runtimes/headless/input_feeder.py +11 -2
  9. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/runtimes/http/http_runtime.py +23 -8
  10. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/__init__.py +6 -0
  11. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/aiortc/audio_track.py +148 -2
  12. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/aiortc/client.py +102 -4
  13. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/events.py +23 -3
  14. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/gstreamer/client.py +133 -37
  15. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/gstreamer/decoders/__init__.py +2 -0
  16. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/gstreamer/decoders/factory.py +5 -1
  17. reactor_runtime-2.4.0/src/reactor_runtime/transports/gstreamer/decoders/opus.py +54 -0
  18. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/gstreamer/receiver/audio.py +15 -16
  19. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/media.py +43 -0
  20. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime.egg-info/PKG-INFO +2 -2
  21. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime.egg-info/SOURCES.txt +1 -0
  22. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime.egg-info/requires.txt +1 -1
  23. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/README.md +0 -0
  24. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/setup.cfg +0 -0
  25. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/api/__init__.py +0 -0
  26. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_cli/commands/__init__.py +0 -0
  27. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_cli/commands/init.py +0 -0
  28. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_cli/commands/run.py +0 -0
  29. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_cli/commands/schema.py +0 -0
  30. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_cli/main.py +0 -0
  31. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_cli/utils/__init__.py +0 -0
  32. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_cli/utils/config.py +0 -0
  33. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_cli/utils/runtime.py +0 -0
  34. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_cli/utils/version.py +0 -0
  35. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/config.py +0 -0
  36. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/interface/defaults.py +0 -0
  37. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/interface/driver/__init__.py +0 -0
  38. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/interface/driver/step_result.py +0 -0
  39. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/interface/events/__init__.py +0 -0
  40. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/interface/events/connected.py +0 -0
  41. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/interface/events/event.py +0 -0
  42. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/interface/events/messages.py +0 -0
  43. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/interface/events/upload.py +0 -0
  44. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/interface/internal/__init__.py +0 -0
  45. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/interface/internal/output_buffer.py +0 -0
  46. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/interface/model/__init__.py +0 -0
  47. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/interface/model/decorators.py +0 -0
  48. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/interface/model/handlers.py +0 -0
  49. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/interface/model/reactor_model.py +0 -0
  50. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/interface/pipeline/__init__.py +0 -0
  51. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/interface/pipeline/idle.py +0 -0
  52. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/interface/pipeline/input_state.py +0 -0
  53. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/interface/pipeline/reactor_pipeline.py +0 -0
  54. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/interface/tracks/__init__.py +0 -0
  55. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/interface/tracks/descriptors.py +0 -0
  56. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/interface/tracks/input.py +0 -0
  57. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/interface/tracks/output.py +0 -0
  58. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/interface/upload.py +0 -0
  59. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/model_state.py +0 -0
  60. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/profiling/__init__.py +0 -0
  61. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/profiling/backends/__init__.py +0 -0
  62. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/profiling/backends/base.py +0 -0
  63. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/profiling/backends/file.py +0 -0
  64. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/profiling/backends/otlp.py +0 -0
  65. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/profiling/helpers.py +0 -0
  66. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/profiling/plotting/__init__.py +0 -0
  67. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/profiling/plotting/plot_profiling.py +0 -0
  68. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/profiling/profiler.py +0 -0
  69. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/profiling/singleton.py +0 -0
  70. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/runtime_api.py +0 -0
  71. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/runtimes/headless/config.py +0 -0
  72. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/runtimes/headless/headless_runtime.py +0 -0
  73. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/runtimes/http/config.py +0 -0
  74. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/runtimes/http/types.py +0 -0
  75. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/schema.py +0 -0
  76. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/schema_validator.py +0 -0
  77. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/aiortc/__init__.py +0 -0
  78. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/aiortc/frame_conversion.py +0 -0
  79. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/aiortc/ice_connection.py +0 -0
  80. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/aiortc/video_track.py +0 -0
  81. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/config.py +0 -0
  82. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/gstreamer/__init__.py +0 -0
  83. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/gstreamer/decoders/av1.py +0 -0
  84. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/gstreamer/decoders/base.py +0 -0
  85. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/gstreamer/decoders/h264.py +0 -0
  86. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/gstreamer/decoders/h265.py +0 -0
  87. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/gstreamer/decoders/vp8.py +0 -0
  88. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/gstreamer/decoders/vp9.py +0 -0
  89. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/gstreamer/encoders/__init__.py +0 -0
  90. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/gstreamer/encoders/av1.py +0 -0
  91. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/gstreamer/encoders/base.py +0 -0
  92. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/gstreamer/encoders/factory.py +0 -0
  93. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/gstreamer/encoders/h264.py +0 -0
  94. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/gstreamer/encoders/h265.py +0 -0
  95. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/gstreamer/encoders/opus.py +0 -0
  96. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/gstreamer/encoders/vp8.py +0 -0
  97. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/gstreamer/encoders/vp9.py +0 -0
  98. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/gstreamer/gst.py +0 -0
  99. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/gstreamer/gst_helpers.py +0 -0
  100. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/gstreamer/probes/__init__.py +0 -0
  101. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/gstreamer/probes/fps_probe.py +0 -0
  102. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/gstreamer/receiver/__init__.py +0 -0
  103. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/gstreamer/receiver/base.py +0 -0
  104. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/gstreamer/receiver/video.py +0 -0
  105. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/gstreamer/sdp/__init__.py +0 -0
  106. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/gstreamer/sdp/bundle.py +0 -0
  107. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/gstreamer/sdp/codec.py +0 -0
  108. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/gstreamer/sdp/extmap.py +0 -0
  109. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/gstreamer/sdp/ice.py +0 -0
  110. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/gstreamer/sender/__init__.py +0 -0
  111. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/gstreamer/sender/audio.py +0 -0
  112. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/gstreamer/sender/base.py +0 -0
  113. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/gstreamer/sender/video.py +0 -0
  114. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/gstreamer/settings.py +0 -0
  115. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/gstreamer/signals.py +0 -0
  116. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/ice_uris.py +0 -0
  117. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/interface.py +0 -0
  118. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/transports/types.py +0 -0
  119. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/utils/launch.py +0 -0
  120. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/utils/loader.py +0 -0
  121. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/utils/log.py +0 -0
  122. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/utils/messages.py +0 -0
  123. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime/utils/typing.py +0 -0
  124. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime.egg-info/dependency_links.txt +0 -0
  125. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime.egg-info/entry_points.txt +0 -0
  126. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/reactor_runtime.egg-info/top_level.txt +0 -0
  127. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/template/README.md +0 -0
  128. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/template/__init__.py +0 -0
  129. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/template/config.yml +0 -0
  130. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/template/model.py +0 -0
  131. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/template/pipeline.py +0 -0
  132. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/template/reactor.yaml +0 -0
  133. {reactor_runtime-2.3.2 → reactor_runtime-2.4.0}/src/template/requirements.txt +0 -0
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reactor_runtime
3
- Version: 2.3.2
3
+ Version: 2.4.0
4
4
  Summary: Reactor runtime with public model API
5
5
  Author-email: Reactor <team@reactor.inc>
6
6
  Requires-Python: >=3.9
7
7
  Description-Content-Type: text/markdown
8
- Requires-Dist: numpy<2.0
8
+ Requires-Dist: numpy>=1.24.0
9
9
  Requires-Dist: pydantic>=2.0.0
10
10
  Requires-Dist: omegaconf>=2.3.0
11
11
  Requires-Dist: av>=14.0.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "reactor_runtime"
7
- version = "2.3.2"
7
+ version = "2.4.0"
8
8
  description = "Reactor runtime with public model API"
9
9
  authors = [
10
10
  { name = "Reactor", email = "team@reactor.inc" }
@@ -13,7 +13,7 @@ readme = "README.md"
13
13
  requires-python = ">=3.9"
14
14
 
15
15
  dependencies = [
16
- "numpy<2.0",
16
+ "numpy>=1.24.0",
17
17
  "pydantic>=2.0.0",
18
18
  "omegaconf>=2.3.0",
19
19
  "av>=14.0.0",
@@ -14,6 +14,7 @@ from reactor_runtime.interface import (
14
14
  MESSAGE_REGISTRY,
15
15
  FieldInfo,
16
16
  InputField,
17
+ InputFrame,
17
18
  Output,
18
19
  Input,
19
20
  Video,
@@ -45,6 +46,7 @@ __all__ = [
45
46
  "MESSAGE_REGISTRY",
46
47
  "FieldInfo",
47
48
  "InputField",
49
+ "InputFrame",
48
50
  "Output",
49
51
  "Input",
50
52
  "Video",
@@ -54,6 +54,10 @@ from reactor_runtime.interface.internal.input_buffer import (
54
54
  ReadMode,
55
55
  )
56
56
 
57
+ # Inbound frame with timing metadata (re-exported from transports so model
58
+ # authors can ``from reactor_runtime.interface import InputFrame``).
59
+ from reactor_runtime.transports.media import InputFrame
60
+
57
61
  __all__ = [
58
62
  # Tracks
59
63
  "Output",
@@ -95,4 +99,6 @@ __all__ = [
95
99
  "InputBuffer",
96
100
  "BufferClosed",
97
101
  "ReadMode",
102
+ # Inbound frame
103
+ "InputFrame",
98
104
  ]
@@ -6,7 +6,9 @@ from __future__ import annotations
6
6
  import asyncio
7
7
  import dataclasses
8
8
  import inspect
9
- from typing import Any, Dict, Iterator, List, Optional, Type
9
+ from typing import Any, Dict, Iterator, List, Optional, Type, Union
10
+
11
+ import numpy as np
10
12
 
11
13
  from reactor_runtime.interface.driver.step_result import StepResult
12
14
  from reactor_runtime.interface.events.messages import ModelMessage
@@ -17,6 +19,7 @@ from reactor_runtime.interface.pipeline.reactor_pipeline import (
17
19
  GeneratorEnded,
18
20
  ReactorPipeline,
19
21
  )
22
+ from reactor_runtime.transports.media import InputFrame
20
23
 
21
24
 
22
25
  class PipelineExecutor(Iterator[StepResult]):
@@ -166,14 +169,29 @@ class PipelineExecutor(Iterator[StepResult]):
166
169
  }
167
170
  return self._run(self._send_event(entry, **handler_kwargs))
168
171
 
169
- def push_media(self, track_name: str, data: Any) -> None:
172
+ def push_media(self, track_name: str, data: Union[np.ndarray, InputFrame]) -> None:
170
173
  """Push a media frame into an input buffer.
171
174
 
172
- The frame becomes available to the model on the next
173
- iteration when ``inference()`` calls ``try_read()`` or
174
- ``read()``.
175
+ Accepts either a raw ``np.ndarray`` (auto-wrapped into an
176
+ :class:`~reactor_runtime.transports.media.InputFrame` with
177
+ ``pts=None``) or a prebuilt :class:`InputFrame` for callers
178
+ that want to stamp the frame with an explicit presentation
179
+ timestamp.
180
+
181
+ The frame becomes available to the model on the next iteration
182
+ when ``inference()`` calls ``try_read()`` / ``read()`` — both
183
+ of which now return ``List[InputFrame]``.
175
184
  """
176
- self._model._push_media(track_name, data)
185
+ if isinstance(data, InputFrame):
186
+ frame = data
187
+ elif isinstance(data, np.ndarray):
188
+ frame = InputFrame(data=data)
189
+ else:
190
+ raise TypeError(
191
+ "push_media expects an np.ndarray or InputFrame, got "
192
+ f"{type(data).__name__}"
193
+ )
194
+ self._model._push_media(track_name, frame)
177
195
 
178
196
  def disconnect(self) -> List[ModelMessage]:
179
197
  """End the current session.
@@ -26,7 +26,7 @@ from collections import deque
26
26
  from enum import Enum, auto
27
27
  from typing import List, Optional
28
28
 
29
- import numpy as np
29
+ from reactor_runtime.transports.media import InputFrame
30
30
 
31
31
 
32
32
  class ReadMode(Enum):
@@ -64,12 +64,16 @@ class InputBuffer:
64
64
  Accessed via :class:`Input` track handles — model authors never
65
65
  instantiate this class directly.
66
66
 
67
- Read API:
67
+ Read API (returns :class:`~reactor_runtime.transports.media.InputFrame`):
68
68
 
69
69
  - :meth:`read` — async, blocks until *n* frames are available.
70
70
  - :meth:`try_read` — sync, returns immediately (latest frame or
71
71
  ``None``).
72
72
 
73
+ Both return ``List[InputFrame]``. Callers unwrap the ``.data``
74
+ ``np.ndarray`` for numpy operations and ``.pts`` when they need
75
+ the presentation timestamp for cross-track alignment.
76
+
73
77
  Lifecycle:
74
78
 
75
79
  - :meth:`close` — signal end-of-input (raises :class:`BufferClosed`
@@ -93,7 +97,7 @@ class InputBuffer:
93
97
  self._lock = threading.Lock()
94
98
  self._condition = threading.Condition(self._lock)
95
99
 
96
- self._buffer: deque[np.ndarray] = deque(maxlen=maxlen)
100
+ self._buffer: deque[InputFrame] = deque(maxlen=maxlen)
97
101
  self._total_received: int = 0
98
102
  self._closed: bool = False
99
103
 
@@ -106,8 +110,8 @@ class InputBuffer:
106
110
  n: int = 1,
107
111
  timeout: Optional[float] = None,
108
112
  mode: ReadMode = ReadMode.LATEST,
109
- ) -> List[np.ndarray]:
110
- """Return *n* frames, waiting if necessary.
113
+ ) -> List[InputFrame]:
114
+ """Return *n* :class:`InputFrame` objects, waiting if necessary.
111
115
 
112
116
  Blocks the calling coroutine (via ``asyncio.to_thread``) until
113
117
  at least *n* frames are present in the buffer, then returns
@@ -118,7 +122,8 @@ class InputBuffer:
118
122
  Typical usage::
119
123
 
120
124
  frames = await self.input.camera.read(4) # newest 4
121
- frames = await self.input.camera.read(4, mode=ReadMode.FIFO) # oldest 4
125
+ latest = frames[-1].data # np.ndarray
126
+ pts = frames[-1].pts # seconds or None
122
127
 
123
128
  Args:
124
129
  n: Number of frames to return.
@@ -130,7 +135,11 @@ class InputBuffer:
130
135
  leaving newer frames in the buffer.
131
136
 
132
137
  Returns:
133
- A list of *n* ``np.ndarray`` frames (HWC, uint8).
138
+ A list of *n* :class:`InputFrame` objects. Each carries
139
+ ``data`` (the numpy payload — HWC uint8 RGB for video,
140
+ ``(1, M)`` int16 for audio) and ``pts`` (presentation
141
+ timestamp in seconds, or ``None`` when the transport did
142
+ not provide one).
134
143
 
135
144
  Raises:
136
145
  BufferClosed: The client disconnected while waiting.
@@ -144,7 +153,7 @@ class InputBuffer:
144
153
  n: int,
145
154
  timeout: Optional[float],
146
155
  mode: ReadMode = ReadMode.LATEST,
147
- ) -> List[np.ndarray]:
156
+ ) -> List[InputFrame]:
148
157
  if self._buffer.maxlen is not None and n > self._buffer.maxlen:
149
158
  raise ValueError(
150
159
  f"Requested {n} frames but buffer capacity is {self._buffer.maxlen}. "
@@ -165,24 +174,27 @@ class InputBuffer:
165
174
  return self._read_latest(n)
166
175
  return self._read_fifo(n)
167
176
 
168
- def _read_latest(self, n: int) -> List[np.ndarray]:
169
- """Return the *n* newest frames and discard the rest. Caller holds the lock."""
177
+ def _read_latest(self, n: int) -> List[InputFrame]:
178
+ """Return the *n* newest :class:`InputFrame` objects and discard the rest.
179
+
180
+ Caller holds the lock.
181
+ """
170
182
  buf_len = len(self._buffer)
171
183
  start = buf_len - n
172
184
  result = [self._buffer[start + i] for i in range(n)]
173
185
  self._buffer.clear()
174
186
  return result
175
187
 
176
- def _read_fifo(self, n: int) -> List[np.ndarray]:
177
- """Pop the *n* oldest frames. Caller holds the lock."""
188
+ def _read_fifo(self, n: int) -> List[InputFrame]:
189
+ """Pop the *n* oldest :class:`InputFrame` objects. Caller holds the lock."""
178
190
  return [self._buffer.popleft() for _ in range(n)]
179
191
 
180
192
  def try_read(
181
193
  self,
182
194
  n: int = 1,
183
195
  mode: ReadMode = ReadMode.LATEST,
184
- ) -> Optional[List[np.ndarray]]:
185
- """Try to read *n* frames without blocking.
196
+ ) -> Optional[List[InputFrame]]:
197
+ """Try to read *n* :class:`InputFrame` objects without blocking.
186
198
 
187
199
  Returns ``None`` when fewer than *n* frames are available —
188
200
  the buffer is left untouched so nothing is lost.
@@ -195,7 +207,7 @@ class InputBuffer:
195
207
  leaving newer frames in the buffer.
196
208
 
197
209
  Returns:
198
- A list of *n* ``np.ndarray`` frames, or ``None`` if
210
+ A list of *n* :class:`InputFrame` objects, or ``None`` if
199
211
  fewer than *n* are available.
200
212
 
201
213
  Raises:
@@ -274,14 +286,15 @@ class InputBuffer:
274
286
  #
275
287
  # Call chain:
276
288
  # WebRTC transport (on_track / on_frame callback)
277
- # → ReactorCore._push_media(track_name, data)
278
- # → InputBuffer._push(data)
289
+ # → VideoFrameEvent(frame=InputFrame(data, pts), track_name=...)
290
+ # → ReactorCore._push_media(track_name, frame)
291
+ # → InputBuffer._push(frame)
279
292
  #
280
293
  # Thread-safe: the model thread may be blocked in read()
281
294
  # concurrently — notify_all wakes it.
282
295
 
283
- def _push(self, data: np.ndarray) -> None:
284
- """Append a media frame (called by the runtime, not model code).
296
+ def _push(self, frame: InputFrame) -> None:
297
+ """Append an :class:`InputFrame` (called by the runtime, not model code).
285
298
 
286
299
  Silently drops the frame if the buffer has been closed.
287
300
  When full, the oldest frame is evicted (bounded deque).
@@ -289,7 +302,7 @@ class InputBuffer:
289
302
  with self._condition:
290
303
  if self._closed:
291
304
  return
292
- self._buffer.append(data)
305
+ self._buffer.append(frame)
293
306
  self._total_received += 1
294
307
  # Wake _read() if it's sleeping in wait_for() — it will
295
308
  # re-acquire the lock and re-check len(buffer) >= n.
@@ -28,6 +28,7 @@ from reactor_runtime.interface.internal.input_buffer import InputBuffer
28
28
  from reactor_runtime.interface.tracks.descriptors import TRACK_MARKERS
29
29
  from reactor_runtime.interface.tracks.input import INPUT_REGISTRY, Input
30
30
  from reactor_runtime.interface.tracks.output import Output
31
+ from reactor_runtime.transports.media import InputFrame
31
32
  from reactor_runtime.utils.log import get_logger
32
33
 
33
34
  logger = get_logger(__name__)
@@ -198,13 +199,20 @@ class ReactorCore:
198
199
  except RuntimeError:
199
200
  pass
200
201
 
201
- def _push_media(self, track_name: str, data: Any) -> None:
202
- """Thread-safe: push media into the corresponding InputBuffer."""
202
+ def _push_media(self, track_name: str, frame: InputFrame) -> None:
203
+ """Thread-safe: push an :class:`InputFrame` into the matching InputBuffer.
204
+
205
+ Strict typing: callers must build an :class:`InputFrame` (transports
206
+ do so at the ingress boundary). Permissive auto-wrapping of a bare
207
+ ``np.ndarray`` happens at the outer public surface
208
+ (``PipelineExecutor.push_media``) — not here — so this stays a
209
+ simple threadsafe dispatch.
210
+ """
203
211
  if self._loop.is_closed():
204
212
  return
205
213
  buf = self._input_buffers.get(track_name)
206
214
  if buf is not None:
207
- buf._push(data)
215
+ buf._push(frame)
208
216
  else:
209
217
  logger.debug(
210
218
  "Received media for unknown input track",
@@ -17,7 +17,7 @@ from typing import Optional
17
17
  import cv2
18
18
 
19
19
  from reactor_runtime.interface.internal.reactor_core import ReactorCore
20
- from reactor_runtime.transports.media import MediaBundle
20
+ from reactor_runtime.transports.media import InputFrame, MediaBundle
21
21
  from reactor_runtime.utils.log import get_logger
22
22
 
23
23
  logger = get_logger(__name__)
@@ -222,9 +222,18 @@ class InputFrameFeeder:
222
222
  continue
223
223
 
224
224
  frame_rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)
225
+ # Synthesise a presentation timestamp from the frame index
226
+ # when running at a fixed input fps, so downstream model
227
+ # code reading via ``read()`` / ``try_read()`` sees
228
+ # monotonically increasing ``pts``. Without an fps target
229
+ # we leave ``pts`` unset (``None``).
230
+ pts = (i / self._input_fps) if self._input_fps else None
225
231
  bundle = MediaBundle.from_video_frame(frame_rgb)
226
232
  for track_name, track_data in bundle.tracks.items():
227
- self._model._push_media(track_name, track_data.data)
233
+ self._model._push_media(
234
+ track_name,
235
+ InputFrame(data=track_data.data, pts=pts),
236
+ )
228
237
  fed += 1
229
238
  self.frames_fed = fed
230
239
 
@@ -25,7 +25,7 @@ import asyncio
25
25
  import os
26
26
  import uuid
27
27
  from typing import Any, Dict, Optional
28
- import numpy as np
28
+
29
29
  from fastapi import FastAPI, HTTPException, Request
30
30
  from importlib.metadata import version as _pkg_version, PackageNotFoundError
31
31
 
@@ -38,6 +38,7 @@ import uvicorn
38
38
 
39
39
  from reactor_runtime.runtime_api import Runtime
40
40
  from reactor_runtime.transports.media import (
41
+ InputFrame,
41
42
  MediaBundle,
42
43
  TrackMapping,
43
44
  )
@@ -48,6 +49,7 @@ from reactor_runtime.transports import (
48
49
  EventType,
49
50
  MessageEvent,
50
51
  DisconnectedEvent,
52
+ AudioFrameEvent,
51
53
  VideoFrameEvent,
52
54
  PingTimeoutEvent,
53
55
  )
@@ -471,6 +473,9 @@ class HttpRuntime(Runtime):
471
473
  def on_video_frame(event: VideoFrameEvent):
472
474
  self._on_incoming_video_frame(event.frame, event.track_name)
473
475
 
476
+ def on_audio_frame(event: AudioFrameEvent):
477
+ self._on_incoming_audio_frame(event.frame, event.track_name)
478
+
474
479
  def on_ping_timeout(event: PingTimeoutEvent):
475
480
  if not self.loop.is_closed():
476
481
  self.loop.call_soon_threadsafe(
@@ -480,6 +485,7 @@ class HttpRuntime(Runtime):
480
485
  client.on(EventType.MESSAGE, on_message)
481
486
  client.on(EventType.DISCONNECTED, on_disconnect)
482
487
  client.on(EventType.VIDEO_FRAME, on_video_frame)
488
+ client.on(EventType.AUDIO_FRAME, on_audio_frame)
483
489
  client.on(EventType.PING_TIMEOUT, on_ping_timeout)
484
490
 
485
491
  async def _stop_webrtc_client(self) -> None:
@@ -512,16 +518,25 @@ class HttpRuntime(Runtime):
512
518
  self._webrtc_client.notify_ping()
513
519
 
514
520
  def _on_incoming_video_frame(
515
- self, frame: np.ndarray, track_name: str = "video"
521
+ self, frame: InputFrame, track_name: str = "video"
516
522
  ) -> None:
517
- """Handle incoming video frame from WebRTC.
523
+ """Handle an incoming video :class:`InputFrame` from the WebRTC transport.
524
+
525
+ Routes the frame into the model's per-track :class:`InputBuffer`,
526
+ preserving ``pts`` so model code can read it via
527
+ ``(await self.input.<track>.read(n))[i].pts``.
528
+ """
529
+ if self.model is not None:
530
+ self.model._push_media(track_name, frame)
518
531
 
519
- Pushes the raw frame directly into the model's InputBuffer
520
- for the named track.
532
+ def _on_incoming_audio_frame(
533
+ self, frame: InputFrame, track_name: str = "audio"
534
+ ) -> None:
535
+ """Handle an incoming audio :class:`InputFrame` from the WebRTC transport.
521
536
 
522
- Args:
523
- frame: NumPy array in RGB format with shape (H, W, 3).
524
- track_name: The MID-derived track name.
537
+ Routes the frame into the model's per-track :class:`InputBuffer`,
538
+ preserving ``pts`` so model code can align audio chunks with
539
+ video frames by timestamp.
525
540
  """
526
541
  if self.model is not None:
527
542
  self.model._push_media(track_name, frame)
@@ -23,6 +23,7 @@ Public API re-exported here for convenience::
23
23
  TrackInfo,
24
24
  TrackData,
25
25
  MediaBundle,
26
+ InputFrame,
26
27
  # Events
27
28
  EventType,
28
29
  WebRTCNoVideoError,
@@ -32,6 +33,7 @@ Public API re-exported here for convenience::
32
33
  DisconnectedEvent,
33
34
  MessageEvent,
34
35
  VideoFrameEvent,
36
+ AudioFrameEvent,
35
37
  MediaBundleEvent,
36
38
  PingTimeoutEvent,
37
39
  EventHandler,
@@ -47,6 +49,7 @@ from reactor_runtime.transports.events import (
47
49
  MessageEvent,
48
50
  PingTimeoutEvent,
49
51
  StatsMeasuredEvent,
52
+ AudioFrameEvent,
50
53
  VideoFrameEvent,
51
54
  WebRTCEvent,
52
55
  WebRTCNoVideoError,
@@ -55,6 +58,7 @@ from reactor_runtime.transports.events import (
55
58
  )
56
59
  from reactor_runtime.transports.interface import WebRTCTransport
57
60
  from reactor_runtime.transports.media import (
61
+ InputFrame,
58
62
  MediaBundle,
59
63
  TrackData,
60
64
  TrackDirection,
@@ -100,6 +104,7 @@ __all__ = [
100
104
  "TrackMapping",
101
105
  "TrackData",
102
106
  "MediaBundle",
107
+ "InputFrame",
103
108
  # Events
104
109
  "EventType",
105
110
  "WebRTCNoVideoError",
@@ -110,6 +115,7 @@ __all__ = [
110
115
  "DisconnectedEvent",
111
116
  "MessageEvent",
112
117
  "VideoFrameEvent",
118
+ "AudioFrameEvent",
113
119
  "MediaBundleEvent",
114
120
  "PingTimeoutEvent",
115
121
  "StatsMeasuredEvent",
@@ -36,7 +36,7 @@ from fractions import Fraction
36
36
  from typing import Optional
37
37
 
38
38
  import numpy as np
39
- from aiortc import MediaStreamTrack
39
+ from aiortc import AudioStreamTrack
40
40
  from av import AudioFrame
41
41
 
42
42
  from reactor_runtime.utils.log import get_logger
@@ -70,8 +70,154 @@ _FADE_SAMPLES = 64
70
70
  # to cap latency. 1 second at 48 kHz = 48 000.
71
71
  MAX_BUFFER_SAMPLES = 48000
72
72
 
73
+ # =============================================================================
74
+ # NumPy <-> AudioFrame helpers
75
+ # =============================================================================
76
+
77
+
78
+ def _audio_plane_to_bytes(plane) -> bytes:
79
+ """Raw bytes for a PyAV ``AudioPlane``/``VideoPlane`` (``to_bytes()`` or buffer)."""
80
+ to_b = getattr(plane, "to_bytes", None)
81
+ if callable(to_b):
82
+ return to_b()
83
+ return bytes(plane)
84
+
85
+
86
+ def _float_to_s16_samples(arr: np.ndarray) -> np.ndarray:
87
+ """FFmpeg/ libav ``fltp`` samples in approximately ``[-1.0, 1.0]`` → ``int16``."""
88
+ a = np.asarray(arr, dtype=np.float64)
89
+ a = np.clip(a, -1.0, 1.0)
90
+ return np.rint(a * 32767.0).astype(np.int16)
91
+
92
+
93
+ def _frame_channel_count(frame: AudioFrame) -> int:
94
+ """Best-effort channel count from the frame (layout, else plane count)."""
95
+ layout = getattr(frame, "layout", None)
96
+ if layout is not None:
97
+ nb = getattr(layout, "nb_channels", None)
98
+ if isinstance(nb, int) and nb > 0:
99
+ return nb
100
+ chs = getattr(layout, "channels", None)
101
+ if chs is not None and len(chs) > 0:
102
+ return len(chs)
103
+ n_planes = len(frame.planes)
104
+ return max(1, n_planes) if n_planes else 1
105
+
106
+
107
+ def _s16_to_mono_1d(arr: np.ndarray, frame: AudioFrame) -> np.ndarray:
108
+ """Collapse any supported layout to one **int16** vector (first channel only)."""
109
+ arr = np.asarray(arr, dtype=np.int16, order="C")
110
+ nch = _frame_channel_count(frame)
111
+ samples = int(getattr(frame, "samples", 0) or 0)
112
+
113
+ if arr.size == 0:
114
+ return arr
115
+
116
+ if arr.ndim == 1:
117
+ if nch <= 1:
118
+ return arr.copy()
119
+ if samples > 0 and arr.size == samples * nch:
120
+ return arr.reshape(-1, nch)[:, 0].copy()
121
+ if samples > 0 and arr.size == samples:
122
+ return arr.copy()
123
+ return arr.copy()
124
+
125
+ if arr.ndim == 2:
126
+ # Planar: (nch, samples) — e.g. s16p, fltp with separate planes per channel.
127
+ if nch > 1 and samples > 0 and arr.shape[0] == nch and arr.shape[1] == samples:
128
+ return arr[0].copy()
129
+ # Transposed planar: (samples, nch).
130
+ if nch > 1 and samples > 0 and arr.shape[1] == nch and arr.shape[0] == samples:
131
+ return arr[:, 0].copy()
132
+ # Packed interleaved: (1, samples * nch) — PyAV's default return shape for
133
+ # s16 / fltp interleaved (one plane holding [L, R, L, R, ...]). Without
134
+ # this case the (1, 1920) mono-ish array for 960-sample stereo falls into
135
+ # the `shape[0] <= shape[1]` heuristic below and is returned as-is, which
136
+ # feeds interleaved stereo into the model as if it were mono — effectively
137
+ # doubling the sample rate and producing unintelligible high-frequency
138
+ # noise on playback.
139
+ if (
140
+ nch > 1
141
+ and samples > 0
142
+ and arr.shape[0] == 1
143
+ and arr.shape[1] == samples * nch
144
+ ):
145
+ return arr[0].reshape(-1, nch)[:, 0].copy()
146
+ # Mono packed: (1, samples).
147
+ if nch <= 1 and arr.shape[0] == 1:
148
+ return arr[0].copy()
149
+ # Heuristic fallback for unusual layouts.
150
+ if arr.shape[0] <= arr.shape[1]:
151
+ return arr[0].copy()
152
+ return arr[:, 0].copy()
153
+
154
+ flat = arr.ravel()
155
+ if samples > 0 and nch > 1 and flat.size == samples * nch:
156
+ return flat.reshape(-1, nch)[:, 0].copy()
157
+ return flat.copy()
158
+
159
+
160
+ def audio_frame_to_numpy(frame: AudioFrame) -> np.ndarray:
161
+ """Convert an :class:`AudioFrame` to a ``(1, N)`` NumPy **int16 (s16, mono)** array.
162
+
163
+ Uses :meth:`~av.audio.frame.AudioFrame.to_ndarray` when available
164
+ (handles ``s16`` / ``s16p`` / ``fltp``, planar vs interleaved), then
165
+ normalizes to ``int16`` and **keeps the first channel only** (stereo
166
+ and multi-plane sources are down-selected, not down-mixed).
167
+
168
+ If ``to_ndarray`` is missing, decodes 16-bit PCM from ``frame.planes``
169
+ (single interleaved buffer or one plane per channel for ``s16p``).
170
+
171
+ The result matches the documented transport audio contract in
172
+ :mod:`reactor_runtime.transports.media` — ``(1, N)`` ``int16`` mono
173
+ — so the GStreamer and aiortc transports produce identically-shaped
174
+ buffers on the model's ``InputBuffer`` (before the runtime, only
175
+ aiortc produced a 1-D ``(N,)`` array, which broke any consumer that
176
+ indexed ``chunk[0]`` or read ``chunk.shape[1]``).
73
177
 
74
- class OutputAudioTrack(MediaStreamTrack):
178
+ Args:
179
+ frame: The :class:`AudioFrame` to convert.
180
+
181
+ Returns:
182
+ ``np.ndarray`` of dtype ``int16``, shape ``(1, N)`` — *N* samples
183
+ in one channel.
184
+ """
185
+ flat = _audio_frame_to_mono_1d(frame)
186
+ return flat.reshape(1, -1)
187
+
188
+
189
+ def _audio_frame_to_mono_1d(frame: AudioFrame) -> np.ndarray:
190
+ """Internal: flat int16 mono extractor (1-D), used by :func:`audio_frame_to_numpy`."""
191
+ to_nd = getattr(frame, "to_ndarray", None)
192
+ if callable(to_nd):
193
+ arr = np.asarray(to_nd())
194
+ if arr.dtype in (np.float32, np.float64):
195
+ return _s16_to_mono_1d(_float_to_s16_samples(arr), frame)
196
+ if arr.dtype == np.int16:
197
+ return _s16_to_mono_1d(arr, frame)
198
+ if arr.dtype in (np.int32, np.int64, np.uint16):
199
+ return _s16_to_mono_1d(np.clip(arr, -32768, 32767).astype(np.int16), frame)
200
+
201
+ # Fallback: 16-bit PCM in one or more planes (or ``to_ndarray`` missing)
202
+ n_planes = len(frame.planes)
203
+ if n_planes == 0:
204
+ return np.empty(0, dtype=np.int16)
205
+ if n_planes == 1:
206
+ raw = np.frombuffer(
207
+ _audio_plane_to_bytes(frame.planes[0]),
208
+ dtype=np.int16,
209
+ )
210
+ return _s16_to_mono_1d(raw, frame)
211
+ chans = [
212
+ np.frombuffer(_audio_plane_to_bytes(p), dtype=np.int16) for p in frame.planes
213
+ ]
214
+ n = min(c.size for c in chans)
215
+ if n == 0:
216
+ return np.empty(0, dtype=np.int16)
217
+ return chans[0][:n].copy()
218
+
219
+
220
+ class OutputAudioTrack(AudioStreamTrack):
75
221
  """Audio track that outputs samples provided via :meth:`push_samples`.
76
222
 
77
223
  Designed to be fed audio data from the model's output. Thread-safe