diff --git a/repos/gems/recipes/src/rom_osci/content.mk b/repos/gems/recipes/src/rom_osci/content.mk
new file mode 100644
index 0000000000..303e72ee5e
--- /dev/null
+++ b/repos/gems/recipes/src/rom_osci/content.mk
@@ -0,0 +1,2 @@
+SRC_DIR = src/app/rom_osci
+include $(GENODE_DIR)/repos/base/recipes/src/content.inc
diff --git a/repos/gems/recipes/src/rom_osci/hash b/repos/gems/recipes/src/rom_osci/hash
new file mode 100644
index 0000000000..47222982c8
--- /dev/null
+++ b/repos/gems/recipes/src/rom_osci/hash
@@ -0,0 +1 @@
+2024-01-19 8490b1bc29f4a69c27c9dce66b6630e984310e2b
diff --git a/repos/gems/recipes/src/rom_osci/used_apis b/repos/gems/recipes/src/rom_osci/used_apis
new file mode 100644
index 0000000000..e6dc84c584
--- /dev/null
+++ b/repos/gems/recipes/src/rom_osci/used_apis
@@ -0,0 +1,10 @@
+base
+os
+blit
+gems
+framebuffer_session
+input_session
+gui_session
+timer_session
+nitpicker_gfx
+polygon_gfx
diff --git a/repos/gems/run/waveform_player.run b/repos/gems/run/waveform_player.run
new file mode 100644
index 0000000000..54b8f1db67
--- /dev/null
+++ b/repos/gems/run/waveform_player.run
@@ -0,0 +1,198 @@
+create_boot_directory
+
+import_from_depot [depot_user]/src/[base_src] \
+ [depot_user]/pkg/[drivers_interactive_pkg] \
+ [depot_user]/pkg/fonts_fs \
+ [depot_user]/src/init \
+ [depot_user]/src/report_rom \
+ [depot_user]/src/dynamic_rom \
+ [depot_user]/src/nitpicker \
+ [depot_user]/src/libc \
+ [depot_user]/src/libpng \
+ [depot_user]/src/zlib
+
+build { server/record_play_mixer server/record_rom app/waveform_player app/rom_osci }
+
+proc play_period_ms { } {
+ if {[have_spec linux]} { return 80 }
+ return 5
+}
+
+proc record_period_ms { } {
+ if {[have_spec linux]} { return 40 }
+ return 5
+}
+
+proc jitter_ms { } {
+ if {[have_spec linux]} { return 20 }
+ return 2
+}
+
+install_config {
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+}
+
+build_boot_image [build_artifacts]
+
+append qemu_args " -nographic "
+
+run_genode_until forever
+
diff --git a/repos/gems/src/app/rom_osci/main.cc b/repos/gems/src/app/rom_osci/main.cc
new file mode 100644
index 0000000000..7b896b7d3e
--- /dev/null
+++ b/repos/gems/src/app/rom_osci/main.cc
@@ -0,0 +1,340 @@
+/*
+ * \brief Oscilloscope showing data obtained from a dynamic ROM
+ * \author Norman Feske
+ * \date 2023-12-20
+ */
+
+/*
+ * Copyright (C) 2023 Genode Labs GmbH
+ *
+ * This file is part of the Genode OS framework, which is distributed
+ * under the terms of the GNU Affero General Public License version 3.
+ */
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace Osci {
+
+ using namespace Genode;
+
+ struct Main;
+
+ static void with_skipped_bytes(Const_byte_range_ptr const &bytes,
+ size_t const n, auto const &fn)
+ {
+ if (bytes.num_bytes < n)
+ return;
+
+ Const_byte_range_ptr const remainder { bytes.start + n,
+ bytes.num_bytes - n };
+ fn(remainder);
+ }
+
+ static void with_skipped_whitespace(Const_byte_range_ptr const &bytes, auto const &fn)
+ {
+ auto whitespace = [] (char const c) { return c == ' ' || c == '\n'; };
+
+ size_t skip = 0;
+ while (whitespace(bytes.start[skip]) && (skip < bytes.num_bytes))
+ skip++;
+
+ Const_byte_range_ptr const remainder { bytes.start + skip,
+ bytes.num_bytes - skip };
+ fn(remainder);
+ }
+
+ template
+ static void with_parsed_value(Const_byte_range_ptr const &bytes, auto const &fn)
+ {
+ T value { };
+ size_t const n = ascii_to(bytes.start, value);
+ with_skipped_bytes(bytes, n, [&] (Const_byte_range_ptr const &remainder) {
+ with_skipped_whitespace(remainder, [&] (Const_byte_range_ptr const &remainder) {
+ fn(value, remainder); }); });
+ }
+}
+
+
+struct Osci::Main
+{
+ Env &_env;
+
+ using Point = Gui_buffer::Point;
+ using Area = Gui_buffer::Area;
+ using Rect = Gui_buffer::Rect;
+
+ Area _size { };
+ Color _background { };
+ unsigned _fps { };
+ bool _phase_lock { };
+
+ Heap _heap { _env.ram(), _env.rm() };
+
+ Timer::Connection _timer { _env };
+ Gui::Connection _gui { _env };
+
+ Constructible _gui_buffer { };
+
+ struct View
+ {
+ Gui::Connection &_gui;
+
+ Gui::Session::View_handle _handle { _gui.create_view() };
+
+ View(Gui::Connection &gui, Point position, Area size) : _gui(gui)
+ {
+ using Command = Gui::Session::Command;
+ _gui.enqueue(_handle, Rect(position, size));
+ _gui.enqueue(_handle, Gui::Session::View_handle());
+ _gui.execute();
+ }
+
+ ~View() { _gui.destroy_view(_handle); }
+ };
+
+ Constructible _view { };
+
+ Attached_rom_dataspace _config { _env, "config" };
+ Attached_rom_dataspace _recording { _env, "recording" };
+
+ Signal_handler _timer_handler { _env.ep(), *this, &Main::_handle_timer };
+ Signal_handler _config_handler { _env.ep(), *this, &Main::_handle_config };
+
+ struct Phase_lock { unsigned offset; };
+
+ struct Captured_channel
+ {
+ static constexpr unsigned SIZE_LOG2 = 10, SIZE = 1 << SIZE_LOG2, MASK = SIZE - 1;
+
+ float _samples[SIZE] { };
+
+ unsigned _pos = 0;
+
+ Captured_channel() { };
+
+ Captured_channel(Xml_node const &channel)
+ {
+ auto insert = [&] (float value)
+ {
+ _pos = (_pos + 1) & MASK;
+ _samples[_pos] = value;
+ };
+
+ channel.with_raw_content([&] (char const *start, size_t len) {
+ while (len > 0) {
+ Const_byte_range_ptr bytes { start, len };
+ with_parsed_value(bytes, [&] (double v, Const_byte_range_ptr const &remaining) {
+ insert(float(v));
+ start = remaining.start;
+ len = remaining.num_bytes;
+ });
+ }
+ });
+ }
+
+ float past_value(unsigned past) const
+ {
+ return _samples[(_pos - past) & MASK];
+ }
+ };
+
+ struct Channel : private List_model>::Element
+ {
+ friend class List_model>;
+ friend class List>;
+
+ using Label = String<20>;
+ Label const label;
+
+ static Label _label_from_xml(Xml_node const &node)
+ {
+ return node.attribute_value("label", Label());
+ }
+
+ struct Attr
+ {
+ double v_pos, v_scale;
+ Color color;
+
+ static Attr from_xml(Xml_node const &node, Attr const defaults)
+ {
+ return Attr {
+ .v_pos = node.attribute_value("v_pos", defaults.v_pos),
+ .v_scale = node.attribute_value("v_scale", defaults.v_scale),
+ .color = node.attribute_value("color", defaults.color),
+ };
+ }
+ } _attr { };
+
+ Captured_channel _capture { };
+
+ Line_painter const _line_painter { };
+
+ Channel(Xml_node const &node) : label(_label_from_xml(node)) { }
+
+ virtual ~Channel() { };
+
+ void update(Xml_node const &node, Attr const defaults)
+ {
+ _attr = Attr::from_xml(node, defaults);
+ }
+
+ void capture(Xml_node const &node) { _capture = Captured_channel(node); }
+
+ Phase_lock phase_lock(unsigned start, float const threshold, unsigned const max) const
+ {
+ float curr_value = 0.0f;
+ for (unsigned i = 0 ; i < max; i++) {
+ float const prev_value = curr_value;
+ curr_value = _capture.past_value(i + start);
+ if (prev_value <= threshold && curr_value > threshold)
+ return { i };
+ }
+ return { 0 };
+ }
+
+ void render(Gui_buffer::Pixel_surface &pixel,
+ Gui_buffer::Alpha_surface &, Phase_lock const phase_lock) const
+ {
+ /*
+ * Draw captured samples from right to left.
+ */
+
+ Area const area = pixel.size();
+ unsigned const w = area.w();
+ int const y_pos = int(_attr.v_pos*area.h());
+ double const screen_v_scale = _attr.v_scale*area.h()/2;
+
+ auto _horizontal_line = [&] (Color c, int y, int alpha)
+ {
+ _line_painter.paint(pixel, Point { 0, y },
+ Point { int(w) - 2, y },
+ Color { c.r, c.g, c.b, alpha });
+ };
+
+ _horizontal_line(_attr.color, y_pos, 80);
+ _horizontal_line(_attr.color, y_pos - int(screen_v_scale), 40);
+ _horizontal_line(_attr.color, y_pos + int(screen_v_scale), 40);
+
+ Point const centered { 0, y_pos };
+ Point previous_p { };
+ bool first_iteration = true;
+ for (unsigned i = 0; i < w; i++) {
+
+ Point p { int(w - i),
+ int(screen_v_scale*_capture.past_value(i + phase_lock.offset)) };
+
+ p = p + centered;
+
+ if (!first_iteration)
+ _line_painter.paint(pixel, p, previous_p, _attr.color);
+
+ previous_p = p;
+ first_iteration = false;
+ }
+ }
+
+ /*
+ * List_model::Element
+ */
+ static bool type_matches(Xml_node const &node)
+ {
+ return node.has_type("channel");
+ }
+
+ bool matches(Xml_node const &node) const
+ {
+ return _label_from_xml(node) == label;
+ }
+ };
+
+ List_model> _channels { };
+
+ Registry> _channel_registry { }; /* for non-const for_each */
+
+ void _handle_config()
+ {
+ _config.update();
+
+ Xml_node const config = _config.xml();
+
+ _size = Area::from_xml(config);
+ _background = config.attribute_value("background", Color { 0, 0, 0 });
+ _fps = config.attribute_value("fps", 50u);
+ _phase_lock = config.attribute_value("phase_lock", false);
+
+ _fps = max(_fps, 1u);
+
+ /* channel defaults obtained from top-level config node */
+ Channel::Attr const channel_defaults = Channel::Attr::from_xml(config, {
+ .v_pos = 0.5,
+ .v_scale = 0.6,
+ .color = { 255, 255, 255 },
+ });
+
+ _gui_buffer.construct(_gui, _size, _env.ram(), _env.rm(),
+ Gui_buffer::Alpha::OPAQUE, _background);
+
+ _view.construct(_gui, Point::from_xml(config), _size);
+
+ _channels.update_from_xml(config,
+ [&] (Xml_node const &node) -> Registered & {
+ return *new (_heap) Registered(_channel_registry, node); },
+ [&] (Registered &channel) {
+ destroy(_heap, &channel); },
+ [&] (Channel &channel, Xml_node const &node) {
+ channel.update(node, channel_defaults); }
+ );
+
+ _timer.trigger_periodic(1000*1000/_fps);
+ }
+
+ void _handle_timer()
+ {
+ /* import recorded samples */
+ _recording.update();
+ _recording.xml().for_each_sub_node("channel", [&] (Xml_node const &node) {
+ Channel::Label const label = node.attribute_value("label", Channel::Label());
+ _channel_registry.for_each([&] (Registered &channel) {
+ if (channel.label == label)
+ channel.capture(node); }); });
+
+ /* determine phase-locking offset from one channel */
+ Phase_lock phase_lock { };
+ if (_phase_lock)
+ _channels.with_first([&] (Channel const &channel) {
+ phase_lock = channel.phase_lock(_size.w()/2, -0.1f, _size.w()/2); });
+
+ _gui_buffer->reset_surface();
+ _gui_buffer->apply_to_surface([&] (auto &pixel, auto &alpha) {
+ _channels.for_each([&] (Channel const &channel) {
+ channel.render(pixel, alpha, phase_lock); }); });
+
+ _gui_buffer->flush_surface();
+ _gui.framebuffer()->refresh(0, 0, _size.w(), _size.h());
+ }
+
+ Main(Env &env) : _env(env)
+ {
+ _timer.sigh(_timer_handler);
+ _config.sigh(_config_handler);
+ _handle_config();
+ }
+};
+
+
+void Component::construct(Genode::Env &env)
+{
+ static Osci::Main main(env);
+}
diff --git a/repos/gems/src/app/rom_osci/target.mk b/repos/gems/src/app/rom_osci/target.mk
new file mode 100644
index 0000000000..6d3074a0e7
--- /dev/null
+++ b/repos/gems/src/app/rom_osci/target.mk
@@ -0,0 +1,3 @@
+TARGET = rom_osci
+SRC_CC = main.cc
+LIBS = base blit
diff --git a/repos/os/include/play_session/connection.h b/repos/os/include/play_session/connection.h
new file mode 100644
index 0000000000..b9a7f30d61
--- /dev/null
+++ b/repos/os/include/play_session/connection.h
@@ -0,0 +1,171 @@
+/*
+ * \brief Connection to audio-play service
+ * \author Norman Feske
+ * \date 2023-12-11
+ */
+
+/*
+ * Copyright (C) 2023 Genode Labs GmbH
+ *
+ * This file is part of the Genode OS framework, which is distributed
+ * under the terms of the GNU Affero General Public License version 3.
+ */
+
+#ifndef _INCLUDE__PLAY_SESSION__CONNECTION_H_
+#define _INCLUDE__PLAY_SESSION__CONNECTION_H_
+
+#include
+#include
+#include
+#include
+
+namespace Play { struct Connection; }
+
+
+struct Play::Connection : Genode::Connection, Rpc_client
+{
+ private:
+
+ static constexpr Ram_quota RAM_QUOTA = { DATASPACE_SIZE + 4096 };
+
+ Attached_dataspace _ds;
+
+ Shared_buffer &_buffer = *_ds.local_addr();
+
+ Seq _seq { };
+
+ unsigned _slot_id { };
+
+ Sample_start _sample_start { }; /* position within 'samples' ring buffer */
+
+ Num_samples _submit_samples(auto const &fn)
+ {
+ _seq = { _seq.value() + 1 };
+ _slot_id = (_slot_id + 1) % Shared_buffer::NUM_SLOTS;
+
+ Shared_buffer::Slot &slot = _buffer.slots[_slot_id];
+
+ slot.acquired_seq = _seq;
+ slot.time_window = { };
+ slot.sample_start = { };
+ slot.num_samples = { };
+
+ class Submission
+ {
+ private:
+
+ float * const _dst;
+ unsigned _pos;
+ unsigned _count = 0;
+
+ /*
+ * Noncopyable
+ */
+ Submission &operator = (Submission const &);
+ Submission(Submission const &);
+
+ public:
+
+ Submission(float *dst, unsigned pos) : _dst(dst), _pos(pos) { }
+
+ void operator () (float value)
+ {
+ _dst[_pos] = value;
+ if (++_pos >= Shared_buffer::MAX_SAMPLES)
+ _pos = 0;
+ _count++;
+ }
+
+ Num_samples num_samples() const { return { _count }; }
+ };
+
+ Submission submission(_buffer.samples, _sample_start.index);
+
+ fn(submission);
+
+ return submission.num_samples();
+ }
+
+ void _commit_to_current_slot(Num_samples n, Time_window tw)
+ {
+ Shared_buffer::Slot &slot = _buffer.slots[_slot_id];
+
+ slot.sample_start = _sample_start;
+ slot.num_samples = n;
+ slot.time_window = tw;
+ slot.committed_seq = _seq;
+
+ /* advance destination position for next submission */
+ _sample_start.index = (_sample_start.index + n.value())
+ % Shared_buffer::MAX_SAMPLES;
+ }
+
+ public:
+
+ Connection(Genode::Env &env, Label const &label = Label())
+ :
+ Genode::Connection(env, label, RAM_QUOTA, Args()),
+ Rpc_client(cap()),
+ _ds(env.rm(), call())
+ {
+ if (_ds.size() < DATASPACE_SIZE) {
+ error("play buffer has insufficient size");
+ sleep_forever();
+ }
+ }
+
+ /**
+ * Schedule playback of data after the given 'previous' time window
+ *
+ * \param previous time window returned by previous call, or
+ * a default-constructed 'Time_window' when starting
+ * \param duration duration of the sample data in microseconds
+ * \param fn functor to be repeatedly called with float sample values
+ *
+ * The sample rate depends on the given 'duration' and the number of
+ * 'fn' calls in the scope of the 'schedule' call. Note that the
+ * duration is evaluated only as a hint when starting a new playback.
+ * During continuous playback, the duration is inferred by the rate
+ * of the periodic 'schedule_and_enqueue' calls.
+ */
+ Time_window schedule_and_enqueue(Time_window previous, Duration duration,
+ auto const &fn)
+ {
+ Num_samples const n = _submit_samples(fn);
+ Time_window const tw = call(previous, duration, n);
+
+ _commit_to_current_slot(n, tw);
+
+ return tw;
+ }
+
+ /**
+ * Passively enqueue data for the playback at the given time window
+ *
+ * In contrast to 'schedule_and_enqueue', this method does not allocate
+ * a new time window but schedules sample data for an already known
+ * time window. It is designated for the synchronized playback of
+ * multiple audio channels whereas each channel is a separate play
+ * session.
+ *
+ * One channel (e.g., the left) drives the allocation of time windows
+ * using 'schedule_and_enqueue' whereas the other channels (e.g., the
+ * right) merely submit audio data for the already allocated time
+ * windows using 'enqueue'. This way, only one RPC per period is needed
+ * to drive the synchronized output of an arbitrary number of channels.
+ */
+ void enqueue(Time_window time_window, auto const &fn)
+ {
+ _commit_to_current_slot(_submit_samples(fn), time_window);
+ }
+
+ /**
+ * Inform the server that no further data is expected
+ *
+ * By calling 'stop', the client allows the server to distinguish the
+ * (temporary) end of playback from jitter.
+ */
+ void stop() { call(); }
+};
+
+#endif /* _INCLUDE__RECORD_SESSION__CONNECTION_H_ */
diff --git a/repos/os/include/play_session/play_session.h b/repos/os/include/play_session/play_session.h
new file mode 100644
index 0000000000..94a7593746
--- /dev/null
+++ b/repos/os/include/play_session/play_session.h
@@ -0,0 +1,102 @@
+/*
+ * \brief Audio-play session interface
+ * \author Norman Feske
+ * \date 2023-12-11
+ */
+
+/*
+ * Copyright (C) 2023 Genode Labs GmbH
+ *
+ * This file is part of the Genode OS framework, which is distributed
+ * under the terms of the GNU Affero General Public License version 3.
+ */
+
+#ifndef _INCLUDE__PLAY_SESSION__PLAY_SESSION_H_
+#define _INCLUDE__PLAY_SESSION__PLAY_SESSION_H_
+
+#include
+#include
+#include
+
+namespace Play {
+
+ using namespace Genode;
+
+ struct Seq
+ {
+ static constexpr unsigned LIMIT = 1 << 7, MASK = LIMIT - 1;
+ unsigned _value; /* 0...127 */
+ unsigned value() const { return _value & MASK; }
+ };
+
+ struct Time_window { unsigned start, end; };
+
+ struct Sample_start { unsigned index; };
+
+ struct Num_samples
+ {
+ unsigned _value; /* 0...4095 */
+ unsigned value() const { return _value & 0xfff; }
+ };
+
+ struct Duration
+ {
+ static constexpr unsigned LIMIT = 100*1000;
+ unsigned us;
+ bool valid() const { return us > 0 && us <= LIMIT; }
+ };
+
+ struct Session;
+}
+
+
+struct Play::Session : Genode::Session
+{
+ /**
+ * Layout of the audio buffer shared between client and server
+ */
+ struct Shared_buffer
+ {
+ static const unsigned NUM_SLOTS = 20;
+ static const unsigned MAX_SAMPLES = 8*1024; /* 160 ms at 50 KHz */
+
+ struct Slot
+ {
+ Seq acquired_seq;
+ Time_window time_window;
+ Sample_start sample_start; /* offset within 'samples' */
+ Num_samples num_samples;
+ Seq committed_seq; /* detect slot modification during read */
+ };
+
+ Slot slots[NUM_SLOTS];
+
+ float samples[MAX_SAMPLES]; /* ring buffer of sample values */
+ };
+
+ static constexpr size_t DATASPACE_SIZE = align_addr(sizeof(Shared_buffer), 12);
+
+ /**
+ * \noapi
+ */
+ static const char *service_name() { return "Play"; }
+
+ /*
+ * A play session consumes a dataspace capability for the server's
+ * session-object allocation, a dataspace capability for the audio
+ * buffer, and its session capability.
+ */
+ static constexpr unsigned CAP_QUOTA = 3;
+
+
+ /*********************
+ ** RPC declaration **
+ *********************/
+
+ GENODE_RPC(Rpc_dataspace, Dataspace_capability, dataspace);
+ GENODE_RPC(Rpc_schedule, Time_window, schedule, Time_window, Duration, Num_samples);
+ GENODE_RPC(Rpc_stop, void, stop);
+ GENODE_RPC_INTERFACE(Rpc_dataspace, Rpc_schedule, Rpc_stop);
+};
+
+#endif /* _INCLUDE__EVENT_SESSION__EVENT_SESSION_H_ */
diff --git a/repos/os/include/record_session/connection.h b/repos/os/include/record_session/connection.h
new file mode 100644
index 0000000000..05a6da4ac9
--- /dev/null
+++ b/repos/os/include/record_session/connection.h
@@ -0,0 +1,96 @@
+/*
+ * \brief Connection to an audio-record service
+ * \author Norman Feske
+ * \date 2023-12-11
+ */
+
+/*
+ * Copyright (C) 2023 Genode Labs GmbH
+ *
+ * This file is part of the Genode OS framework, which is distributed
+ * under the terms of the GNU Affero General Public License version 3.
+ */
+
+#ifndef _INCLUDE__RECORD_SESSION__CONNECTION_H_
+#define _INCLUDE__RECORD_SESSION__CONNECTION_H_
+
+#include
+#include
+#include
+#include
+
+namespace Record { struct Connection; }
+
+
+struct Record::Connection : Genode::Connection, Rpc_client
+{
+ private:
+
+ Attached_dataspace _ds;
+
+ public:
+
+ static constexpr Ram_quota RAM_QUOTA = { DATASPACE_SIZE + 2*4096 };
+
+ Connection(Env &env, Session_label const &label = Session_label())
+ :
+ Genode::Connection(env, label, RAM_QUOTA, Args()),
+ Rpc_client(cap()),
+ _ds(env.rm(), call())
+ {
+ if (_ds.size() < DATASPACE_SIZE) {
+ error("record buffer has insufficient size");
+ sleep_forever();
+ }
+ }
+
+ /**
+ * Register signal handler on new data becomes available after depletion
+ */
+ void wakeup_sigh(Signal_context_capability sigh) { call(sigh); }
+
+ /**
+ * Read-only sample data as an array of float values
+ */
+ struct Samples_ptr : Genode::Noncopyable
+ {
+ struct { float const * const start; unsigned num_samples; };
+
+ Samples_ptr(float const *start, unsigned num_samples)
+ : start(start), num_samples(num_samples) { }
+ };
+
+ /**
+ * Record the specified number of audio samples
+ *
+ * \param fn called with the 'Time_window' and 'Samples_ptr const &'
+ * of the recording
+ *
+ * \param depleted_fn called when no sample data is available
+ *
+ * Subsequent 'record' calls result in consecutive time windows.
+ */
+ void record(Num_samples n, auto const &fn, auto const &depleted_fn)
+ {
+ call(n).with_result(
+ [&] (Time_window const &tw) {
+ fn(tw, Samples_ptr(_ds.local_addr(), n.value()));
+ },
+ [&] (Depleted) { depleted_fn(); });
+ }
+
+ /**
+ * Record specified number of audio samples at the given time window
+ *
+ * By using the time window returned by 'record' as argument for
+ * 'record_at', a user of multiple sessions (e.g., for left and right)
+ * can obtain sample data synchronized between the sessions.
+ */
+ void record_at(Time_window tw, Num_samples n, auto const &fn)
+ {
+ call(tw, n);
+ fn(Samples_ptr(_ds.local_addr(), n.value()));
+ }
+};
+
+#endif /* _INCLUDE__RECORD_SESSION__CONNECTION_H_ */
diff --git a/repos/os/include/record_session/record_session.h b/repos/os/include/record_session/record_session.h
new file mode 100644
index 0000000000..b81c2cdf13
--- /dev/null
+++ b/repos/os/include/record_session/record_session.h
@@ -0,0 +1,79 @@
+/*
+ * \brief Audio-record session interface
+ * \author Norman Feske
+ * \date 2023-12-11
+ */
+
+/*
+ * Copyright (C) 2023 Genode Labs GmbH
+ *
+ * This file is part of the Genode OS framework, which is distributed
+ * under the terms of the GNU Affero General Public License version 3.
+ */
+
+#ifndef _INCLUDE__RECORD_SESSION__RECORD_SESSION_H_
+#define _INCLUDE__RECORD_SESSION__RECORD_SESSION_H_
+
+#include
+#include
+#include
+
+namespace Record {
+
+ using namespace Genode;
+
+ /*
+ * The 'Time_window' values are merely used as tokens between 'record'
+ * and 'record_at' calls. They are not meant to be interpreted by the
+ * client.
+ */
+ struct Time_window { unsigned start, end; };
+
+ struct Num_samples
+ {
+ unsigned _value; /* 0...8191 (13 bits) */
+ unsigned value() const { return _value & 0x1fff; }
+ };
+
+ struct Session;
+}
+
+
+struct Record::Session : Genode::Session
+{
+ /*
+ * The dataspace shared between client and server can hold 160 ms of 50 KHz
+ * audio, using one float (4 bytes) per sample.
+ */
+ static constexpr size_t DATASPACE_SIZE = 32*1024;
+
+ /**
+ * \noapi
+ */
+ static const char *service_name() { return "Record"; }
+
+ /*
+ * A record session consumes a dataspace capability for the server's
+ * session-object allocation, a dataspace capability for the audio
+ * buffer, and its session capability.
+ */
+ static constexpr unsigned CAP_QUOTA = 3;
+
+ struct Depleted { };
+
+ using Record_result = Attempt;
+
+
+ /*********************
+ ** RPC declaration **
+ *********************/
+
+ GENODE_RPC(Rpc_dataspace, Dataspace_capability, dataspace);
+ GENODE_RPC(Rpc_wakeup_sigh, void, wakeup_sigh, Signal_context_capability);
+ GENODE_RPC(Rpc_record, Record_result, record, Num_samples);
+ GENODE_RPC(Rpc_record_at, void, record_at, Time_window, Num_samples);
+
+ GENODE_RPC_INTERFACE(Rpc_dataspace, Rpc_wakeup_sigh, Rpc_record, Rpc_record_at);
+};
+
+#endif /* _INCLUDE__EVENT_SESSION__EVENT_SESSION_H_ */
diff --git a/repos/os/recipes/api/play_session/content.mk b/repos/os/recipes/api/play_session/content.mk
new file mode 100644
index 0000000000..6b603ee7bf
--- /dev/null
+++ b/repos/os/recipes/api/play_session/content.mk
@@ -0,0 +1,2 @@
+MIRRORED_FROM_REP_DIR := include/play_session
+include $(REP_DIR)/recipes/api/session.inc
diff --git a/repos/os/recipes/api/play_session/hash b/repos/os/recipes/api/play_session/hash
new file mode 100644
index 0000000000..721809e694
--- /dev/null
+++ b/repos/os/recipes/api/play_session/hash
@@ -0,0 +1 @@
+2024-01-19 3e0d1397ae70a3d2a9c297d9e8b32eb750889c24
diff --git a/repos/os/recipes/api/record_session/content.mk b/repos/os/recipes/api/record_session/content.mk
new file mode 100644
index 0000000000..3257c632da
--- /dev/null
+++ b/repos/os/recipes/api/record_session/content.mk
@@ -0,0 +1,2 @@
+MIRRORED_FROM_REP_DIR := include/record_session
+include $(REP_DIR)/recipes/api/session.inc
diff --git a/repos/os/recipes/api/record_session/hash b/repos/os/recipes/api/record_session/hash
new file mode 100644
index 0000000000..eaed885bc3
--- /dev/null
+++ b/repos/os/recipes/api/record_session/hash
@@ -0,0 +1 @@
+2024-01-19 3a9fdc374119a6b1b43fcb6a80ccbea571793f55
diff --git a/repos/os/recipes/src/record_play_mixer/content.mk b/repos/os/recipes/src/record_play_mixer/content.mk
new file mode 100644
index 0000000000..b15ce7e524
--- /dev/null
+++ b/repos/os/recipes/src/record_play_mixer/content.mk
@@ -0,0 +1,2 @@
+SRC_DIR = src/server/record_play_mixer
+include $(GENODE_DIR)/repos/base/recipes/src/content.inc
diff --git a/repos/os/recipes/src/record_play_mixer/hash b/repos/os/recipes/src/record_play_mixer/hash
new file mode 100644
index 0000000000..b29d6e2b1f
--- /dev/null
+++ b/repos/os/recipes/src/record_play_mixer/hash
@@ -0,0 +1 @@
+2024-01-19 2c71de8cc006a5ed9455d8c052584db4a7a238f2
diff --git a/repos/os/recipes/src/record_play_mixer/used_apis b/repos/os/recipes/src/record_play_mixer/used_apis
new file mode 100644
index 0000000000..fe9a94ba7d
--- /dev/null
+++ b/repos/os/recipes/src/record_play_mixer/used_apis
@@ -0,0 +1,6 @@
+base
+os
+record_session
+play_session
+timer_session
+report_session
diff --git a/repos/os/recipes/src/record_rom/content.mk b/repos/os/recipes/src/record_rom/content.mk
new file mode 100644
index 0000000000..d71129bf8f
--- /dev/null
+++ b/repos/os/recipes/src/record_rom/content.mk
@@ -0,0 +1,2 @@
+SRC_DIR = src/server/record_rom
+include $(GENODE_DIR)/repos/base/recipes/src/content.inc
diff --git a/repos/os/recipes/src/record_rom/hash b/repos/os/recipes/src/record_rom/hash
new file mode 100644
index 0000000000..f9babffcb5
--- /dev/null
+++ b/repos/os/recipes/src/record_rom/hash
@@ -0,0 +1 @@
+2024-01-19 0b59428f86704633fdf5e0507759e512e18f6b20
diff --git a/repos/os/recipes/src/record_rom/used_apis b/repos/os/recipes/src/record_rom/used_apis
new file mode 100644
index 0000000000..83422159f2
--- /dev/null
+++ b/repos/os/recipes/src/record_rom/used_apis
@@ -0,0 +1,4 @@
+base
+os
+timer_session
+record_session
diff --git a/repos/os/recipes/src/waveform_player/content.mk b/repos/os/recipes/src/waveform_player/content.mk
new file mode 100644
index 0000000000..cffe52f778
--- /dev/null
+++ b/repos/os/recipes/src/waveform_player/content.mk
@@ -0,0 +1,2 @@
+SRC_DIR = src/app/waveform_player
+include $(GENODE_DIR)/repos/base/recipes/src/content.inc
diff --git a/repos/os/recipes/src/waveform_player/hash b/repos/os/recipes/src/waveform_player/hash
new file mode 100644
index 0000000000..41b9cc8180
--- /dev/null
+++ b/repos/os/recipes/src/waveform_player/hash
@@ -0,0 +1 @@
+2024-01-19 851b68f4d966f6671ff5dbbd008365df547a7728
diff --git a/repos/os/recipes/src/waveform_player/used_apis b/repos/os/recipes/src/waveform_player/used_apis
new file mode 100644
index 0000000000..c792cfa546
--- /dev/null
+++ b/repos/os/recipes/src/waveform_player/used_apis
@@ -0,0 +1,4 @@
+base
+os
+timer_session
+play_session
diff --git a/repos/os/src/app/waveform_player/main.cc b/repos/os/src/app/waveform_player/main.cc
new file mode 100644
index 0000000000..110ef3ea99
--- /dev/null
+++ b/repos/os/src/app/waveform_player/main.cc
@@ -0,0 +1,330 @@
+/*
+ * \brief Waveform generator targeting play sessions
+ * \author Norman Feske
+ * \date 2023-12-20
+ */
+
+/*
+ * Copyright (C) 2023 Genode Labs GmbH
+ *
+ * This file is part of the Genode OS framework, which is distributed
+ * under the terms of the GNU Affero General Public License version 3.
+ */
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace Waveform_player {
+
+ using namespace Genode;
+
+ struct Main;
+}
+
+
+struct Waveform_player::Main
+{
+ Env &_env;
+
+ Heap _heap { _env.ram(), _env.rm() };
+
+ Timer::Connection _timer { _env };
+
+ Attached_rom_dataspace _config_ds { _env, "config" };
+
+ Signal_handler _config_handler {
+ _env.ep(), *this, &Main::_handle_config };
+
+ Signal_handler _timer_handler {
+ _env.ep(), *this, &Main::_handle_timer };
+
+ struct Phase
+ {
+ /* value range corresponds to 0...2*pi */
+ static constexpr unsigned PI2 = (1u << 16);
+ uint16_t angle;
+ };
+
+ struct Waveform
+ {
+ static constexpr unsigned STEPS_LOG2 = 10u, STEPS = 1u << STEPS_LOG2;
+
+ float _values[STEPS] { };
+
+ float value(Phase phase) const
+ {
+ return _values[phase.angle >> (16u - STEPS_LOG2)];
+ }
+ };
+
+ struct Sine_waveform : Waveform
+ {
+ Sine_waveform()
+ {
+ /* sin and cos values of 2*pi/STEPS */
+ static constexpr double SINA = 0.00613588, COSA = 0.99998117;
+
+ struct Point
+ {
+ double x, y;
+
+ Point rotated() const { return { .x = x*COSA - y*SINA,
+ .y = y*COSA + x*SINA }; }
+ } p { .x = 1.0, .y = 0.0 };
+
+ for (unsigned i = 0; i < STEPS; i++, p = p.rotated())
+ _values[i] = float(p.y);
+ }
+ };
+
+ struct Square_waveform : Waveform
+ {
+ Square_waveform()
+ {
+ for (unsigned i = 0; i < STEPS; i++)
+ _values[i] = (i < STEPS/2) ? -1.0f : 1.0f;
+ }
+ };
+
+ struct Saw_waveform : Waveform
+ {
+ Saw_waveform()
+ {
+ for (unsigned i = 0; i < STEPS; i++)
+ _values[i] = -1.0f + (float(i)*2)/STEPS;
+ }
+ };
+
+ Sine_waveform const _sine_waveform { };
+ Square_waveform const _square_waveform { };
+ Saw_waveform const _saw_waveform { };
+
+ enum class Wave { NONE, SINE, SQUARE, SAW };
+
+ void with_waveform(Wave const wave, auto const &fn) const
+ {
+ auto waveform_ptr = [&] (Wave const wave) -> Waveform const *
+ {
+ if (wave == Wave::SINE) return &_sine_waveform;
+ if (wave == Wave::SQUARE) return &_square_waveform;
+ if (wave == Wave::SAW) return &_saw_waveform;
+ return nullptr;
+ };
+
+ Waveform const *ptr = waveform_ptr(wave);
+ if (ptr)
+ fn(*ptr);
+ }
+
+ struct Channel : private List_model>::Element
+ {
+ friend class List_model>;
+ friend class List>;
+
+ using Label = String<20>;
+ Label const label;
+
+ static Label _label_from_xml(Xml_node const &node)
+ {
+ return node.attribute_value("label", Label());
+ }
+
+ struct Attr
+ {
+ unsigned sample_rate_hz;
+ double wave_hz;
+ Wave wave;
+
+ static Attr from_xml(Xml_node const &node, Attr const defaults)
+ {
+ auto wave_from_xml = [] (Xml_node const &node)
+ {
+ auto const attr = node.attribute_value("wave", String<16>());
+
+ if (attr == "sine") return Wave::SINE;
+ if (attr == "square") return Wave::SQUARE;
+ if (attr == "saw") return Wave::SAW;
+ if (attr == "") return Wave::SINE;
+
+ warning("unsupported waveform '", attr, "'");
+ return Wave::NONE;
+ };
+ return Attr {
+ .sample_rate_hz = node.attribute_value("sample_rate_hz", defaults.sample_rate_hz),
+ .wave_hz = node.attribute_value("hz", defaults.wave_hz),
+ .wave = wave_from_xml(node)
+ };
+ }
+
+ bool ready_to_play() const { return wave_hz && sample_rate_hz; }
+ };
+
+ Play::Connection _play;
+
+ Attr _attr { };
+ Phase _phase { };
+
+ Phase _phase_increment() const
+ {
+ return { uint16_t(uint32_t(_attr.wave_hz*Phase::PI2)/_attr.sample_rate_hz) };
+ }
+
+ unsigned _samples_per_period(unsigned period_ms) const
+ {
+ return (period_ms*_attr.sample_rate_hz)/1000;
+ }
+
+ void _produce_samples(Waveform const &waveform, Phase increment,
+ unsigned num_samples, auto const fn)
+ {
+ for (unsigned i = 0; i < num_samples; i++) {
+ fn(waveform.value(_phase));
+ _phase.angle += increment.angle;
+ }
+ }
+
+ Channel(Env &env, Xml_node const &node)
+ :
+ label(_label_from_xml(node)), _play(env, label)
+ { }
+
+ virtual ~Channel() { };
+
+ bool ready_to_play() const { return _attr.ready_to_play(); }
+
+ Play::Time_window play(Main const &main, Play::Time_window previous, unsigned period_ms)
+ {
+ Play::Duration const duration { .us = period_ms*1000 };
+ unsigned const num_samples = _samples_per_period(period_ms);
+ Phase const increment = _phase_increment();
+
+ return _play.schedule_and_enqueue(previous, duration, [&] (auto &submit) {
+ main.with_waveform(_attr.wave, [&] (Waveform const &waveform) {
+ _produce_samples(waveform, increment, num_samples,
+ [&] (float v) { submit(v); }); }); });
+ }
+
+ void play_at(Main const &main, Play::Time_window tw, unsigned period_ms)
+ {
+ unsigned const num_samples = _samples_per_period(period_ms);
+ Phase const increment = _phase_increment();
+
+ _play.enqueue(tw, [&] (auto &submit) {
+ main.with_waveform(_attr.wave, [&] (Waveform const &waveform) {
+ _produce_samples(waveform, increment, num_samples,
+ [&] (float v) { submit(v); }); }); });
+ }
+
+ void stop() { _play.stop(); }
+
+ void update(Xml_node const &node, Attr const defaults)
+ {
+ _attr = Attr::from_xml(node, defaults);
+ };
+
+ /*
+ * List_model::Element
+ */
+ static bool type_matches(Xml_node const &node)
+ {
+ return node.has_type("play");
+ }
+
+ bool matches(Xml_node const &node) const
+ {
+ return _label_from_xml(node) == label;
+ }
+ };
+
+ List_model> _channels { };
+
+ Registry> _channel_registry { }; /* for non-const for_each */
+
+ struct Config
+ {
+ unsigned period_ms;
+
+ Channel::Attr channel_defaults;
+
+ static Config from_xml(Xml_node const &config)
+ {
+ return {
+ .period_ms = config.attribute_value("period_ms", 10u),
+ .channel_defaults = Channel::Attr::from_xml(config, {
+ .sample_rate_hz = 44100u,
+ .wave_hz = 1000.0,
+ .wave = Wave::SINE
+ }) };
+ }
+ };
+
+ Config _config { };
+
+ Play::Time_window _time_window { };
+
+ void _play_channels()
+ {
+ bool const was_playing = ( _time_window.start != _time_window.end );
+
+ /*
+ * The first channel drives the time window that is reused for all
+ * other channels to attain time-synchronized data.
+ */
+ bool first = true;
+ bool playing = false;
+ _channel_registry.for_each([&] (Registered &channel) {
+ if (channel.ready_to_play()) {
+ playing = true;
+ if (first)
+ _time_window = channel.play(*this, _time_window, _config.period_ms);
+ else
+ channel.play_at(*this, _time_window, _config.period_ms);
+ first = false;
+ }
+ });
+
+ if (was_playing && !playing) {
+ _channel_registry.for_each([&] (Registered &channel) {
+ channel.stop(); });
+ _time_window = { };
+ }
+ }
+
+ void _handle_timer() { _play_channels(); }
+
+ void _handle_config()
+ {
+ _config_ds.update();
+ Xml_node const &config = _config_ds.xml();
+
+ _config = Config::from_xml(config);
+
+ _channels.update_from_xml(config,
+ [&] (Xml_node const &node) -> Registered & {
+ return *new (_heap)
+ Registered(_channel_registry, _env, node); },
+ [&] (Registered &channel) {
+ destroy(_heap, &channel); },
+ [&] (Channel &channel, Xml_node const &node) {
+ channel.update(node, _config.channel_defaults); }
+ );
+
+ if (_config.period_ms)
+ _timer.trigger_periodic(_config.period_ms*1000);
+ }
+
+ Main(Env &env) : _env(env)
+ {
+ _timer.sigh(_timer_handler);
+ _config_ds.sigh(_config_handler);
+ _handle_config();
+ }
+};
+
+
+void Component::construct(Genode::Env &env) { static Waveform_player::Main main(env); }
diff --git a/repos/os/src/app/waveform_player/target.mk b/repos/os/src/app/waveform_player/target.mk
new file mode 100644
index 0000000000..5fc41df6e5
--- /dev/null
+++ b/repos/os/src/app/waveform_player/target.mk
@@ -0,0 +1,3 @@
+TARGET = waveform_player
+SRC_CC = main.cc
+LIBS = base
diff --git a/repos/os/src/server/record_play_mixer/README b/repos/os/src/server/record_play_mixer/README
new file mode 100644
index 0000000000..183b2f2178
--- /dev/null
+++ b/repos/os/src/server/record_play_mixer/README
@@ -0,0 +1,78 @@
+The mixer routes and mixes audio signals produced by play clients to record
+clients according to its configuration. Typical play clients are an audio
+player or a microphone driver whereas typical record clients are an audio
+recorder or an audio-output driver.
+
+Both play and record clients are expected to operate periodically. The number
+of samples produced per period is up to each client and does not need to be
+constant over time. The mixer infers the used sample rates and periods by
+observing the behavior of the clients. Sample rates between play and record
+clients are converted automatically.
+
+The latency depends on the period lengths of both record and play sides as
+well as on the observed jitter. By default, the mixer automatically determines
+the buffering parameters needed for continuous playback in the presence of
+jitter. If jitter for a given scenario is known, the expected jitter can be
+configured such that the mixer won't try to optimize latency beyond known-good
+bounds.
+
+Configuration
+~~~~~~~~~~~~~
+
+A simple mixer configuration looks as follows:
+
+!
+!
+!
+!
+!
+!
+!
+!
+!
+
+This configuration defines two signals "left" and "right" that are mixed from
+the audio input of the matching clients. In the example, each play
+session labeled as "left" is mixed into the "left" signal. Each node
+can host an arbitrary number of nodes. The same policy can appear
+at multiple nodes.
+
+A node assigns a signal to a record client. In the example, a record
+client labeled "left" is connected to the signal "left".
+
+The mixer allows for the cascading of nodes. For example, the following
+signal "lefty" is a mix if the two signals "left" and "right", weighted by
+respective volume attributes.
+
+!
+!
+!
+!
+
+The 'volume' can be specified for , , and nodes and
+defines a factor that is multiplied with each sample value.
+
+The 'jitter_ms' value at the node denotes the expected jitter. The
+mixer won't try to optimize latency beyond that value. A jitter of 5 ms
+means that the periodicity of play and record clients is expected to vary
+from period to period up to 5 ms. To attain continuous playback given
+this situation, the mixer buffers 10 ms of data. In the worst case - should
+the play client come 5 ms too late while the record client comes 5 ms too
+early - the audio still does not stutter.
+
+In cases where the jittering behavior of client differs, it is possible to
+define the expected jitter as 'jitter_ms' attribute at individual and
+ nodes. This way, a high-priority audio driver strictly driven by
+interrupts in 5 ms intervals can be configured to a low jitter value like 1
+ms. This way, a high-priority sporadic play client operating at a period of 5
+ms can attain low latency while a low-priority media player operating at a
+period of 80 ms could be configured to a jitter of 10 ms. In the presence of
+multiple jitter attributes (e.g, one present in the node and one
+present in a node, the highest value takes effect.
+
+Example
+~~~~~~~
+
+The _gems/run/waveform_player.run_ script illustrates the integration and
+configuration of the mixer by using a waveform generator as play client and an
+oscilloscope as record client.
diff --git a/repos/os/src/server/record_play_mixer/audio_signal.h b/repos/os/src/server/record_play_mixer/audio_signal.h
new file mode 100644
index 0000000000..7269c7e22c
--- /dev/null
+++ b/repos/os/src/server/record_play_mixer/audio_signal.h
@@ -0,0 +1,66 @@
+/*
+ * \brief Audio-signal types
+ * \author Norman Feske
+ * \date 2023-12-13
+ */
+
+/*
+ * Copyright (C) 2023 Genode Labs GmbH
+ *
+ * This file is part of the Genode OS framework, which is distributed
+ * under the terms of the GNU Affero General Public License version 3.
+ */
+
+#ifndef _AUDIO_SIGNAL_H_
+#define _AUDIO_SIGNAL_H_
+
+/* Genode includes */
+#include
+
+/* local includes */
+#include
+
+namespace Mixer { class Audio_signal; }
+
+
+class Mixer::Audio_signal : List_model::Element, public Sample_producer
+{
+ public:
+
+ using Name = String<32>;
+
+ static constexpr auto mix_type_name = "mix";
+
+ private:
+
+ friend class List_model;
+ friend class List;
+
+ public:
+
+ Name const name;
+
+ Audio_signal(Name const name) : name(name) { };
+
+ virtual void update(Xml_node const &) { }
+
+ virtual void bind_inputs(List_model &, Play_sessions &) = 0;
+
+ /**
+ * List_model::Element
+ */
+ bool matches(Xml_node const &node) const
+ {
+ return node.attribute_value("name", Name()) == name;
+ }
+
+ /**
+ * List_model::Element
+ */
+ static bool type_matches(Xml_node const &node)
+ {
+ return node.has_type(mix_type_name);
+ }
+};
+
+#endif /* _AUDIO_SIGNAL_H_ */
diff --git a/repos/os/src/server/record_play_mixer/main.cc b/repos/os/src/server/record_play_mixer/main.cc
new file mode 100644
index 0000000000..81abf7d093
--- /dev/null
+++ b/repos/os/src/server/record_play_mixer/main.cc
@@ -0,0 +1,215 @@
+/*
+ * \brief Audio mixer
+ * \author Norman Feske
+ * \date 2023-12-13
+ */
+
+/*
+ * Copyright (C) 2023 Genode Labs GmbH
+ *
+ * This file is part of the Genode OS framework, which is distributed
+ * under the terms of the GNU Affero General Public License version 3.
+ */
+
+/* Genode includes */
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+/* local includes */
+#include
+#include
+#include
+
+namespace Mixer { struct Main; }
+
+
+struct Mixer::Main : Record_session::Operations, Play_session::Operations
+{
+ Env &_env;
+
+ Attached_rom_dataspace _config { _env, "config" };
+
+ Heap _heap { _env.ram(), _env.rm() };
+
+ Timer::Connection _timer { _env };
+
+ Expanding_reporter _state_reporter { _env, "state", "state" };
+
+ Play_sessions _play_sessions { };
+ Record_sessions _record_sessions { };
+
+ Play_root _play_root { _env, _heap, _play_sessions, *this };
+ Record_root _record_root { _env, _heap, _record_sessions, *this };
+
+ using Config_version = String<32>;
+ Config_version _version { };
+
+ Constructible _clock_from_config { };
+
+ Time_window_scheduler::Config _global_record_config { };
+ unsigned _global_play_jitter_us { };
+
+ /**
+ * Record_session::Operations
+ */
+ Clock current_clock_value() override
+ {
+ if (_clock_from_config.constructed())
+ return *_clock_from_config;
+
+ return Clock { unsigned(_timer.elapsed_us()) };
+ }
+
+ List_model _audio_signals { };
+
+ /**
+ * Record_session::Operations
+ */
+ void bind_sample_producers_to_record_sessions() override
+ {
+ _record_sessions.for_each([&] (Record_session &record_session) {
+
+ record_session.release_sample_producer();
+
+ using Name = Audio_signal::Name;
+ with_matching_policy(record_session.label(), _config.xml(),
+ [&] (Xml_node const &policy) {
+ record_session.apply_config(policy, _global_record_config);
+ Name const name = policy.attribute_value("record", Name());
+ _audio_signals.for_each([&] (Audio_signal &audio_signal) {
+ if (audio_signal.name == name)
+ record_session.assign_sample_producer(audio_signal); });
+ },
+ [&] {
+ log("no policy for session ", record_session.label());
+ });
+ });
+ }
+
+ /**
+ * Play_session::Operations
+ */
+ void bind_play_sessions_to_audio_signals() override
+ {
+ _play_sessions.for_each([&] (Play_session &play_session) {
+ play_session.global_jitter_us(_global_play_jitter_us); });
+
+ _audio_signals.for_each([&] (Audio_signal &audio_signal) {
+ audio_signal.bind_inputs(_audio_signals, _play_sessions); });
+ }
+
+ /**
+ * Play_session::Operations
+ */
+ void wakeup_record_clients() override
+ {
+ _record_sessions.for_each([&] (Record_session &record_session) {
+ record_session.wakeup(); });
+ }
+
+ void _generate_state_report(Xml_generator &xml) const
+ {
+ if (_clock_from_config.constructed())
+ xml.attribute("clock_value", _clock_from_config->us());
+ (void)xml;
+ }
+
+ void _update_state_report()
+ {
+ _state_reporter.generate([&] (Xml_generator &xml) {
+ xml.attribute("version", _version);
+ _generate_state_report(xml);
+ });
+ }
+
+ void _handle_config()
+ {
+ _config.update();
+ Xml_node const config = _config.xml();
+
+ double const default_jitter_ms = config.attribute_value("jitter_ms", 1.0);
+ _global_record_config = {
+ .period_us = us_from_ms_attr(config, "record_period_ms", 5.0),
+ .jitter_us = us_from_ms_attr(config, "record_jitter_ms", default_jitter_ms),
+ };
+ _global_play_jitter_us = us_from_ms_attr(config, "play_jitter_ms", default_jitter_ms);
+
+ _version = config.attribute_value("version", _version);
+
+ _clock_from_config.destruct();
+ if (config.has_attribute("clock_value"))
+ _clock_from_config.construct(config.attribute_value("clock_value", 0u));
+
+ _audio_signals.update_from_xml(config,
+
+ /* create */
+ [&] (Xml_node const &node) -> Audio_signal & {
+
+ if (node.has_type(Audio_signal::mix_type_name))
+ return *new (_heap) Mix_signal(node, _heap);
+
+ error("unable to create signal: ", node);
+ sleep_forever(); /* should never be reachable */
+ },
+
+ /* destroy */
+ [&] (Audio_signal &audio_signal) {
+ destroy(_heap, &audio_signal); },
+
+ /* update */
+ [&] (Audio_signal &audio_signal, Xml_node const &node) {
+ audio_signal.update(node); }
+ );
+
+ bind_play_sessions_to_audio_signals();
+ bind_sample_producers_to_record_sessions();
+
+ _update_state_report();
+ }
+
+ struct Timer_count { unsigned value; };
+
+ Timer_count _count { };
+
+ void _handle_timer()
+ {
+ _count.value++;
+ }
+
+ Timer_count _once_in_a_while_triggered { };
+
+ bool once_in_a_while() override
+ {
+ if (_count.value == _once_in_a_while_triggered.value)
+ return false;
+
+ _once_in_a_while_triggered = _count;
+ return true;
+ }
+
+ Signal_handler _config_handler
+ { _env.ep(), *this, &Main::_handle_config };
+
+ Signal_handler _timer_handler
+ { _env.ep(), *this, &Main::_handle_timer };
+
+ Main(Genode::Env &env) : _env(env)
+ {
+ _config.sigh(_config_handler);
+ _handle_config();
+
+ _timer.sigh(_timer_handler);
+ _timer.trigger_periodic(1000*1000);
+
+ _env.parent().announce(_env.ep().manage(_play_root));
+ _env.parent().announce(_env.ep().manage(_record_root));
+ }
+};
+
+
+void Component::construct(Genode::Env &env) { static Mixer::Main inst(env); }
diff --git a/repos/os/src/server/record_play_mixer/median.h b/repos/os/src/server/record_play_mixer/median.h
new file mode 100644
index 0000000000..a8806fb13e
--- /dev/null
+++ b/repos/os/src/server/record_play_mixer/median.h
@@ -0,0 +1,65 @@
+/*
+ * \brief Utility to determine the median of N values
+ * \author Norman Feske
+ * \date 2023-12-13
+ */
+
+/*
+ * Copyright (C) 2024 Genode Labs GmbH
+ *
+ * This file is part of the Genode OS framework, which is distributed
+ * under the terms of the GNU Affero General Public License version 3.
+ */
+
+#ifndef _MEDIAN_H_
+#define _MEDIAN_H_
+
+namespace Mixer { template struct Median; }
+
+
+template
+struct Mixer::Median
+{
+ T sorted[N] { };
+
+ unsigned n = 0;
+
+ void capture(T v)
+ {
+ if (n >= N)
+ return;
+
+ unsigned pos = 0;
+
+ while ((pos < n) && (v > sorted[pos]))
+ pos++;
+
+ n++;
+ for (unsigned i = n; i > pos; i--)
+ sorted[i] = sorted[i - 1];
+
+ sorted[pos] = v;
+ }
+
+ T median() const { return sorted[n/2]; }
+
+ T jitter() const
+ {
+ if (n < 2)
+ return { };
+
+ return max(median() - sorted[0], sorted[n - 1] - median());
+ }
+
+ void print(Output &out) const
+ {
+ for (unsigned i = 0; i < n; i++) {
+ Genode::print(out, sorted[i]);
+ if (i + 1 < n)
+ Genode::print(out, ", ");
+ }
+ }
+};
+
+#endif /* _MEDIAN_H_ */
+
diff --git a/repos/os/src/server/record_play_mixer/mix_signal.h b/repos/os/src/server/record_play_mixer/mix_signal.h
new file mode 100644
index 0000000000..eec7573e7c
--- /dev/null
+++ b/repos/os/src/server/record_play_mixer/mix_signal.h
@@ -0,0 +1,237 @@
+/*
+ * \brief Mix signal
+ * \author Norman Feske
+ * \date 2023-18-13
+ */
+
+/*
+ * Copyright (C) 2023 Genode Labs GmbH
+ *
+ * This file is part of the Genode OS framework, which is distributed
+ * under the terms of the GNU Affero General Public License version 3.
+ */
+
+#ifndef _MIX_SIGNAL_H_
+#define _MIX_SIGNAL_H_
+
+/* local includes */
+#include
+
+namespace Mixer { class Mix_signal; }
+
+
+struct Mixer::Mix_signal : Audio_signal
+{
+ Allocator &_alloc;
+
+ Volume _volume { };
+
+ struct Input : Interface
+ {
+ Volume volume;
+
+ Input(Xml_node const &node) : volume(Volume::from_xml(node)) { }
+ };
+
+ struct Named_signal_input : Input
+ {
+ using Name = Audio_signal::Name;
+
+ Name const name;
+
+ struct { Sample_producer *_sample_producer_ptr = nullptr; };
+
+ Named_signal_input(Xml_node const &node)
+ :
+ Input(node), name(node.attribute_value("name", Name()))
+ { }
+
+ void with_sample_producer(auto const &fn)
+ {
+ if (_sample_producer_ptr)
+ fn(*_sample_producer_ptr, volume);
+ }
+ };
+
+ struct Play_session_input : Input
+ {
+ using Label = String<64>;
+ using Suffix = String<64>;
+
+ Allocator &_alloc;
+
+ Label const label;
+ Suffix const suffix;
+ unsigned const jitter_us;
+
+ struct Sample_producer_ptr { Sample_producer *ptr = nullptr; };
+
+ using Registered_sample_producer_ptr = Registered_no_delete;
+
+ Registry _sample_producer_ptrs { };
+
+ Play_session_input(Xml_node const &node, Allocator &alloc)
+ :
+ Input(node), _alloc(alloc),
+ label (node.attribute_value("label", Label())),
+ suffix(node.attribute_value("label_suffix", Suffix())),
+ jitter_us(us_from_ms_attr(node, "jitter_ms", 0.0))
+ { }
+
+ ~Play_session_input() { detach_all_producers(); }
+
+ void try_attach(Play_session &session)
+ {
+ auto matches = [&] (Session_label const &session_label) -> bool
+ {
+ if (label.valid() && label == session_label)
+ return true;
+
+ if (!suffix.valid())
+ return false;
+
+ if (session_label.length() < suffix.length())
+ return false;
+
+ size_t const len = suffix.length() - 1;
+ char const * const label_suffix_cstring =
+ session_label.string() + session_label.length() - 1 - len;
+
+ return (strcmp(label_suffix_cstring, suffix.string(), len) == 0);
+ };
+
+ if (!matches(session.label()))
+ return;
+
+ session.expect_jitter_us(jitter_us);
+
+ new (_alloc) Registered_sample_producer_ptr(_sample_producer_ptrs,
+ Sample_producer_ptr { &session });
+ }
+
+ void detach_all_producers()
+ {
+ _sample_producer_ptrs.for_each([&] (Registered_sample_producer_ptr &ptr) {
+ destroy(_alloc, &ptr); });
+ }
+
+ void for_each_sample_producer(auto const &fn)
+ {
+ _sample_producer_ptrs.for_each([&] (Sample_producer_ptr &ptr) {
+ if (ptr.ptr)
+ fn(*ptr.ptr, volume); });
+ }
+ };
+
+ Registry> _named_signal_inputs { };
+ Registry> _play_session_inputs { };
+
+ Sample_buffer<512> _input_buffer { };
+
+ bool _input_buffer_used = false;
+ bool _warned_once = false;
+
+ /*
+ * Helper to protect against nested call of 'produce_sample_data'
+ */
+ struct Used_guard
+ {
+ bool &_used;
+ Used_guard(bool &used) : _used(used) { _used = true; }
+ ~Used_guard() { _used = false; }
+ };
+
+ Mix_signal(Xml_node const &node, Allocator &alloc)
+ :
+ Audio_signal(node.attribute_value("name", Name())),
+ _alloc(alloc)
+ { }
+
+ /**
+ * Audio_signal interface
+ */
+ void update(Xml_node const &node) override
+ {
+ _volume = Volume::from_xml(node);
+
+ _named_signal_inputs.for_each([&] (Registered &input) {
+ destroy(_alloc, &input); });
+
+ _play_session_inputs.for_each([&] (Registered &input) {
+ destroy(_alloc, &input); });
+
+ node.for_each_sub_node([&] (Xml_node const &input_node) {
+
+ if (input_node.has_type("signal"))
+ new (_alloc) Registered(_named_signal_inputs, input_node);
+
+ if (input_node.has_type("play"))
+ new (_alloc) Registered(_play_session_inputs, input_node, _alloc);
+ });
+ }
+
+ /**
+ * Audio_signal interface
+ */
+ void bind_inputs(List_model &named_signals,
+ Play_sessions &play_sessions) override
+ {
+ _named_signal_inputs.for_each([&] (Named_signal_input &input) {
+ named_signals.for_each([&] (Audio_signal &named_signal) {
+ if (named_signal.name == input.name) {
+ input._sample_producer_ptr = &named_signal; } }); });
+
+ _play_session_inputs.for_each([&] (Play_session_input &input) {
+ input.detach_all_producers();
+ play_sessions.for_each([&] (Play_session &play_session) {
+ input.try_attach(play_session); }); });
+ }
+
+ /**
+ * Sample_producer interface
+ */
+ bool produce_sample_data(Time_window const tw, Float_range_ptr &samples) override
+ {
+ if (_input_buffer_used) {
+ if (!_warned_once)
+ error("attempt to feed output (", name, ") as input to the same node");
+ _warned_once = true;
+ return false;
+ }
+
+ samples.clear();
+
+ Used_guard guard(_input_buffer_used);
+
+ auto for_each_sample_producer = [&] (auto const &fn)
+ {
+ _named_signal_inputs.for_each([&] (Named_signal_input &input) {
+ input.with_sample_producer([&] (Sample_producer &p, Volume v) {
+ fn(p, v); }); });
+
+ _play_session_inputs.for_each([&] (Play_session_input &input) {
+ input.for_each_sample_producer([&] (Sample_producer &p, Volume v) {
+ fn(p, v); }); });
+ };
+
+ bool result = false;
+
+ for_each_sub_window<_input_buffer.CAPACITY>(tw, samples,
+ [&] (Time_window sub_tw, Float_range_ptr &dst) {
+ for_each_sample_producer([&] (Sample_producer &producer, Volume volume) {
+
+ /* render input into '_input_buffer", mix result into 'dst' */
+ Float_range_ptr input_dst(_input_buffer.values, dst.num_floats);
+ input_dst.clear();
+ result |= producer.produce_sample_data(sub_tw, input_dst);
+ input_dst.scale(volume.value);
+ dst.add(input_dst);
+ });
+ });
+
+ samples.scale(_volume.value);
+ return result;
+ }
+};
+
+#endif /* _MIX_SIGNAL_H_ */
diff --git a/repos/os/src/server/record_play_mixer/play_session.h b/repos/os/src/server/record_play_mixer/play_session.h
new file mode 100644
index 0000000000..2ed054223f
--- /dev/null
+++ b/repos/os/src/server/record_play_mixer/play_session.h
@@ -0,0 +1,477 @@
+/*
+ * \brief Play service of the audio mixer
+ * \author Norman Feske
+ * \date 2023-12-13
+ */
+
+/*
+ * Copyright (C) 2023 Genode Labs GmbH
+ *
+ * This file is part of the Genode OS framework, which is distributed
+ * under the terms of the GNU Affero General Public License version 3.
+ */
+
+#ifndef _PLAY_SESSION_H_
+#define _PLAY_SESSION_H_
+
+/* Genode includes */
+#include
+#include
+#include
+#include
+
+/* local includes */
+#include
+#include
+
+namespace Mixer { class Play_root; }
+
+
+class Mixer::Play_session : public Session_object,
+ public Sample_producer,
+ private Registry::Element
+{
+ public:
+
+ struct Operations : virtual Clock_operations
+ {
+ virtual void bind_play_sessions_to_audio_signals() = 0;
+ virtual void wakeup_record_clients() = 0;
+ };
+
+ private:
+
+ Attached_ram_dataspace _ds;
+
+ Shared_buffer const &_buffer = *_ds.local_addr();
+
+ Operations &_operations;
+
+ Play::Seq _latest_seq { };
+ Play::Seq _stopped_seq { }; /* latest seq number at stop time */
+
+ bool _stopped() const { return _latest_seq.value() == _stopped_seq.value(); }
+
+ using Scheduler = Time_window_scheduler;
+
+ Scheduler _scheduler { };
+
+ unsigned _expected_jitter_us = 0;
+
+ /*
+ * Cached meta data fetched from shared buffer
+ */
+ struct Slot
+ {
+ Clock start, end;
+ unsigned sample_start;
+ unsigned num_samples;
+ Play::Seq seq;
+
+ unsigned duration_us = end.us_since(start);
+
+ bool _valid() const { return duration_us > 0 && num_samples > 1; }
+
+ unsigned dt() const
+ {
+ return _valid() ? duration_us / num_samples : 0;
+ }
+
+ bool contains(Clock t) const
+ {
+ return _valid() && !start.later_than(t)
+ && t.earlier_than(end);
+ }
+
+ void with_index_for_t(Clock t, auto const &fn) const
+ {
+ if (!contains(t))
+ return;
+
+ unsigned const rel_t = t.us_since(start);
+ unsigned const index = (rel_t*num_samples) / duration_us;
+
+ fn(index);
+ }
+
+ void with_time_window_at_index(unsigned i, auto const &fn) const
+ {
+ if (i >= num_samples || !_valid())
+ return;
+
+ Clock const t_start = start.after_us((i*duration_us) / num_samples);
+
+ fn(t_start, dt());
+ }
+
+ float u_for_t(unsigned index, Clock t) const
+ {
+ float result = 0.5f;
+ with_time_window_at_index(index, [&] (Clock const t_start, unsigned dt) {
+ result = float(t.us_since(t_start))/float(dt); });
+
+ auto clamped = [] (float v) { return min(1.0f, max(0.0f, v)); };
+
+ return clamped(result);
+ }
+
+ void print(Output &out) const
+ {
+ Genode::print(out, Time_window { start.us(), end.us() }, " seq=", seq.value());
+ }
+ };
+
+ Slot _slots[Shared_buffer::NUM_SLOTS] { };
+
+ /**
+ * Coordinate of a sample within the shared buffer
+ */
+ struct Position
+ {
+ unsigned slot_id;
+ unsigned index; /* relative to the slot's 'sample_start' */
+
+ Position next(Play_session const &session) const
+ {
+ if (index + 1 < session._slots[slot_id].num_samples)
+ return {
+ .slot_id = slot_id,
+ .index = index + 1,
+ };
+
+ /* proceed to next slot */
+ return {
+ .slot_id = (slot_id + 1) % Shared_buffer::NUM_SLOTS,
+ .index = 0,
+ };
+ }
+ };
+
+ struct Probe
+ {
+ float v[4] { };
+ float u = 0.0f;
+
+ Probe(Play_session const &session, Position pos, Clock t)
+ {
+ /*
+ * Technically, the 'u' value ought to be computed between t1
+ * and t2 (not between t0 and t1). Since the sample values are
+ * taken in steps of dt, the u values are the same except when
+ * dt is not constant (when crossing slot boundaries). However,
+ * even in this case, u_01 approximates u_12.
+ */
+ u = session._slots[pos.slot_id].u_for_t(pos.index, t);
+
+ for (unsigned i = 0; i < 4; i++, pos = pos.next(session)) {
+ unsigned const index = session._slots[pos.slot_id].sample_start + pos.index;
+ v[i] = session._buffer.samples[index % Shared_buffer::MAX_SAMPLES];
+ }
+ }
+
+ void print(Output &out) const
+ {
+ Genode::print(out, " ", v[0], " ", v[1], " (u:", Right_aligned(6, u), ")"
+ " ", v[2], " ", v[3]);
+ }
+ };
+
+ enum class Probe_result { OK, MISSING, AMBIGUOUS };
+
+ Probe_result _with_start_position_at(Clock t, auto fn) const
+ {
+ Position pos { };
+
+ unsigned matching_slots = 0;
+
+ for (unsigned slot_id = 0; slot_id < Shared_buffer::NUM_SLOTS; slot_id++) {
+ _slots[slot_id].with_index_for_t(t, [&] (unsigned index) {
+ matching_slots++;
+ pos = Position { .slot_id = slot_id,
+ .index = index, }; }); }
+
+ if (matching_slots == 1u) {
+ fn(pos);
+ return Probe_result::OK;
+ }
+
+ if (matching_slots == 0u)
+ return Probe_result::MISSING;
+
+ return Probe_result::AMBIGUOUS;
+ }
+
+ Probe_result _with_interpolated_sample_value(Clock const t, auto const &fn) const
+ {
+ return _with_start_position_at(t, [&] (Position pos) {
+
+ Probe probe(*this, pos, t);
+
+ /* b-spline blending functions (u and v denote position v1 <-> v2) */
+ float const u = probe.u, v = 1.0f - u,
+ uu = u*u, uuu = u*uu,
+ vv = v*v, vvv = v*vv;
+
+ float const b0 = vvv/6.0f,
+ b1 = uuu/2.0f - uu + 4.0f/6.0f,
+ b2 = vvv/2.0f - vv + 4.0f/6.0f,
+ b3 = uuu/6.0f;
+
+ float const avg = b0*probe.v[0] + b1*probe.v[1] + b2*probe.v[2] + b3*probe.v[3];
+
+ fn(avg);
+ });
+ }
+
+ public:
+
+ Play_session(Play_sessions &sessions,
+ Env &env,
+ Resources const &resources,
+ Label const &label,
+ Diag const &diag,
+ Operations &operations)
+ :
+ Session_object(env.ep(), resources, label, diag),
+ Registry::Element(sessions, *this),
+ _ds(env.ram(), env.rm(), Play::Session::DATASPACE_SIZE),
+ _operations(operations)
+ {
+ _operations.bind_play_sessions_to_audio_signals();
+ }
+
+ void global_jitter_us(unsigned us) { _expected_jitter_us = us; };
+
+ void expect_jitter_us(unsigned us)
+ {
+ _expected_jitter_us = max(_expected_jitter_us, us);
+ }
+
+ /**
+ * Sample_producer interface
+ */
+ bool produce_sample_data(Time_window tw, Float_range_ptr &samples) override
+ {
+ bool result = false;
+
+ /*
+ * Make local copy of meta data from shared buffer to ensure
+ * operating on consistent values during 'produce_sample_data'.
+ * Import slot meta data only if not currently modified by the
+ * client.
+ */
+ for (unsigned i = 0; i < Shared_buffer::NUM_SLOTS; i++) {
+
+ Play::Session::Shared_buffer::Slot const &src = _buffer.slots[i];
+ Slot &dst = _slots[i];
+
+ dst = { };
+
+ Play::Seq const acquired_seq = src.acquired_seq;
+
+ Slot const slot { .start = Clock { src.time_window.start },
+ .end = Clock { src.time_window.end },
+ .sample_start = src.sample_start.index,
+ .num_samples = src.num_samples.value(),
+ .seq = src.acquired_seq };
+
+ Play::Seq const committed_seq = src.committed_seq;
+
+ if (acquired_seq.value() == committed_seq.value()) {
+ dst = slot;
+
+ if (_latest_seq < slot.seq)
+ _latest_seq = slot.seq;
+ }
+ }
+
+ auto anything_scheduled = [&]
+ {
+ for (unsigned i = 0; i < Shared_buffer::NUM_SLOTS; i++)
+ if (_slots[i].num_samples)
+ return true;
+ return false;
+ };
+
+ if (!anything_scheduled())
+ return false;
+
+ for_each_sub_window<1>(tw, samples, [&] (Time_window sub_tw, Float_range_ptr &dst) {
+
+ Clock const t { sub_tw.start };
+
+ auto probe_result = _with_interpolated_sample_value(t,
+ [&] (float v) {
+ dst.start[0] = v;
+ result = true; });
+
+ if (probe_result == Probe_result::OK)
+ return;
+
+ if (_operations.once_in_a_while()) {
+
+ bool earlier_than_avail_samples = false,
+ later_than_avail_samples = false;
+
+ for (unsigned i = 0; i < Shared_buffer::NUM_SLOTS; i++) {
+ if (_slots[i].duration_us) {
+ if (t.earlier_than(_slots[i].start))
+ earlier_than_avail_samples = true;
+
+ if (_slots[i].end.earlier_than(t))
+ later_than_avail_samples = true;
+ }
+ }
+
+ if (probe_result == Probe_result::MISSING) {
+ if (earlier_than_avail_samples) {
+ warning("required sample value is no longer available");
+ warning("(jitter config or period too high?)");
+ }
+ else if (later_than_avail_samples && !_stopped()) {
+ warning("required sample is not yet available");
+ warning("(increase 'jitter_ms' config attribute?)");
+ }
+ }
+
+ if (probe_result == Probe_result::AMBIGUOUS)
+ warning("ambiguous sample value for t=", float(t.us())/1000);
+ }
+ });
+
+ return result;
+ }
+
+
+ /****************************
+ ** Play session interface **
+ ****************************/
+
+ Dataspace_capability dataspace() { return _ds.cap(); }
+
+ Play::Time_window schedule(Play::Time_window previous,
+ Play::Duration duration,
+ Play::Num_samples num_samples)
+ {
+ using Time_window = Play::Time_window;
+
+ if (!duration.valid() || num_samples.value() == 0)
+ return { };
+
+ /* playback just started, reset scheduler */
+ if (previous.start == previous.end)
+ _scheduler = { };
+
+ _scheduler.track_activity({
+ .time = _operations.current_clock_value(),
+ .num_samples = num_samples.value()
+ });
+
+ if (_scheduler.learned_jitter_ms() > _expected_jitter_us/1000) {
+ if (_operations.once_in_a_while()) {
+ warning("jitter of ", _scheduler.learned_jitter_ms(), " ms is higher than expected");
+ warning("(increase 'jitter_ms' attribute of node?)");
+ }
+ }
+
+ Scheduler::Config const config {
+ .period_us = duration.us,
+ .jitter_us = _expected_jitter_us
+ };
+
+ Scheduler::Play_window_result const window =
+ _scheduler.play_window(config, previous, num_samples);
+
+ if (!_scheduler.consecutive())
+ _operations.wakeup_record_clients();
+
+ return window.convert(
+ [&] (Time_window const &tw) -> Time_window { return tw; },
+ [&] (Scheduler::Play_window_error e) -> Time_window {
+ Scheduler::Stats const stats = _scheduler.stats();
+ unsigned const period_us = stats.median_period_us;
+
+ switch (e) {
+
+ case Scheduler::Play_window_error::JITTER_TOO_LARGE:
+
+ if (_operations.once_in_a_while())
+ warning("jitter too large for period of ",
+ float(period_us)/1000.0f, " ms");
+
+ return {
+ .start = previous.end,
+ .end = Clock{previous.end}.after_us(1000).us()
+ };
+
+ case Scheduler::Play_window_error::INACTIVE:
+ /* cannot happen because of 'track_activity' call above */
+ error("attempt to allocate play window w/o activity");
+ }
+ return { };
+ }
+ );
+ }
+
+ void stop()
+ {
+ /* remember latest seq number at stop time */
+ for (unsigned i = 0; i < Shared_buffer::NUM_SLOTS; i++)
+ if (_latest_seq < _buffer.slots[i].committed_seq)
+ _latest_seq = _buffer.slots[i].committed_seq;
+
+ _stopped_seq = _latest_seq;
+
+ /* discard period-tracking state */
+ _scheduler = { };
+ }
+};
+
+
+class Mixer::Play_root : public Root_component
+{
+ private:
+
+ Env &_env;
+ Play_sessions &_sessions;
+ Play_session::Operations &_operations;
+
+ protected:
+
+ Play_session *_create_session(const char *args) override
+ {
+ if (session_resources_from_args(args).ram_quota.value < Play::Session::DATASPACE_SIZE)
+ throw Insufficient_ram_quota();
+
+ return new (md_alloc())
+ Play_session(_sessions,
+ _env,
+ session_resources_from_args(args),
+ session_label_from_args(args),
+ session_diag_from_args(args),
+ _operations);
+ }
+
+ void _upgrade_session(Play_session *s, const char *args) override
+ {
+ s->upgrade(ram_quota_from_args(args));
+ s->upgrade(cap_quota_from_args(args));
+ }
+
+ void _destroy_session(Play_session *session) override
+ {
+ Genode::destroy(md_alloc(), session);
+ _operations.bind_play_sessions_to_audio_signals();
+ }
+
+ public:
+
+ Play_root(Env &env, Allocator &md_alloc, Play_sessions &sessions,
+ Play_session::Operations &operations)
+ :
+ Root_component(&env.ep().rpc_ep(), &md_alloc),
+ _env(env), _sessions(sessions), _operations(operations)
+ { }
+};
+
+#endif /* _PLAY_SESSION_H_ */
diff --git a/repos/os/src/server/record_play_mixer/record_session.h b/repos/os/src/server/record_play_mixer/record_session.h
new file mode 100644
index 0000000000..facb9d53fb
--- /dev/null
+++ b/repos/os/src/server/record_play_mixer/record_session.h
@@ -0,0 +1,265 @@
+/*
+ * \brief Record service of the audio mixer
+ * \author Norman Feske
+ * \date 2023-12-13
+ */
+
+/*
+ * Copyright (C) 2023 Genode Labs GmbH
+ *
+ * This file is part of the Genode OS framework, which is distributed
+ * under the terms of the GNU Affero General Public License version 3.
+ */
+
+#ifndef _RECORD_SESSION_H_
+#define _RECORD_SESSION_H_
+
+/* Genode includes */
+#include
+#include
+#include
+
+/* local includes */
+#include
+#include
+
+namespace Mixer {
+
+ class Record_session;
+ using Record_sessions = Registry;
+ class Record_root;
+}
+
+
+class Mixer::Record_session : public Session_object,
+ private Registry::Element
+{
+ public:
+
+ struct Operations : virtual Clock_operations
+ {
+ virtual void bind_sample_producers_to_record_sessions() = 0;
+ };
+
+ private:
+
+ /*
+ * Noncopyable
+ */
+ Record_session(Record_session const &);
+ Record_session &operator = (Record_session const &);
+
+ Attached_ram_dataspace _ds;
+ Signal_context_capability _wakeup_sigh { };
+
+ using Scheduler = Time_window_scheduler;
+
+ Scheduler::Config _config { };
+ Scheduler _scheduler { };
+
+ float _volume { };
+
+ Time_window _previous { };
+
+ Constructible _stalled { };
+
+ Sample_producer *_sample_producer_ptr = nullptr;
+
+ Operations &_operations;
+
+ bool _produce_scaled_sample_data(Time_window tw, Float_range_ptr &samples_ptr)
+ {
+ if (!_sample_producer_ptr)
+ return false;
+
+ if (!_sample_producer_ptr->produce_sample_data(tw, samples_ptr))
+ return false;
+
+ samples_ptr.scale(_volume);
+ return true;
+ }
+
+ public:
+
+ Record_session(Record_sessions &sessions,
+ Env &env,
+ Resources const &resources,
+ Label const &label,
+ Diag const &diag,
+ Operations &operations)
+ :
+ Session_object(env.ep(), resources, label, diag),
+ Registry::Element(sessions, *this),
+ _ds(env.ram(), env.rm(), Record::Session::DATASPACE_SIZE),
+ _operations(operations)
+ {
+ _operations.bind_sample_producers_to_record_sessions();
+ }
+
+ void wakeup()
+ {
+ if (!_scheduler.consecutive() && _wakeup_sigh.valid())
+ Signal_transmitter(_wakeup_sigh).submit();
+ }
+
+ void assign_sample_producer(Sample_producer &s) { _sample_producer_ptr = &s; }
+
+ void release_sample_producer() { _sample_producer_ptr = nullptr; }
+
+ void apply_config(Xml_node const &config, Scheduler::Config global)
+ {
+ _volume = float(config.attribute_value("volume", 1.0));
+
+ _config = global;
+
+ auto override_us_from_ms_attr = [&] (auto const &attr, auto &value)
+ {
+ if (config.has_attribute(attr))
+ value = us_from_ms_attr(config, attr, 0.0);
+ };
+
+ override_us_from_ms_attr("period_ms", _config.period_us);
+ override_us_from_ms_attr("jitter_ms", _config.jitter_us);
+ }
+
+
+ /******************************
+ ** Record session interface **
+ ******************************/
+
+ Dataspace_capability dataspace() { return _ds.cap(); }
+
+ void wakeup_sigh(Signal_context_capability sigh)
+ {
+ _wakeup_sigh = sigh;
+ wakeup(); /* initial wakeup */
+ }
+
+ Record::Session::Record_result record(Record::Num_samples num_samples)
+ {
+ Clock const now = _operations.current_clock_value();
+
+ _scheduler.track_activity({
+ .time = now,
+ .num_samples = num_samples.value()
+ });
+
+ if (_scheduler.learned_jitter_ms() > _config.jitter_us/1000) {
+ if (_operations.once_in_a_while()) {
+ warning("jitter of ", _scheduler.learned_jitter_ms(), " ms is higher than expected");
+ warning("(increase 'jitter_ms' attribute of record node?)");
+ }
+ }
+
+ Time_window time_window { };
+
+ _scheduler.record_window(_config, _previous).with_result(
+ [&] (Time_window const &tw) {
+ time_window = tw;
+ },
+ [&] (Scheduler::Record_window_error e) {
+ Scheduler::Stats const stats = _scheduler.stats();
+ unsigned const period_us = stats.median_period_us;
+
+ switch (e) {
+
+ case Scheduler::Record_window_error::JITTER_TOO_LARGE:
+
+ if (_operations.once_in_a_while())
+ warning("jitter too large for period of ",
+ float(period_us)/1000.0f, " ms");
+
+ time_window = Time_window {
+ .start = _previous.end,
+ .end = Clock{_previous.end}.after_us(1000).us()
+ };
+ break;
+
+ case Scheduler::Record_window_error::INACTIVE:
+ /* cannot happen because of 'track_activity' call above */
+ error("attempt to allocate record window w/o activity");
+ }
+ }
+ );
+
+ Float_range_ptr samples_ptr(_ds.local_addr(), num_samples.value());
+
+ if (_produce_scaled_sample_data(time_window, samples_ptr)) {
+ _stalled.destruct();
+
+ } else {
+
+ samples_ptr.clear();
+
+ /* remember when samples become unavailable */
+ if (!_stalled.constructed())
+ _stalled.construct(now);
+
+ /* tell the client to stop recording after some time w/o samples */
+ if (now.later_than(_stalled->after_us(250*1000))) {
+ _scheduler = { };
+ _stalled.destruct();
+ return Depleted();
+ }
+ }
+
+ _previous = time_window;
+ return time_window;
+ }
+
+ void record_at(Record::Time_window time_window, Record::Num_samples num_samples)
+ {
+ Float_range_ptr samples_ptr(_ds.local_addr(), num_samples.value());
+
+ if (!_produce_scaled_sample_data(time_window, samples_ptr))
+ samples_ptr.clear();
+ }
+};
+
+
+class Mixer::Record_root : public Root_component
+{
+ private:
+
+ Env &_env;
+ Record_sessions &_sessions;
+ Record_session::Operations &_operations;
+
+ protected:
+
+ Record_session *_create_session(const char *args) override
+ {
+ if (session_resources_from_args(args).ram_quota.value < Record::Session::DATASPACE_SIZE)
+ throw Insufficient_ram_quota();
+
+ return new (md_alloc())
+ Record_session(_sessions,
+ _env,
+ session_resources_from_args(args),
+ session_label_from_args(args),
+ session_diag_from_args(args),
+ _operations);
+ }
+
+ void _upgrade_session(Record_session *s, const char *args) override
+ {
+ s->upgrade(ram_quota_from_args(args));
+ s->upgrade(cap_quota_from_args(args));
+ }
+
+ void _destroy_session(Record_session *session) override
+ {
+ Genode::destroy(md_alloc(), session);
+ }
+
+ public:
+
+ Record_root(Env &env, Allocator &md_alloc, Record_sessions &sessions,
+ Record_session::Operations &operations)
+ :
+ Root_component(&env.ep().rpc_ep(), &md_alloc),
+ _env(env), _sessions(sessions), _operations(operations)
+ { }
+};
+
+#endif /* _RECORD_SESSION_H_ */
diff --git a/repos/os/src/server/record_play_mixer/target.mk b/repos/os/src/server/record_play_mixer/target.mk
new file mode 100644
index 0000000000..c3c4f17a19
--- /dev/null
+++ b/repos/os/src/server/record_play_mixer/target.mk
@@ -0,0 +1,4 @@
+TARGET = record_play_mixer
+SRC_CC = main.cc
+LIBS = base
+INC_DIR += $(PRG_DIR)
diff --git a/repos/os/src/server/record_play_mixer/time_window_scheduler.h b/repos/os/src/server/record_play_mixer/time_window_scheduler.h
new file mode 100644
index 0000000000..3faee8d9ca
--- /dev/null
+++ b/repos/os/src/server/record_play_mixer/time_window_scheduler.h
@@ -0,0 +1,255 @@
+/*
+ * \brief Jitter-aware time-window scheduler
+ * \author Norman Feske
+ * \date 2024-01-11
+ */
+
+/*
+ * Copyright (C) 2024 Genode Labs GmbH
+ *
+ * This file is part of the Genode OS framework, which is distributed
+ * under the terms of the GNU Affero General Public License version 3.
+ */
+
+#ifndef _TIME_WINDOW_SCHEDULER_H_
+#define _TIME_WINDOW_SCHEDULER_H_
+
+/* Genode includes */
+#include
+
+/* local includes */
+#include
+#include
+
+namespace Mixer { class Time_window_scheduler; }
+
+
+class Mixer::Time_window_scheduler
+{
+ public:
+
+ struct Entry
+ {
+ Clock time;
+ unsigned num_samples;
+ };
+
+ struct Config
+ {
+ unsigned period_us; /* period assumed before measurements are available */
+ unsigned jitter_us; /* expected lower limit of jitter */
+ };
+
+ struct Stats
+ {
+ unsigned rate_hz;
+ unsigned median_period_us;
+ unsigned predicted_now_us;
+ unsigned jitter_us;
+
+ bool valid() const { return median_period_us > 0u; }
+
+ void print(Output &out) const
+ {
+ Genode::print(out, "rate_hz=", rate_hz,
+ " median_period_us=", median_period_us,
+ " predicted_now_us=", predicted_now_us,
+ " jitter_us=", jitter_us);
+ }
+ };
+
+ private:
+
+ static constexpr unsigned N = 5;
+
+ Entry _entries[N] { };
+
+ unsigned _curr_index = 0;
+ unsigned _num_entries = 0;
+
+ unsigned _learned_jitter_us = 5000;
+
+ void _with_nth_entry(unsigned n, auto const &fn) const
+ {
+ unsigned const i = (_curr_index + N - n) % N;
+ if (n < _num_entries)
+ fn(_entries[i]);
+ }
+
+ void _for_each_period(auto const &fn) const
+ {
+ for (unsigned i = 0; i + 1 < _num_entries; i++)
+ _with_nth_entry(i, [&] (Entry const &curr) {
+ _with_nth_entry(i + 1, [&] (Entry const &prev) {
+ fn(prev, curr); }); });
+ }
+
+ Stats _calc_stats() const
+ {
+ unsigned sum_period_us = 0,
+ num_periods = 0;
+
+ unsigned long sum_ages_us = 0, /* relative to latest */
+ sum_samples = 0;
+
+ Entry latest { };
+ _with_nth_entry(0, [&] (Entry const e) { latest = e; });
+
+ Median median_period_us { };
+
+ _for_each_period([&] (Entry const prev, Entry const curr) {
+
+ unsigned const period_us = curr.time.us_since(prev.time);
+
+ median_period_us.capture(period_us);
+
+ num_periods++;
+ sum_ages_us += latest.time.us_since(prev.time);
+ sum_period_us += period_us;
+ sum_samples += prev.num_samples;
+ });
+
+ if (num_periods == 0)
+ return { };
+
+ unsigned const avg_age_us = unsigned(sum_ages_us/num_periods);
+
+ return {
+ .rate_hz = (sum_period_us == 0) ? 0
+ : unsigned((sum_samples*1000*1000)/sum_period_us),
+
+ .median_period_us = median_period_us.median(),
+
+ .predicted_now_us = latest.time.before_us(avg_age_us)
+ .after_us(sum_period_us/2).us(),
+
+ .jitter_us = median_period_us.jitter(),
+ };
+ }
+
+ void _learn_jitter(Stats const &stats)
+ {
+ _learned_jitter_us = (99*_learned_jitter_us)/100;
+
+ if (stats.jitter_us > _learned_jitter_us)
+ _learned_jitter_us = stats.jitter_us;
+ }
+
+ /*
+ * Compute delay on account to the (pre-)fetching the four probe
+ * values. Noticeable with extremely low sample rates and large
+ * periods.
+ */
+ unsigned _prefetch_us(unsigned sample_rate_hz) const
+ {
+ if (sample_rate_hz == 0)
+ return 0;
+
+ unsigned const sample_distance_ns = (1000*1000*1000)/sample_rate_hz;
+
+ return (4*sample_distance_ns)/1000;
+ }
+
+ public:
+
+ Time_window_scheduler() { }
+
+ void track_activity(Entry entry)
+ {
+ _curr_index = (_curr_index + 1) % N;
+ _num_entries = min(_num_entries + 1, N);
+ _entries[_curr_index] = entry;
+ }
+
+ bool consecutive() const { return (_num_entries > 1); }
+
+ Stats stats() const { return _calc_stats(); }
+
+ unsigned learned_jitter_ms() const { return _learned_jitter_us/1000; }
+
+ enum class Play_window_error { INACTIVE, JITTER_TOO_LARGE };
+
+ using Play_window_result = Attempt;
+
+ Play_window_result play_window(Config config,
+ Play::Time_window previous,
+ Play::Num_samples num_samples)
+ {
+ if (_num_entries == 0)
+ return Play_window_error::INACTIVE;
+
+ if (!consecutive()) {
+ unsigned const rate_hz = num_samples.value()
+ ? config.period_us/num_samples.value()
+ : 0;
+ unsigned const prefetch_us = _prefetch_us(rate_hz);
+ Entry const now = _entries[_curr_index];
+ return Play::Time_window {
+ .start = now.time.after_us(prefetch_us).us(),
+ .end = now.time.after_us(config.period_us + prefetch_us).us()
+ };
+ }
+
+ Stats const stats = _calc_stats();
+
+ _learn_jitter(stats);
+
+ unsigned const jitter_us = max(_learned_jitter_us, config.jitter_us);
+ unsigned const delay_us = jitter_us + _prefetch_us(stats.rate_hz);
+
+ Clock const start { previous.end },
+ end { stats.predicted_now_us + stats.median_period_us + delay_us };
+
+ if (Clock::range_valid(start, end))
+ return Play::Time_window { start.us(), end.us() };
+
+ return Play_window_error::JITTER_TOO_LARGE;
+ }
+
+ enum class Record_window_error { INACTIVE, JITTER_TOO_LARGE };
+
+ using Record_window_result = Attempt;
+
+ Record_window_result record_window(Config config, Record::Time_window previous)
+ {
+ if (_num_entries == 0)
+ return Record_window_error::INACTIVE;
+
+ if (!consecutive()) {
+ Entry const now = _entries[_curr_index];
+ return Record::Time_window {
+ .start = now.time.before_us(config.period_us
+ + config.jitter_us).us(),
+ .end = now.time.us()
+ };
+ }
+
+ Stats const stats = _calc_stats();
+
+ _learn_jitter(stats);
+
+ unsigned const jitter_us = max(_learned_jitter_us, config.jitter_us);
+
+ Clock const start { previous.end },
+ end { Clock{stats.predicted_now_us}.before_us(jitter_us).us() };
+
+ if (Clock::range_valid(start, end))
+ return Record::Time_window { start.us(), end.us() };
+
+ return Record_window_error::JITTER_TOO_LARGE;
+ }
+
+ void print(Output &out) const
+ {
+ Stats const stats = _calc_stats();
+
+ Genode::print(out, "now=", _entries[_curr_index].time.us()/1000,
+ " (predicted ", stats.predicted_now_us/1000, ")"
+ " period=", float(stats.median_period_us)/1000.0f,
+ " jitter=", float(stats.jitter_us)/1000.0f,
+ " (learned ", float(_learned_jitter_us)/1000.0f, ")",
+ " prefetch=", float(_prefetch_us(stats.rate_hz))/1000.0f);
+ }
+};
+
+#endif /* _TIME_WINDOW_SCHEDULER_H_ */
diff --git a/repos/os/src/server/record_play_mixer/types.h b/repos/os/src/server/record_play_mixer/types.h
new file mode 100644
index 0000000000..a35c3663e9
--- /dev/null
+++ b/repos/os/src/server/record_play_mixer/types.h
@@ -0,0 +1,214 @@
+/*
+ * \brief Types used by the mixer
+ * \author Norman Feske
+ * \date 2023-12-13
+ */
+
+/*
+ * Copyright (C) 2023 Genode Labs GmbH
+ *
+ * This file is part of the Genode OS framework, which is distributed
+ * under the terms of the GNU Affero General Public License version 3.
+ */
+
+#ifndef _TYPES_H_
+#define _TYPES_H_
+
+/* Genode includes */
+#include
+#include
+#include
+#include
+#include
+
+namespace Mixer {
+
+ using namespace Genode;
+
+ /**
+ * Circular clock in microseconds, wrapping after 4 seconds
+ */
+ class Clock
+ {
+ private:
+
+ unsigned _us = 0;
+
+ static constexpr unsigned LIMIT = (1 << 22),
+ MASK = LIMIT - 1;
+
+ static constexpr unsigned _masked(unsigned v) { return v & MASK; }
+
+ static constexpr bool _positive(unsigned v)
+ {
+ return (v > 0) && (v < LIMIT/2);
+ }
+
+ public:
+
+ explicit Clock(unsigned us) : _us(_masked(us)) { };
+
+ Clock() { }
+
+ unsigned us() const { return _masked(_us); };
+ unsigned us_since(Clock past) const { return _masked(_us - past._us); }
+
+ Clock after_us (unsigned us) const { return Clock { _masked(_us + us) }; }
+ Clock before_us(unsigned us) const { return Clock { _masked(_us - us) }; }
+
+ bool earlier_than(Clock const &other) const
+ {
+ return (other.us() < us()) ? _positive(other.us() + LIMIT - us())
+ : _positive(other.us() - us());
+ }
+
+ bool later_than(Clock const &other) const
+ {
+ return other.earlier_than(*this);
+ }
+
+ static bool range_valid(Clock const &start, Clock const &end)
+ {
+ return _positive(end.us_since(start));
+ }
+ };
+
+ struct Clock_operations : Interface
+ {
+ virtual Clock current_clock_value() = 0;
+
+ /**
+ * Return true if the time is right for latent diagnostic output
+ *
+ * Used for limiting the rate of log messages on account of wrong
+ * audio parameters.
+ */
+ virtual bool once_in_a_while() = 0;
+ };
+
+ struct Float_range_ptr : Noncopyable
+ {
+ struct {
+ float *start;
+ unsigned num_floats;
+ };
+
+ Float_range_ptr(float *start, unsigned num_floats)
+ : start(start), num_floats(num_floats) { }
+
+ void clear()
+ {
+ for (size_t i = 0; i < num_floats; i++)
+ start[i] = 0.0f;
+ }
+
+ void add(Float_range_ptr const &other)
+ {
+ size_t const limit = min(num_floats, other.num_floats);
+ for (unsigned i = 0; i < limit; i++)
+ start[i] += other.start[i];
+ }
+
+ void scale(float const factor)
+ {
+ for (size_t i = 0; i < num_floats; i++)
+ start[i] *= factor;
+ }
+ };
+
+ template
+ struct Sample_buffer
+ {
+ float values[N] { };
+ static constexpr unsigned CAPACITY = N;
+ };
+
+ using Time_window = Record::Time_window;
+
+ struct Volume
+ {
+ float value;
+
+ static Volume from_xml(Xml_node const &node)
+ {
+ return { float(node.attribute_value("volume", 1.0)) };
+ }
+ };
+
+ struct Sample_producer : Noncopyable, Interface
+ {
+ virtual bool produce_sample_data(Time_window, Float_range_ptr &) = 0;
+ };
+
+ class Play_session;
+ using Play_sessions = Registry;
+
+ /**
+ * Call 'fn' for each sub window of a maximum of N samples
+ */
+ template
+ void for_each_sub_window(Time_window const tw, Float_range_ptr &samples, auto const &fn)
+ {
+ if (samples.num_floats == 0)
+ return;
+
+ Clock const start { tw.start },
+ end { tw.end };
+
+ unsigned pos = 0; /* position within 'samples' */
+
+ auto remain = [&] { return samples.num_floats - pos; };
+
+ /* time per sample in 1/1024 microseconds */
+ uint32_t const ascent { (end.us_since(start) << 10) / samples.num_floats };
+
+ while (remain() > 0) {
+
+ /* samples to process in this iteration */
+ unsigned const num_samples = min(remain(), N);
+
+ /* window of 'samples' buffer to process in this iteration */
+ Float_range_ptr sub_samples(samples.start + pos, num_samples);
+
+ /* sub window of 'tw' that corresponds to the current iteration */
+ unsigned const start_offset_us = pos * ascent,
+ end_offset_us = (pos + num_samples) * ascent;
+
+ Time_window const sub_window(start.after_us(start_offset_us >> 10).us(),
+ start.after_us(end_offset_us >> 10).us());
+
+ fn(sub_window, sub_samples);
+
+ pos += num_samples;
+ }
+ }
+
+ unsigned us_from_ms_attr(Xml_node const &node, auto const &attr, double default_value)
+ {
+ return unsigned(1000*node.attribute_value(attr, default_value));
+ }
+}
+
+
+namespace Genode {
+
+ void print(Output &out, Mixer::Time_window const &window)
+ {
+ using namespace Mixer;
+ print(out, window.start/1000, "...", window.end/1000,
+ " (", Clock{window.end}.us_since(Clock{window.start})/1000, ")");
+ }
+}
+
+
+static bool operator < (Play::Seq const &l, Play::Seq const &r)
+{
+ unsigned const LIMIT = Play::Seq::LIMIT;
+
+ auto pos = [] (unsigned v) { return (v > 0) && (v < LIMIT/2); };
+
+ return (r.value() < l.value()) ? pos(r.value() + LIMIT - l.value())
+ : pos(r.value() - l.value());
+}
+
+#endif /* _TYPES_H_ */
diff --git a/repos/os/src/server/record_rom/main.cc b/repos/os/src/server/record_rom/main.cc
new file mode 100644
index 0000000000..4654c7346c
--- /dev/null
+++ b/repos/os/src/server/record_rom/main.cc
@@ -0,0 +1,290 @@
+/*
+ * \brief Expose recorded data as ROM module
+ * \author Norman Feske
+ * \date 2023-01-16
+ */
+
+/*
+ * Copyright (C) 2023 Genode Labs GmbH
+ *
+ * This file is part of the Genode OS framework, which is distributed
+ * under the terms of the GNU Affero General Public License version 3.
+ */
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace Record_rom {
+ using namespace Genode;
+ struct Main;
+}
+
+
+struct Record_rom::Main : Dynamic_rom_session::Xml_producer
+{
+ Env &_env;
+
+ unsigned _period_ms { };
+
+ Heap _heap { _env.ram(), _env.rm() };
+
+ Timer::Connection _timer { _env };
+
+ Attached_rom_dataspace _config { _env, "config" };
+
+ Signal_handler _timer_handler { _env.ep(), *this, &Main::_handle_timer };
+ Signal_handler _wakeup_handler { _env.ep(), *this, &Main::_handle_wakeup };
+ Signal_handler _config_handler { _env.ep(), *this, &Main::_handle_config };
+
+ using Samples_ptr = Record::Connection::Samples_ptr;
+
+ struct Captured_audio
+ {
+ static constexpr unsigned SIZE_LOG2 = 10, SIZE = 1 << SIZE_LOG2, MASK = SIZE - 1;
+
+ float _samples[SIZE] { };
+
+ unsigned _pos = 0;
+ unsigned _count = 0;
+
+ void insert(float value)
+ {
+ _pos = (_pos + 1) & MASK;
+ _samples[_pos] = value;
+ _count = min(SIZE, _count + 1);
+ }
+
+ void insert(Samples_ptr const &samples)
+ {
+ for (unsigned i = 0; i < samples.num_samples; i++)
+ insert(samples.start[i]);
+ }
+
+ float past_value(unsigned past) const
+ {
+ return _samples[(_pos - past) & MASK];
+ }
+
+ unsigned count() const { return _count; }
+ };
+
+ struct Channel : private List_model>::Element
+ {
+ friend class List_model>;
+ friend class List>;
+
+ using Label = String<20>;
+ Label const label;
+
+ static Label _label_from_xml(Xml_node const &node)
+ {
+ return node.attribute_value("label", Label());
+ }
+
+ struct Attr
+ {
+ unsigned sample_rate_hz;
+
+ static Attr from_xml(Xml_node const &node, Attr const defaults)
+ {
+ return Attr {
+ .sample_rate_hz = node.attribute_value("sample_rate_hz", defaults.sample_rate_hz),
+ };
+ }
+ };
+
+ Attr _attr { };
+
+ Record::Connection _record;
+
+ Captured_audio _capture { };
+
+ Channel(Env &env, Xml_node const &node, Signal_context_capability wakeup_sigh)
+ :
+ label(_label_from_xml(node)), _record(env, label)
+ {
+ _record.wakeup_sigh(wakeup_sigh);
+ }
+
+ virtual ~Channel() { };
+
+ void update(Xml_node const &node, Attr const defaults)
+ {
+ _attr = Attr::from_xml(node, defaults);
+ }
+
+ void generate(Xml_generator &xml) const
+ {
+ unsigned const num_values = min(_capture.count(), 1000u);
+ for (unsigned i = 0; i < num_values; i++)
+ xml.append_content(_capture.past_value(num_values - i), "\n");
+ }
+
+ Record::Num_samples num_samples(unsigned period_ms) const
+ {
+ return { unsigned((_attr.sample_rate_hz*period_ms)/1000) };
+ }
+
+ struct Capture_result
+ {
+ Record::Time_window tw;
+ bool depleted;
+ };
+
+ Capture_result capture(Record::Num_samples const num_samples)
+ {
+ Capture_result result { };
+ _record.record(num_samples,
+ [&] (Record::Time_window tw, Samples_ptr const &samples) {
+ result = { .tw = tw, .depleted = false };
+ _capture.insert(samples);
+ },
+ [&] { /* audio data depleted */
+ result = { .tw = { }, .depleted = true };
+ for (unsigned i = 0; i < num_samples.value(); i++)
+ _capture.insert(0.0f);
+ }
+ );
+ return result;
+ }
+
+ void capture_at(Record::Time_window tw,
+ Record::Num_samples num_samples)
+ {
+ _record.record_at(tw, num_samples,
+ [&] (Samples_ptr const &samples) {
+ _capture.insert(samples); });
+ }
+
+ /*
+ * List_model::Element
+ */
+ static bool type_matches(Xml_node const &node)
+ {
+ return node.has_type("record");
+ }
+
+ bool matches(Xml_node const &node) const
+ {
+ return _label_from_xml(node) == label;
+ }
+ };
+
+ List_model> _channels { };
+
+ Registry> _channel_registry { }; /* for non-const for_each */
+
+ void _handle_wakeup()
+ {
+ _timer.trigger_periodic(1000*_period_ms);
+ }
+
+ void _handle_config()
+ {
+ _config.update();
+
+ Xml_node const config = _config.xml();
+
+ _period_ms = config.attribute_value("period_ms", 20u);
+
+ /* channel defaults obtained from top-level config node */
+ Channel::Attr const channel_defaults = Channel::Attr::from_xml(config, {
+ .sample_rate_hz = 44100u, });
+
+ _channels.update_from_xml(config,
+ [&] (Xml_node const &node) -> Registered & {
+ return *new (_heap)
+ Registered(_channel_registry, _env, node,
+ _wakeup_handler); },
+ [&] (Registered &channel) {
+ destroy(_heap, &channel); },
+ [&] (Channel &channel, Xml_node const &node) {
+ channel.update(node, channel_defaults); }
+ );
+
+ _handle_wakeup();
+ }
+
+ void _capture_channels()
+ {
+ /*
+ * The first channel drives the time window that is reused for all
+ * other channels to attain time-synchronized data.
+ */
+ bool first = true;
+ Channel::Capture_result capture { };
+ _channel_registry.for_each([&] (Registered &channel) {
+ if (first)
+ capture = channel.capture(channel.num_samples(_period_ms));
+ else
+ channel.capture_at(capture.tw, channel.num_samples(_period_ms));
+ first = false;
+ });
+
+ if (capture.depleted)
+ _timer.trigger_periodic(0);
+ }
+
+ /**
+ * Dynamic_rom_session::Xml_producer
+ */
+ void produce_xml(Xml_generator &xml) override
+ {
+ _channels.for_each([&] (Channel const &channel) {
+ xml.node("channel", [&] {
+ xml.attribute("label", channel.label);
+ xml.attribute("rate_hz", channel._attr.sample_rate_hz);
+ channel.generate(xml);
+ });
+ });
+ }
+
+ void _handle_timer()
+ {
+ _capture_channels();
+ }
+
+ struct Rom_root : Root_component
+ {
+ Env &_env;
+ Main &_main;
+
+ Dynamic_rom_session *_create_session(const char *) override
+ {
+ using namespace Genode;
+
+ return new (md_alloc())
+ Dynamic_rom_session(_env.ep(), _env.ram(), _env.rm(), _main);
+ }
+
+ Rom_root(Env &env, Allocator &md_alloc, Main &main)
+ :
+ Root_component(&env.ep().rpc_ep(), &md_alloc),
+ _env(env), _main(main)
+ { }
+
+ } _rom_root { _env, _heap, *this };
+
+ Main(Env &env) : Xml_producer("recording"), _env(env)
+ {
+ _timer.sigh(_timer_handler);
+ _config.sigh(_config_handler);
+ _handle_config();
+
+ env.parent().announce(_env.ep().manage(_rom_root));
+ }
+};
+
+
+void Component::construct(Genode::Env &env)
+{
+ static Record_rom::Main main(env);
+}
diff --git a/repos/os/src/server/record_rom/target.mk b/repos/os/src/server/record_rom/target.mk
new file mode 100644
index 0000000000..2854efc891
--- /dev/null
+++ b/repos/os/src/server/record_rom/target.mk
@@ -0,0 +1,3 @@
+TARGET = record_rom
+SRC_CC = main.cc
+LIBS = base
diff --git a/repos/os/src/test/audio_play/main.cc b/repos/os/src/test/audio_play/main.cc
new file mode 100644
index 0000000000..16a0258326
--- /dev/null
+++ b/repos/os/src/test/audio_play/main.cc
@@ -0,0 +1,97 @@
+/*
+ * \brief Play audio sample (stereo, interleaved, 32-bit floating point)
+ * \author Norman Feske
+ * \date 2024-02-14
+ */
+
+/*
+ * Copyright (C) 2024 Genode Labs GmbH
+ *
+ * This file is part of the Genode OS framework, which is distributed
+ * under the terms of the GNU Affero General Public License version 3.
+ */
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace Audio_play {
+
+ using namespace Genode;
+
+ struct Main;
+}
+
+
+struct Audio_play::Main
+{
+ Env &_env;
+
+ Heap _heap { _env.ram(), _env.rm() };
+
+ Attached_rom_dataspace _config { _env, "config" };
+
+ Vfs::Global_file_system_factory _fs_factory { _heap };
+ Vfs::Simple_env _vfs_env { _env, _heap, _config.xml().sub_node("vfs") };
+ Directory _root_dir { _vfs_env };
+
+ Directory::Path const _sample_path =
+ _config.xml().attribute_value("sample_path", Directory::Path());
+
+ File_content const _sample_data {
+ _heap, _root_dir, _sample_path, { _env.pd().avail_ram().value } };
+
+ Play::Connection _left { _env, "left" },
+ _right { _env, "right" };
+
+ Play::Time_window _time_window { };
+
+ unsigned _pos = 0;
+
+ unsigned const _period_ms = 5,
+ _sample_rate_hz = 44100,
+ _frames_per_period = (_period_ms*_sample_rate_hz)/1000;
+
+ Timer::Connection _timer { _env };
+
+ Signal_handler _timer_handler { _env.ep(), *this, &Main::_handle_timer };
+
+ struct Frame { float left, right; } __attribute__((packed));
+
+ static_assert(sizeof(Frame) == 8);
+
+ void _for_each_frame_of_period(auto const &fn) const
+ {
+ _sample_data.bytes([&] (char const * const ptr, size_t const num_bytes) {
+ if (num_bytes >= sizeof(Frame))
+ for (unsigned i = 0; i < _frames_per_period; i++)
+ fn(*(Frame const *)(ptr + ((_pos + i*sizeof(Frame)) % num_bytes))); });
+ }
+
+ void _handle_timer()
+ {
+ _time_window = _left.schedule_and_enqueue(_time_window, { _period_ms*1000 },
+ [&] (auto &submit) {
+ _for_each_frame_of_period([&] (Frame const frame) {
+ submit(frame.left); }); });
+
+ _right.enqueue(_time_window,
+ [&] (auto &submit) {
+ _for_each_frame_of_period([&] (Frame const frame) {
+ submit(frame.right); }); });
+
+ _pos = unsigned(_pos + _frames_per_period*sizeof(Frame));
+ }
+
+ Main(Env &env) : _env(env)
+ {
+ _timer.sigh(_timer_handler);
+ _timer.trigger_periodic(_period_ms*1000);
+ }
+};
+
+
+void Component::construct(Genode::Env &env) { static Audio_play::Main main(env); }
diff --git a/repos/os/src/test/audio_play/target.mk b/repos/os/src/test/audio_play/target.mk
new file mode 100644
index 0000000000..4f41b90d30
--- /dev/null
+++ b/repos/os/src/test/audio_play/target.mk
@@ -0,0 +1,3 @@
+TARGET = test-audio_play
+SRC_CC = main.cc
+LIBS = base vfs