From 70dfba81f2b8d5b161f3ddf004e3133af60bf608 Mon Sep 17 00:00:00 2001 From: Sebastian Reimers Date: Sat, 8 Aug 2026 11:56:30 +0200 Subject: [PATCH] wip: vst3 support and drift correction --- CMakeLists.txt | 19 +- Makefile | 21 +- app/vst3/CMakeLists.txt | 137 ++++++++++++ app/vst3/README.md | 111 ++++++++++ app/vst3/include/sl_vst.h | 180 +++++++++++++++ app/vst3/resource/studiolink.rc | 45 ++++ app/vst3/src/cids.h | 26 +++ app/vst3/src/driver.c | 373 ++++++++++++++++++++++++++++++++ app/vst3/src/engine.c | 260 ++++++++++++++++++++++ app/vst3/src/entry.cpp | 28 +++ app/vst3/src/io.c | 225 +++++++++++++++++++ app/vst3/src/plugin.cpp | 305 ++++++++++++++++++++++++++ app/vst3/src/plugin.h | 97 +++++++++ app/vst3/src/version.h | 44 ++++ cmake/FindSampleRate.cmake | 8 + libsl/CMakeLists.txt | 9 +- libsl/include/studiolink.h | 73 +++++++ libsl/src/audio.c | 110 +++++++++- libsl/src/drift.c | 182 ++++++++++++++++ libsl/src/http/server.c | 2 +- libsl/src/resample.c | 159 ++++++++++++++ test/CMakeLists.txt | 1 + test/src/drift.c | 138 ++++++++++++ test/src/main.c | 1 + test/src/test.h | 1 + versions.mk | 2 + 26 files changed, 2546 insertions(+), 11 deletions(-) create mode 100644 app/vst3/CMakeLists.txt create mode 100644 app/vst3/README.md create mode 100644 app/vst3/include/sl_vst.h create mode 100644 app/vst3/resource/studiolink.rc create mode 100644 app/vst3/src/cids.h create mode 100644 app/vst3/src/driver.c create mode 100644 app/vst3/src/engine.c create mode 100644 app/vst3/src/entry.cpp create mode 100644 app/vst3/src/io.c create mode 100644 app/vst3/src/plugin.cpp create mode 100644 app/vst3/src/plugin.h create mode 100644 app/vst3/src/version.h create mode 100644 cmake/FindSampleRate.cmake create mode 100644 libsl/src/drift.c create mode 100644 libsl/src/resample.c create mode 100644 test/src/drift.c diff --git a/CMakeLists.txt b/CMakeLists.txt index f5e0fb2..480579f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -27,6 +27,11 @@ list(APPEND CMAKE_MODULE_PATH ${CMAKE_CURRENT_LIST_DIR}/cmake) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) set(CMAKE_C_STANDARD 11) +option(SL_BUILD_VST3 "Build the VST3 plug-in" OFF) + +if(SL_BUILD_VST3) + set(CMAKE_POSITION_INDEPENDENT_CODE ON) +endif() if(MSVC) add_compile_options("/W3") @@ -87,22 +92,21 @@ set(STATIC CACHE BOOL "") set(MODULES - alsa auconv auresamp vp8 - coreaudio dtls_srtp g711 ice - jack netroam opus - portaudio - pulse turn CACHE STRING "") +if(NOT SL_BUILD_VST3) + list(APPEND MODULES alsa coreaudio jack pulse portaudio) +endif() + # Baresip dependencies add_subdirectory(external/re EXCLUDE_FROM_ALL) add_subdirectory(external/baresip EXCLUDE_FROM_ALL) @@ -112,6 +116,11 @@ find_package(re CONFIG REQUIRED) add_compile_definitions(SL_VERSION="${PROJECT_VERSION}") + add_subdirectory(libsl) add_subdirectory(test) add_subdirectory(app/cli) + +if(SL_BUILD_VST3) + add_subdirectory(app/vst3) +endif() diff --git a/Makefile b/Makefile index dc7e4a9..0453427 100644 --- a/Makefile +++ b/Makefile @@ -159,7 +159,7 @@ third_party/portaudio: $(HIDE)cd third_party && \ git clone ${PORTAUDIO_MIRROR}/portaudio.git && \ cd portaudio && \ - cmake -B build -DBUILD_SHARED_LIBS=0 \ + cmake -B build -DBUILD_SHARED_LIBS=0 -DPA_USE_SNDIO=OFF \ -DPA_USE_WMME=OFF -DPA_USE_DS=OFF -DPA_USE_WDMKS=OFF && \ cmake --build build -j && \ cp -a build/libportaudio.a ../lib/ && \ @@ -205,6 +205,25 @@ external/baresip: patch -p1 < ../../patches/baresip_portaudio.patch +.PHONY: vst3sdk +vst3sdk: external/vst3sdk + +# Only the submodules the plug-in actually needs. VSTGUI is skipped, the +# plug-in has no editor of its own. +external/vst3sdk: + $(HIDE) [ ! -d external/vst3sdk ] && \ + git -C external clone -b $(VST3SDK_VERSION) --depth=1 \ + $(VST3SDK_MIRROR) vst3sdk && \ + cd external/vst3sdk && \ + git submodule update --init --depth=1 \ + base cmake pluginterfaces public.sdk + +.PHONY: vst3 +vst3: third_party external external/vst3sdk + $(HIDE)[ -d build ] || cmake -B build -GNinja \ + -DCMAKE_BUILD_TYPE=Release -DSL_BUILD_VST3=ON + $(HIDE)cmake --build build -j $(CMAKE_VERBOSE) --target studiolink_vst3 + ############################################################################## # # Tools & Cleanup diff --git a/app/vst3/CMakeLists.txt b/app/vst3/CMakeLists.txt new file mode 100644 index 0000000..acab9dd --- /dev/null +++ b/app/vst3/CMakeLists.txt @@ -0,0 +1,137 @@ +# +# CMakeLists.txt +# +# StudioLink VST3 plug-in +# +# Copyright (C) 2026 Sebastian Reimers +# + +############################################################################## +# +# Project and Versioning +# +project(studiolink_vst3 VERSION 26.01.0 LANGUAGES C CXX) + +set(SL_ROOT ${CMAKE_CURRENT_SOURCE_DIR}/../..) +set(vst3sdk_SOURCE_DIR ${SL_ROOT}/external/vst3sdk) + +if(NOT EXISTS ${vst3sdk_SOURCE_DIR}/CMakeLists.txt) + message(STATUS "VST3 SDK not found in external/vst3sdk" + " - skipping VST3 plug-in (run: make vst3sdk)") + return() +endif() + +############################################################################## +# +# Compile options +# +# The top level CMakeLists.txt adds a set of C only warning flags. They would +# be rejected when compiling the C++ sources of the SDK, so start from a +# clean slate here and re-apply them per language below. +# + +set_directory_properties(PROPERTIES COMPILE_OPTIONS "") + +set(SL_VST_C_FLAGS + -Wall + -Wextra + -Wmissing-declarations + -Wmissing-prototypes + -Wold-style-definition + -Wshadow + -Wstrict-prototypes + -Wuninitialized + -Wvla) + +if(MSVC) + set(SL_VST_C_FLAGS "/W3") +endif() + +############################################################################## +# +# VST3 SDK +# +# Examples, hosting tools and VSTGUI are not needed: StudioLink is operated +# through its web frontend, so the plug-in ships without an editor. +# + +set(SMTG_ENABLE_VST3_PLUGIN_EXAMPLES OFF CACHE BOOL "" FORCE) +set(SMTG_ENABLE_VST3_HOSTING_EXAMPLES OFF CACHE BOOL "" FORCE) +set(SMTG_ENABLE_VSTGUI_SUPPORT OFF CACHE BOOL "" FORCE) +set(SMTG_CREATE_PLUGIN_LINK OFF CACHE BOOL "" FORCE) +set(SMTG_RUN_VST_VALIDATOR OFF CACHE BOOL "" FORCE) + +list(APPEND CMAKE_MODULE_PATH ${vst3sdk_SOURCE_DIR}/cmake/modules) + +add_subdirectory(${vst3sdk_SOURCE_DIR} ${CMAKE_CURRENT_BINARY_DIR}/vst3sdk + EXCLUDE_FROM_ALL) + +smtg_enable_vst3_sdk() + +############################################################################## +# +# Source section +# + +find_package(SampleRate REQUIRED) + +# vstsinglecomponenteffect.cpp is not part of the "sdk" library target and +# has to be compiled into the plug-in, like the SDK samples do. +set(SRCS + ${vst3sdk_SOURCE_DIR}/public.sdk/source/vst/vstsinglecomponenteffect.cpp + src/driver.c + src/engine.c + src/io.c + src/entry.cpp + src/plugin.cpp +) + +set(HEADERS + include/sl_vst.h + src/cids.h + src/plugin.h + src/version.h +) + +############################################################################## +# +# Main target +# + +set(target studiolink_vst3) + +smtg_add_vst3plugin(${target} PACKAGE_NAME "StudioLink" SOURCES_LIST ${SRCS} + ${HEADERS}) + +target_include_directories(${target} + PRIVATE + include + ${SAMPLERATE_INCLUDE_DIR} +) + +target_link_libraries(${target} + PRIVATE + sdk + sl + baresip + re + ${SAMPLERATE_LIBRARIES} +) + +target_compile_definitions(${target} PRIVATE ${RE_DEFINITIONS}) + +target_compile_features(${target} PRIVATE cxx_std_17) + +target_compile_options(${target} + PRIVATE + $<$:${SL_VST_C_FLAGS}> +) + +if(SMTG_MAC) + smtg_target_set_bundle(${target} + BUNDLE_IDENTIFIER "de.studio-link.vst3.studiolink" + COMPANY_NAME "Studio Link" + ) +elseif(SMTG_WIN) + target_sources(${target} PRIVATE resource/studiolink.rc) +endif() diff --git a/app/vst3/README.md b/app/vst3/README.md new file mode 100644 index 0000000..5b6df1a --- /dev/null +++ b/app/vst3/README.md @@ -0,0 +1,111 @@ +# StudioLink VST3 Plug-in + +Runs StudioLink inside a DAW. The DAW replaces the sound card: the plug-in +input is sent to the remote peers, the plug-in output carries the mix of all +remote peers. + +``` + DAW track input ──► local StudioLink track ──► remote peers (SIP/Opus) + DAW track output ◄── aumix of all remote tracks +``` + +The plug-in has no editor. StudioLink is operated through its existing web +frontend, which the "Open Web UI" parameter launches. + +## Build + +```bash +make vst3sdk # fetch external/vst3sdk (once) +make vst3 +``` + +The result is `build/VST3//StudioLink.vst3`. Copy it to your VST3 +folder (`~/.vst3` on Linux, `~/Library/Audio/Plug-Ins/VST3` on macOS, +`%COMMONPROGRAMFILES%\VST3` on Windows). + +The plug-in is off by default in the normal build; `-DSL_BUILD_VST3=ON` +enables it. + +## How it hooks into libsl + +`libsl` picks the audio device driver for the local track from +`sl_conf()->play.mod` / `src.mod`, which defaults to `portaudio`. The plug-in +registers a baresip `ausrc`/`auplay` pair named `vst` and points the config at +it *before* `sl_init()` runs, because the local track chooses its driver +during `sl_tracks_init()`. From there on everything else in libsl is +unchanged. + +| File | Role | +| --- | --- | +| `src/engine.c` | Shared, reference counted StudioLink instance running `re_main()` on its own thread | +| `src/driver.c` | The `vst` baresip driver and the two ring buffers to the audio thread | +| `src/io.c` | Per instance channel mapping, format and samplerate conversion | +| `src/resample.c` | libsamplerate wrapper for non 48 kHz hosts | +| `src/plugin.cpp` | VST3 `SingleComponentEffect` | +| `src/entry.cpp` | VST3 module factory | + +### Threading + +Three threads are involved: + +- **DAW audio thread** — `process()` converts, pushes into `ab_in`, drives the + 20 ms frame exchange with baresip, pulls from `ab_out`. It never allocates + and never blocks: the driver lock is taken with `mtx_trylock()` and simply + skipped when the StudioLink thread is restarting the driver, in which case + `aubuf` zero fills for a few blocks. +- **StudioLink thread** — `re_main()`, same as the CLI app. +- **DAW GUI thread** — parameter edits. These are marshalled onto the + StudioLink thread through an `mqueue`, since libre objects must only be + touched from there. Commands are only sent on actual value changes, so the + audio thread stays free of syscalls in the steady state. + +This is the same arrangement baresip's own `portaudio` module uses, where the +read/write handlers are likewise called from a foreign callback thread into +lock protected `aumix`/`aubuf` objects. + +### Clock drift + +The DAW clock and the aumix thread's OS clock drift apart just like a sound +card and the OS clock do. The correction lives in libsl (`libsl/src/drift.c`, +applied in `driver_write_handler`), because the buffer on that clock boundary +is `ab_mix` inside libsl either way, so the plug-in inherits it for free. + +### Samplerate + +StudioLink is fixed at 48 kHz stereo S16LE with 20 ms frames. libre's +`auresamp` only handles integer ratios, so 44.1 kHz hosts would be rejected — +`libsamplerate` is used instead (`libsl/src/resample.c`, shared with the +drift correction), with +`SRC_SINC_FASTEST` and preallocated state so `src_process()` does not allocate +on the audio thread. At 48 kHz the resampler degenerates to a copy. + +## Known limitations + +- **One active instance per host process.** libsl currently supports a single + local track (`tracks.c`: *TODO: refactor allow multiple local tracks*) and + keeps one global libre loop, HTTP server and track list. The first instance + claims the audio bridge; any further instance loads but stays silent. Lifting + this needs multi local track support in libsl first. +- **Stereo in / stereo out only.** Mono input is duplicated to both sides. +- **32 bit float processing only.** Double precision would only add + conversions on the way to 16 bit. +- **Latency is not reported.** The delay is a network path, so the host cannot + compensate it; `getLatencySamples()` returns 0 and the plug-in declares an + infinite tail so hosts keep calling `process()` while remote peers are + talking. +- `sl_open_webui()` launches Chromium with fixed flags on non-Windows hosts. A + `sl_http_port()` getter in libsl would let the plug-in open the URL with the + platform default browser instead. +- `sl_baresip_init()` calls `setbuf(stdout, NULL)` and `sys_coredump_set(true)` + process wide, which is fine for a standalone app but intrusive inside a host + process. + +## Licensing + +This repository is MIT. The Steinberg VST3 SDK is **not** — it is dual +licensed under GPLv3 or Steinberg's proprietary licence, and it is fetched +into `external/` rather than vendored. Distributing binaries of this plug-in +means accepting one of those two, and a GPLv3 build would relicense the +combined work. Steinberg also requires registering as a VST3 licensee before +shipping. This is a maintainer decision, not something the build system can +settle. diff --git a/app/vst3/include/sl_vst.h b/app/vst3/include/sl_vst.h new file mode 100644 index 0000000..2f16169 --- /dev/null +++ b/app/vst3/include/sl_vst.h @@ -0,0 +1,180 @@ +/* _____ __ ___ __ _ __ + * / ___// /___ ______/ (_)___ / / (_)___ / /__ + * \__ \/ __/ / / / __ / / __ \ / / / / __ \/ //_/ + * ___/ / /_/ /_/ / /_/ / / /_/ / / /___/ / / / / ,< + * /____/\__/\__,_/\__,_/_/\____(_)_____/_/_/ /_/_/|_| + * + * VST3 plugin bridge - C interface used by the C++ plugin sources. + * + * Copyright Sebastian Reimers + * License: MIT (see LICENSE File) + */ + +#ifndef SL_VST_H__ +#define SL_VST_H__ + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * StudioLink runs its audio engine at a fixed rate/layout. The DAW may use + * anything, so the bridge resamples and converts on the audio thread. + */ +enum { + SL_VST_SRATE = 48000, /**< StudioLink internal samplerate */ + SL_VST_CH = 2, /**< StudioLink internal channels */ + SL_VST_PTIME = 20, /**< StudioLink internal frame in ms */ +}; + + +/****************************************************************************** + * engine.c - process wide StudioLink instance + * + * libsl uses global state (one libre main loop, one HTTP server, one track + * list), so all plugin instances in a host process share a single engine. + * The engine is reference counted and runs re_main() on its own thread. + */ + +/** + * Reference the shared StudioLink engine, starting it if needed. + * + * Blocks until the engine is up (or failed). Must not be called from the + * realtime audio thread. + * + * @return 0 if success, otherwise errorcode + */ +int sl_vst_engine_ref(void); + +/** + * Release a reference. Stops the engine when the last one is dropped. + */ +void sl_vst_engine_deref(void); + +/** + * @return true if the engine is initialized and running + */ +bool sl_vst_engine_running(void); + +/** Control commands, marshalled to the StudioLink thread */ +enum sl_vst_cmd { + SL_VST_CMD_MUTE, /**< value: 1 mute, 0 unmute local track */ + SL_VST_CMD_RECORD, /**< value: 1 start, 0 stop recording */ + SL_VST_CMD_WEBUI, /**< value: ignored, opens the web frontend */ +}; + +/** + * Send a control command to the StudioLink thread. + * + * Thread safe and non blocking (a single write() on the mqueue pipe), so it + * may be called from the audio thread - but only on actual value changes. + * + * @param cmd Command + * @param value Command value + * + * @return 0 if success, otherwise errorcode + */ +int sl_vst_engine_cmd(enum sl_vst_cmd cmd, int32_t value); + + +/****************************************************************************** + * driver.c - baresip ausrc/auplay driver named "vst" + * + * Registered before sl_init() so that the local StudioLink track picks the + * DAW as its audio device instead of portaudio. + */ + +int sl_vst_driver_register(void); +void sl_vst_driver_unregister(void); + +/** + * Claim the audio bridge for one plugin instance. + * + * Only one instance can carry audio, because libsl currently supports a + * single local track. Every other instance stays in bypass. + * + * @return true if this caller is now the owner + */ +bool sl_vst_driver_claim(void); +void sl_vst_driver_release(void); + +/** + * Push one block of DAW input into StudioLink (48kHz S16LE stereo). + * + * @param sampv Interleaved samples + * @param sampc Number of samples (frames * SL_VST_CH) + */ +void sl_vst_driver_write(const int16_t *sampv, size_t sampc); + +/** + * Pull one block of the StudioLink mix (48kHz S16LE stereo). Missing samples + * are zero filled. + * + * @param sampv Interleaved samples + * @param sampc Number of samples (frames * SL_VST_CH) + */ +void sl_vst_driver_read(int16_t *sampv, size_t sampc); + +/** + * Run the 20ms frame exchange with baresip. Called from the audio thread + * after write() and before read(). + * + * @param need_sampc Number of samples the caller is about to read + */ +void sl_vst_driver_pump(size_t need_sampc); + +void sl_vst_driver_flush(void); + + +/****************************************************************************** + * io.c - per plugin instance audio bridge + */ + +struct sl_vst_io; + +/** + * Allocate the audio bridge for one plugin instance. + * + * Not realtime safe, call from setupProcessing()/setActive(). + * + * @param iop Allocated bridge + * @param srate DAW samplerate + * @param maxframes DAW max. block size + * + * @return 0 if success, otherwise errorcode + */ +int sl_vst_io_alloc(struct sl_vst_io **iop, uint32_t srate, size_t maxframes); + +void sl_vst_io_free(struct sl_vst_io *io); + +/** + * @return true if this instance owns the audio bridge + */ +bool sl_vst_io_owner(const struct sl_vst_io *io); + +/** + * Process one DAW block. Realtime safe. + * + * Non owner instances and blocks larger than maxframes are passed through + * unchanged. + * + * @param io Audio bridge + * @param in DAW input channel buffers + * @param out DAW output channel buffers + * @param ch_in Number of input channels + * @param ch_out Number of output channels + * @param nframes Number of frames + */ +void sl_vst_io_process(struct sl_vst_io *io, const float *const *in, + float *const *out, unsigned ch_in, unsigned ch_out, + size_t nframes); + +#ifdef __cplusplus +} +#endif + +#endif /* SL_VST_H__ */ diff --git a/app/vst3/resource/studiolink.rc b/app/vst3/resource/studiolink.rc new file mode 100644 index 0000000..7ddf358 --- /dev/null +++ b/app/vst3/resource/studiolink.rc @@ -0,0 +1,45 @@ +#include +#include "../src/version.h" + +#define APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// Version +///////////////////////////////////////////////////////////////////////////// +VS_VERSION_INFO VERSIONINFO + FILEVERSION MAJOR_VERSION_INT,SUB_VERSION_INT,RELEASE_NUMBER_INT,BUILD_NUMBER_INT + PRODUCTVERSION MAJOR_VERSION_INT,SUB_VERSION_INT,RELEASE_NUMBER_INT,BUILD_NUMBER_INT + FILEFLAGSMASK 0x3fL +#ifdef _DEBUG + FILEFLAGS 0x1L +#else + FILEFLAGS 0x0L +#endif + FILEOS 0x40004L + FILETYPE 0x1L + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040004e4" + BEGIN + VALUE "FileVersion", FULL_VERSION_STR + VALUE "ProductVersion", FULL_VERSION_STR + VALUE "OriginalFilename", stringOriginalFilename + VALUE "FileDescription", stringFileDescription + VALUE "InternalName", stringFileDescription + VALUE "ProductName", stringFileDescription + VALUE "CompanyName", stringCompanyName + VALUE "LegalCopyright", stringLegalCopyright + VALUE "LegalTrademarks", stringLegalTrademarks + //VALUE "PrivateBuild", " \0" + //VALUE "SpecialBuild", " \0" + //VALUE "Comments", " \0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x400, 1252 + END +END + diff --git a/app/vst3/src/cids.h b/app/vst3/src/cids.h new file mode 100644 index 0000000..c65478c --- /dev/null +++ b/app/vst3/src/cids.h @@ -0,0 +1,26 @@ +//------------------------------------------------------------------------ +// StudioLink VST3 - class and parameter identifiers +// +// Copyright Sebastian Reimers +// License: MIT (see LICENSE File) +//------------------------------------------------------------------------ + +#pragma once + +#include "pluginterfaces/base/funknown.h" +#include "pluginterfaces/vst/vsttypes.h" + +namespace StudioLink { + +// Single component effect: processor and controller share one class id. +// Never change this, hosts identify the plug-in by it. +static const Steinberg::FUID kStudioLinkProcessorUID ( + 0x195982fc, 0xb4a148e8, 0x91a5bf40, 0x3eba43a1); + +enum ParamIds : Steinberg::Vst::ParamID { + kParamMute = 100, + kParamRecord = 101, + kParamWebUi = 102, +}; + +} // namespace StudioLink diff --git a/app/vst3/src/driver.c b/app/vst3/src/driver.c new file mode 100644 index 0000000..7bcd253 --- /dev/null +++ b/app/vst3/src/driver.c @@ -0,0 +1,373 @@ +/** + * @file driver.c baresip audio driver "vst" + * + * Replaces the portaudio hardware driver of the local StudioLink track, so + * that the DAW becomes the audio device. The DAW audio thread pushes its + * input into ab_in, pulls the StudioLink mix out of ab_out and drives the + * 20ms frame exchange with baresip in between. + * + * Copyright Sebastian Reimers + * License: MIT (see LICENSE File) + */ + +#include +#include +#include + +/* One 20ms frame: 48000 * 2 * 20 / 1000 = 1920 samples */ +enum { + FRAME_SAMPC = SL_VST_SRATE * SL_VST_CH * SL_VST_PTIME / 1000, + FRAME_SZ = FRAME_SAMPC * sizeof(int16_t), + PREBUF_SZ = FRAME_SZ * 2, /**< target fill of ab_out */ + MAX_SZ = FRAME_SZ * 16, /**< ~320ms hard limit */ + TS_STEP = SL_VST_PTIME * 1000, /**< AUDIO_TIMEBASE units */ +}; + +struct ausrc_st { + struct ausrc_prm prm; + ausrc_read_h *rh; + void *arg; +}; + +struct auplay_st { + struct auplay_prm prm; + auplay_write_h *wh; + void *arg; +}; + +static struct { + struct ausrc *ausrc; + struct auplay *auplay; + + /* owned by the StudioLink thread, read by the audio thread */ + struct ausrc_st *src_st; + struct auplay_st *play_st; + mtx_t mtx; /**< guards src_st/play_st against driver restarts */ + bool mtx_ready; + + struct aubuf *ab_in; /**< DAW -> StudioLink */ + struct aubuf *ab_out; /**< StudioLink -> DAW */ + + uint64_t ts_in; + uint64_t ts_out; + + RE_ATOMIC bool claimed; +} d; + + +static void src_destructor(void *arg) +{ + struct ausrc_st *st = arg; + + mtx_lock(&d.mtx); + if (d.src_st == st) + d.src_st = NULL; + mtx_unlock(&d.mtx); +} + + +static int src_alloc(struct ausrc_st **stp, const struct ausrc *as, + struct ausrc_prm *prm, const char *device, + ausrc_read_h *rh, ausrc_error_h *errh, void *arg) +{ + struct ausrc_st *st; + (void)device; + (void)errh; + + if (!stp || !as || !prm) + return EINVAL; + + if (prm->srate != SL_VST_SRATE || prm->ch != SL_VST_CH || + prm->fmt != AUFMT_S16LE) { + warning("sl_vst: ausrc unsupported format " + "(%u Hz, %u ch, fmt %d)\n", + prm->srate, prm->ch, prm->fmt); + return ENOTSUP; + } + + st = mem_zalloc(sizeof(*st), src_destructor); + if (!st) + return ENOMEM; + + st->prm = *prm; + st->rh = rh; + st->arg = arg; + + mtx_lock(&d.mtx); + d.src_st = st; + d.ts_in = 0; + mtx_unlock(&d.mtx); + + aubuf_flush(d.ab_in); + + info("sl_vst: ausrc started\n"); + + *stp = st; + + return 0; +} + + +static void play_destructor(void *arg) +{ + struct auplay_st *st = arg; + + mtx_lock(&d.mtx); + if (d.play_st == st) + d.play_st = NULL; + mtx_unlock(&d.mtx); +} + + +static int play_alloc(struct auplay_st **stp, const struct auplay *ap, + struct auplay_prm *prm, const char *device, + auplay_write_h *wh, void *arg) +{ + struct auplay_st *st; + (void)device; + + if (!stp || !ap || !prm) + return EINVAL; + + if (prm->srate != SL_VST_SRATE || prm->ch != SL_VST_CH || + prm->fmt != AUFMT_S16LE) { + warning("sl_vst: auplay unsupported format " + "(%u Hz, %u ch, fmt %d)\n", + prm->srate, prm->ch, prm->fmt); + return ENOTSUP; + } + + st = mem_zalloc(sizeof(*st), play_destructor); + if (!st) + return ENOMEM; + + st->prm = *prm; + st->wh = wh; + st->arg = arg; + + mtx_lock(&d.mtx); + d.play_st = st; + d.ts_out = 0; + mtx_unlock(&d.mtx); + + aubuf_flush(d.ab_out); + + info("sl_vst: auplay started\n"); + + *stp = st; + + return 0; +} + + +static int mediadev_register(void) +{ + struct mediadev *dev; + int err; + + err = mediadev_add(&d.ausrc->dev_list, "DAW"); + if (err) + return err; + + dev = mediadev_find(&d.ausrc->dev_list, "DAW"); + if (!dev) + return ENODATA; + + dev->device_index = 0; + dev->src.channels = SL_VST_CH; + dev->src.is_default = true; + + err = mediadev_add(&d.auplay->dev_list, "DAW"); + if (err) + return err; + + dev = mediadev_find(&d.auplay->dev_list, "DAW"); + if (!dev) + return ENODATA; + + dev->device_index = 0; + dev->play.channels = SL_VST_CH; + dev->play.is_default = true; + + return 0; +} + + +int sl_vst_driver_register(void) +{ + struct sl_config *conf = sl_conf(); + int err; + + if (!conf) + return EINVAL; + + if (d.ausrc || d.auplay) + return EALREADY; + + if (mtx_init(&d.mtx, mtx_plain) != thrd_success) + return ENOMEM; + + d.mtx_ready = true; + + err = aubuf_alloc(&d.ab_in, 0, MAX_SZ); + err |= aubuf_alloc(&d.ab_out, FRAME_SZ, MAX_SZ); + if (err) + goto out; + + /* drop stale audio instead of drifting when the DAW stalls */ + aubuf_set_live(d.ab_in, true); + aubuf_set_live(d.ab_out, true); + + err = ausrc_register(&d.ausrc, baresip_ausrcl(), "vst", src_alloc); + err |= auplay_register(&d.auplay, baresip_auplayl(), "vst", + play_alloc); + if (err) + goto out; + + err = mediadev_register(); + if (err) + goto out; + + /* the local track now opens "vst" instead of "portaudio" */ + str_ncpy(conf->play.mod, "vst", sizeof(conf->play.mod)); + str_ncpy(conf->src.mod, "vst", sizeof(conf->src.mod)); + + info("sl_vst: audio driver registered\n"); + +out: + if (err) + sl_vst_driver_unregister(); + + return err; +} + + +void sl_vst_driver_unregister(void) +{ + d.ausrc = mem_deref(d.ausrc); + d.auplay = mem_deref(d.auplay); + d.ab_in = mem_deref(d.ab_in); + d.ab_out = mem_deref(d.ab_out); + + if (d.mtx_ready) { + mtx_destroy(&d.mtx); + d.mtx_ready = false; + } + + re_atomic_rlx_set(&d.claimed, false); +} + + +bool sl_vst_driver_claim(void) +{ + bool expected = false; + + return re_atomic_compare_exchange_strong(&d.claimed, &expected, true, + re_memory_order_acquire, + re_memory_order_relaxed); +} + + +void sl_vst_driver_release(void) +{ + re_atomic_rlx_set(&d.claimed, false); + + sl_vst_driver_flush(); +} + + +void sl_vst_driver_flush(void) +{ + if (d.ab_in) + aubuf_flush(d.ab_in); + if (d.ab_out) + aubuf_flush(d.ab_out); +} + + +void sl_vst_driver_write(const int16_t *sampv, size_t sampc) +{ + struct auframe af; + + if (!d.ab_in || !sampv || !sampc) + return; + + auframe_init(&af, AUFMT_S16LE, (void *)sampv, sampc, SL_VST_SRATE, + SL_VST_CH); + + (void)aubuf_write_auframe(d.ab_in, &af); +} + + +void sl_vst_driver_read(int16_t *sampv, size_t sampc) +{ + struct auframe af; + + if (!sampv || !sampc) + return; + + if (!d.ab_out) { + memset(sampv, 0, sampc * sizeof(int16_t)); + return; + } + + auframe_init(&af, AUFMT_S16LE, sampv, sampc, SL_VST_SRATE, + SL_VST_CH); + + /* zero fills on underrun */ + aubuf_read_auframe(d.ab_out, &af); +} + + +void sl_vst_driver_pump(size_t need_sampc) +{ + int16_t sampv[FRAME_SAMPC]; + struct auframe af; + size_t target; + + if (!d.mtx_ready || !d.ab_in || !d.ab_out) + return; + + /* + * Never block the DAW thread. A contended lock only happens while + * the StudioLink thread (re)starts the driver, aubuf zero fills for + * the few blocks this takes. + */ + if (mtx_trylock(&d.mtx) != thrd_success) + return; + + /* DAW input -> baresip, one 20ms frame at a time */ + while (d.src_st && aubuf_cur_size(d.ab_in) >= FRAME_SZ) { + + auframe_init(&af, AUFMT_S16LE, sampv, FRAME_SAMPC, + SL_VST_SRATE, SL_VST_CH); + aubuf_read_auframe(d.ab_in, &af); + + af.timestamp = d.ts_in; + d.ts_in += TS_STEP; + + d.src_st->rh(&af, d.src_st->arg); + } + + /* baresip -> DAW output, keep the block plus a prebuffer ready */ + target = need_sampc * sizeof(int16_t) + PREBUF_SZ; + if (target > MAX_SZ) + target = MAX_SZ; + + while (d.play_st && aubuf_cur_size(d.ab_out) < target) { + + memset(sampv, 0, sizeof(sampv)); + auframe_init(&af, AUFMT_S16LE, sampv, FRAME_SAMPC, + SL_VST_SRATE, SL_VST_CH); + + af.timestamp = d.ts_out; + d.ts_out += TS_STEP; + + d.play_st->wh(&af, d.play_st->arg); + + if (aubuf_write_auframe(d.ab_out, &af)) + break; + } + + mtx_unlock(&d.mtx); +} diff --git a/app/vst3/src/engine.c b/app/vst3/src/engine.c new file mode 100644 index 0000000..0f899e7 --- /dev/null +++ b/app/vst3/src/engine.c @@ -0,0 +1,260 @@ +/** + * @file engine.c Shared StudioLink engine + * + * libsl keeps global state - one libre main loop, one HTTP server, one + * track list - so every plugin instance in a host process shares a single + * engine. It is reference counted and runs re_main() on its own thread. + * + * Control commands from the DAW (GUI or audio thread) are marshalled onto + * the StudioLink thread through an mqueue, because libre objects must only + * be touched from there. + * + * Copyright Sebastian Reimers + * License: MIT (see LICENSE File) + */ + +#include +#include + +enum { CMD_QUIT = 1000 }; + +static once_flag engine_once = ONCE_FLAG_INIT; + +static struct { + mtx_t mtx; + cnd_t cnd; + thrd_t thrd; + struct mqueue *mq; + int refs; + bool running; /**< thread created */ + bool ready; /**< startup handshake done */ + int err; /**< startup error */ +} e; + + +static void engine_init(void) +{ + mtx_init(&e.mtx, mtx_plain); + cnd_init(&e.cnd); +} + + +static void cmd_handler(int id, void *data, void *arg) +{ + intptr_t value = (intptr_t)data; + struct sl_track *track = sl_track_by_id(1); + (void)arg; + + switch (id) { + + case SL_VST_CMD_MUTE: + if (!track || track->muted == (bool)value) + break; + + track->muted = (bool)value; + sl_audio_mute(sl_track_audio(track), track->muted); + sl_track_ws_send(); + break; + + case SL_VST_CMD_RECORD: + if ((sl_record_msecs() > 0) == (bool)value) + break; + + sl_record_toggle(); + break; + + case SL_VST_CMD_WEBUI: + (void)sl_open_webui(); + break; + + case CMD_QUIT: + re_cancel(); + break; + + default: + warning("sl_vst: unknown command %d\n", id); + break; + } +} + + +/** + * Open the "vst" driver on the local track. + * + * libsl only starts the hardware driver once a device has been selected, + * which normally happens from the web frontend. The plugin has exactly one + * device (index 0, "DAW"), so select it right away. + */ +static int audio_start(void) +{ + struct sl_track *track; + struct slaudio *audio; + + track = sl_track_by_id(1); + if (!track) + return ENOENT; + + audio = sl_track_audio(track); + if (!audio) + return ENOENT; + + return sl_audio_set_device(audio, 0, 0); +} + + +static void handshake(int err) +{ + mtx_lock(&e.mtx); + e.err = err; + e.ready = true; + cnd_signal(&e.cnd); + mtx_unlock(&e.mtx); +} + + +static int engine_thread(void *arg) +{ + int err; + (void)arg; + + err = sl_baresip_init(NULL); + if (err) { + warning("sl_vst: baresip init failed (%m)\n", err); + goto fail; + } + + /* must happen before sl_init(), the local track picks its driver + during sl_tracks_init() */ + err = sl_vst_driver_register(); + if (err) { + warning("sl_vst: driver register failed (%m)\n", err); + goto fail; + } + + err = sl_init(); + if (err) { + warning("sl_vst: init failed (%m)\n", err); + goto fail; + } + + err = mqueue_alloc(&e.mq, cmd_handler, NULL); + if (err) + goto fail; + + err = audio_start(); + if (err) { + warning("sl_vst: audio start failed (%m)\n", err); + goto fail; + } + + handshake(0); + + (void)sl_main(); + + e.mq = mem_deref(e.mq); + sl_vst_driver_unregister(); + sl_close(); + + return 0; + +fail: + e.mq = mem_deref(e.mq); + sl_vst_driver_unregister(); + sl_close(); + handshake(err); + + return err; +} + + +int sl_vst_engine_ref(void) +{ + int err = 0; + + call_once(&engine_once, engine_init); + + mtx_lock(&e.mtx); + + if (e.refs > 0) { + ++e.refs; + err = 0; + goto out; + } + + e.ready = false; + e.err = 0; + + if (thrd_create(&e.thrd, engine_thread, NULL) != thrd_success) { + err = ENOMEM; + goto out; + } + + e.running = true; + + while (!e.ready) + cnd_wait(&e.cnd, &e.mtx); + + err = e.err; + + if (err) { + mtx_unlock(&e.mtx); + thrd_join(e.thrd, NULL); + mtx_lock(&e.mtx); + e.running = false; + goto out; + } + + e.refs = 1; + +out: + mtx_unlock(&e.mtx); + + return err; +} + + +void sl_vst_engine_deref(void) +{ + bool stop = false; + + mtx_lock(&e.mtx); + + if (e.refs > 0 && --e.refs == 0) + stop = e.running; + + mtx_unlock(&e.mtx); + + if (!stop) + return; + + if (e.mq) + (void)mqueue_push(e.mq, CMD_QUIT, NULL); + + thrd_join(e.thrd, NULL); + + mtx_lock(&e.mtx); + e.running = false; + e.ready = false; + mtx_unlock(&e.mtx); +} + + +bool sl_vst_engine_running(void) +{ + bool running; + + mtx_lock(&e.mtx); + running = e.running && e.ready && !e.err; + mtx_unlock(&e.mtx); + + return running; +} + + +int sl_vst_engine_cmd(enum sl_vst_cmd cmd, int32_t value) +{ + if (!e.mq) + return ENOENT; + + return mqueue_push(e.mq, (int)cmd, (void *)(intptr_t)value); +} diff --git a/app/vst3/src/entry.cpp b/app/vst3/src/entry.cpp new file mode 100644 index 0000000..77c3b03 --- /dev/null +++ b/app/vst3/src/entry.cpp @@ -0,0 +1,28 @@ +//------------------------------------------------------------------------ +// StudioLink VST3 - module factory +// +// Copyright Sebastian Reimers +// License: MIT (see LICENSE File) +//------------------------------------------------------------------------ + +#include "plugin.h" +#include "cids.h" +#include "version.h" + +#include "public.sdk/source/main/pluginfactory.h" + +#define stringSubCategory Steinberg::Vst::PlugType::kFxNetwork + +BEGIN_FACTORY_DEF (stringCompanyName, stringCompanyWeb, stringCompanyEmail) + +DEF_CLASS2 (INLINE_UID_FROM_FUID (StudioLink::kStudioLinkProcessorUID), + PClassInfo::kManyInstances, + kVstAudioEffectClass, + stringPluginName, + 0, // single component effects cannot be distributed + stringSubCategory, + FULL_VERSION_STR, + kVstVersionString, + StudioLink::Processor::createInstance) + +END_FACTORY diff --git a/app/vst3/src/io.c b/app/vst3/src/io.c new file mode 100644 index 0000000..2c7d95a --- /dev/null +++ b/app/vst3/src/io.c @@ -0,0 +1,225 @@ +/** + * @file io.c Per plugin instance audio bridge + * + * Converts between the DAW world (deinterleaved float, host samplerate, + * host block size) and the StudioLink world (interleaved S16LE, 48kHz, + * stereo, 20ms frames). Everything here runs on the realtime audio thread + * and must not allocate, lock or log. + * + * Copyright Sebastian Reimers + * License: MIT (see LICENSE File) + */ + +#include +#include +#include + +enum { SLACK = 8 }; /**< frames of headroom for rounding and filter delay */ + +struct sl_vst_io { + uint32_t srate; /**< DAW samplerate */ + size_t maxframes; /**< DAW max. block size */ + size_t slmax; /**< Max. 48kHz frames / block */ + bool owner; /**< Carries audio? */ + + struct sl_rsmp *rs_in; /**< DAW rate -> 48kHz */ + struct sl_rsmp *rs_out; /**< 48kHz -> DAW rate */ + + float *fl_daw; /**< Interleaved float at DAW rate */ + float *fl_sl; /**< Interleaved float at 48kHz */ + int16_t *s16_sl; /**< Interleaved S16 at 48kHz */ +}; + + +static void io_destructor(void *arg) +{ + struct sl_vst_io *io = arg; + + if (io->owner) + sl_vst_driver_release(); + + mem_deref(io->rs_in); + mem_deref(io->rs_out); + mem_deref(io->fl_daw); + mem_deref(io->fl_sl); + mem_deref(io->s16_sl); +} + + +int sl_vst_io_alloc(struct sl_vst_io **iop, uint32_t srate, size_t maxframes) +{ + struct sl_vst_io *io; + int err = 0; + + if (!iop || !srate || !maxframes) + return EINVAL; + + io = mem_zalloc(sizeof(*io), io_destructor); + if (!io) + return ENOMEM; + + io->srate = srate; + io->maxframes = maxframes; + io->slmax = (size_t)((double)maxframes * (double)SL_VST_SRATE / + (double)srate) + + SLACK; + + io->fl_daw = mem_zalloc(maxframes * SL_VST_CH * sizeof(float), NULL); + io->fl_sl = mem_zalloc(io->slmax * SL_VST_CH * sizeof(float), NULL); + io->s16_sl = mem_zalloc(io->slmax * SL_VST_CH * sizeof(int16_t), + NULL); + if (!io->fl_daw || !io->fl_sl || !io->s16_sl) { + err = ENOMEM; + goto out; + } + + err = sl_rsmp_alloc(&io->rs_in, SL_VST_CH, srate, SL_VST_SRATE, + maxframes + SLACK); + if (err) + goto out; + + err = sl_rsmp_alloc(&io->rs_out, SL_VST_CH, SL_VST_SRATE, srate, + io->slmax + SLACK); + if (err) + goto out; + + io->owner = sl_vst_driver_claim(); + + if (io->owner) + info("sl_vst: audio bridge active (%u Hz, %zu frames)\n", + srate, maxframes); + else + info("sl_vst: further instance, staying in bypass\n"); + +out: + if (err) + mem_deref(io); + else + *iop = io; + + return err; +} + + +void sl_vst_io_free(struct sl_vst_io *io) +{ + mem_deref(io); +} + + +bool sl_vst_io_owner(const struct sl_vst_io *io) +{ + return io ? io->owner : false; +} + + +static void silence(float *const *out, unsigned ch_out, size_t nframes) +{ + unsigned ch; + + for (ch = 0; ch < ch_out; ch++) { + if (out[ch]) + memset(out[ch], 0, nframes * sizeof(float)); + } +} + + +/* deinterleaved DAW channels -> interleaved stereo */ +static void interleave(float *dst, const float *const *in, unsigned ch_in, + size_t nframes) +{ + const float *l, *r; + size_t i; + + l = (ch_in > 0) ? in[0] : NULL; + r = (ch_in > 1) ? in[1] : l; /* mono in feeds both sides */ + + for (i = 0; i < nframes; i++) { + dst[i * 2] = l ? l[i] : 0.0f; + dst[i * 2 + 1] = r ? r[i] : 0.0f; + } +} + + +/* interleaved stereo -> deinterleaved DAW channels */ +static void deinterleave(float *const *out, unsigned ch_out, const float *src, + size_t nframes) +{ + unsigned ch; + size_t i; + + for (ch = 0; ch < ch_out; ch++) { + + if (!out[ch]) + continue; + + /* extra channels beyond stereo stay silent */ + if (ch >= SL_VST_CH) { + memset(out[ch], 0, nframes * sizeof(float)); + continue; + } + + for (i = 0; i < nframes; i++) + out[ch][i] = src[i * SL_VST_CH + ch]; + } +} + + +void sl_vst_io_process(struct sl_vst_io *io, const float *const *in, + float *const *out, unsigned ch_in, unsigned ch_out, + size_t nframes) +{ + size_t n, need, staged; + + if (!out || !ch_out || !nframes) + return; + + /* only one instance carries audio, and only up to the block size + we were set up for */ + if (!io || !io->owner || nframes > io->maxframes) { + silence(out, ch_out, nframes); + return; + } + + /* ---- DAW input -> StudioLink ---- */ + if (in && ch_in) { + interleave(io->fl_daw, in, ch_in, nframes); + + n = sl_rsmp_process(io->rs_in, io->fl_daw, nframes, io->fl_sl, + io->slmax); + if (n) { + auconv_to_s16(io->s16_sl, AUFMT_FLOAT, io->fl_sl, + n * SL_VST_CH); + sl_vst_driver_write(io->s16_sl, n * SL_VST_CH); + } + } + + /* how many 48kHz frames the output resampler still needs */ + need = (size_t)((double)nframes * (double)SL_VST_SRATE / + (double)io->srate) + + 2; + if (need > io->slmax) + need = io->slmax; + + staged = sl_rsmp_staged(io->rs_out); + need = (need > staged) ? need - staged : 0; + + sl_vst_driver_pump(need * SL_VST_CH); + + /* ---- StudioLink -> DAW output ---- */ + if (need) { + sl_vst_driver_read(io->s16_sl, need * SL_VST_CH); + auconv_to_float(io->fl_sl, AUFMT_S16LE, io->s16_sl, + need * SL_VST_CH); + } + + n = sl_rsmp_process(io->rs_out, need ? io->fl_sl : NULL, need, + io->fl_daw, nframes); + + /* startup transient of the sinc resampler */ + if (n < nframes) + memset(io->fl_daw + n * SL_VST_CH, 0, + (nframes - n) * SL_VST_CH * sizeof(float)); + + deinterleave(out, ch_out, io->fl_daw, nframes); +} diff --git a/app/vst3/src/plugin.cpp b/app/vst3/src/plugin.cpp new file mode 100644 index 0000000..c3df76a --- /dev/null +++ b/app/vst3/src/plugin.cpp @@ -0,0 +1,305 @@ +//------------------------------------------------------------------------ +// StudioLink VST3 - processor and controller implementation +// +// Copyright Sebastian Reimers +// License: MIT (see LICENSE File) +//------------------------------------------------------------------------ + +#include "plugin.h" +#include "cids.h" + +#include "base/source/fstreamer.h" +#include "pluginterfaces/base/ibstream.h" +#include "pluginterfaces/vst/ivstparameterchanges.h" + +#include + +// Pure C bridge to libsl. Deliberately does not pull in , which is +// not C++ clean (_Atomic). +#include "sl_vst.h" + +using namespace Steinberg; +using namespace Steinberg::Vst; + +namespace StudioLink { + +enum { kStateVersion = 1 }; + +//------------------------------------------------------------------------ +Processor::~Processor () +{ + if (mIo) + sl_vst_io_free (mIo); + + if (mEngineRef) + sl_vst_engine_deref (); +} + +//------------------------------------------------------------------------ +tresult PLUGIN_API Processor::initialize (FUnknown* context) +{ + tresult result = SingleComponentEffect::initialize (context); + if (result != kResultOk) + return result; + + addAudioInput (STR16 ("Stereo In"), SpeakerArr::kStereo); + addAudioOutput (STR16 ("Stereo Out"), SpeakerArr::kStereo); + + parameters.addParameter (STR16 ("Mute"), nullptr, 1, 0, + ParameterInfo::kCanAutomate, kParamMute); + parameters.addParameter (STR16 ("Record"), nullptr, 1, 0, + ParameterInfo::kCanAutomate, kParamRecord); + parameters.addParameter (STR16 ("Open Web UI"), nullptr, 1, 0, + ParameterInfo::kNoFlags, kParamWebUi); + + int err = sl_vst_engine_ref (); + if (err != 0) { + std::fprintf (stderr, + "StudioLink VST3: engine start failed (%d)\n", + err); + return kResultFalse; + } + + mEngineRef = true; + + return kResultOk; +} + +//------------------------------------------------------------------------ +tresult PLUGIN_API Processor::terminate () +{ + if (mIo) { + sl_vst_io_free (mIo); + mIo = nullptr; + } + + if (mEngineRef) { + sl_vst_engine_deref (); + mEngineRef = false; + } + + return SingleComponentEffect::terminate (); +} + +//------------------------------------------------------------------------ +tresult PLUGIN_API Processor::setActive (TBool state) +{ + if (!state) { + if (mIo) { + sl_vst_io_free (mIo); + mIo = nullptr; + } + return kResultOk; + } + + if (!mEngineRef || mSampleRate <= 0.0 || mMaxBlockSize <= 0) + return kResultFalse; + + if (mIo) + return kResultOk; + + int err = sl_vst_io_alloc (&mIo, static_cast (mSampleRate), + static_cast (mMaxBlockSize)); + if (err != 0) { + std::fprintf (stderr, + "StudioLink VST3: io alloc failed (%d)\n", err); + return kResultFalse; + } + + return kResultOk; +} + +//------------------------------------------------------------------------ +tresult PLUGIN_API Processor::setupProcessing (ProcessSetup& setup) +{ + // Block size and rate decide the bridge buffers, so rebuild it. + if (mIo) { + sl_vst_io_free (mIo); + mIo = nullptr; + } + + mSampleRate = setup.sampleRate; + mMaxBlockSize = setup.maxSamplesPerBlock; + + return SingleComponentEffect::setupProcessing (setup); +} + +//------------------------------------------------------------------------ +tresult PLUGIN_API Processor::canProcessSampleSize (int32 symbolicSampleSize) +{ + // StudioLink is 16 bit / 48 kHz internally, double precision would + // only add conversions. + return symbolicSampleSize == kSample32 ? kResultTrue : kResultFalse; +} + +//------------------------------------------------------------------------ +tresult PLUGIN_API Processor::setBusArrangements (SpeakerArrangement* inputs, + int32 numIns, + SpeakerArrangement* outputs, + int32 numOuts) +{ + if (numIns == 1 && numOuts == 1 && inputs[0] == SpeakerArr::kStereo && + outputs[0] == SpeakerArr::kStereo) { + return SingleComponentEffect::setBusArrangements ( + inputs, numIns, outputs, numOuts); + } + + return kResultFalse; +} + +//------------------------------------------------------------------------ +void Processor::applyParam (ParamID tag, ParamValue value) +{ + const bool on = value >= 0.5; + + switch (tag) { + + case kParamMute: + if (on == mMute) + break; + mMute = on; + sl_vst_engine_cmd (SL_VST_CMD_MUTE, on ? 1 : 0); + break; + + case kParamRecord: + if (on == mRecord) + break; + mRecord = on; + sl_vst_engine_cmd (SL_VST_CMD_RECORD, on ? 1 : 0); + break; + + case kParamWebUi: + // fire on the rising edge only + if (on && !mWebUi) + sl_vst_engine_cmd (SL_VST_CMD_WEBUI, 0); + mWebUi = on; + break; + + default: + break; + } +} + +//------------------------------------------------------------------------ +tresult PLUGIN_API Processor::setParamNormalized (ParamID tag, + ParamValue value) +{ + tresult result = + SingleComponentEffect::setParamNormalized (tag, value); + if (result != kResultTrue) + return result; + + applyParam (tag, value); + + return kResultTrue; +} + +//------------------------------------------------------------------------ +void Processor::handleParamChanges (IParameterChanges* changes) +{ + if (!changes) + return; + + int32 count = changes->getParameterCount (); + + for (int32 i = 0; i < count; i++) { + + IParamValueQueue* queue = changes->getParameterData (i); + if (!queue) + continue; + + int32 points = queue->getPointCount (); + if (points <= 0) + continue; + + int32 offset = 0; + ParamValue value = 0.0; + + // sample accurate automation is pointless here, these + // parameters map to network side state + if (queue->getPoint (points - 1, offset, value) != kResultTrue) + continue; + + setParamNormalized (queue->getParameterId (), value); + } +} + +//------------------------------------------------------------------------ +tresult PLUGIN_API Processor::process (ProcessData& data) +{ + handleParamChanges (data.inputParameterChanges); + + if (data.numSamples <= 0) + return kResultOk; + + if (data.numOutputs < 1 || data.outputs[0].numChannels < 1) + return kResultOk; + + if (data.symbolicSampleSize != kSample32) + return kResultOk; + + const float* const* in = nullptr; + int32 chIn = 0; + + if (data.numInputs > 0 && data.inputs[0].numChannels > 0 && + data.inputs[0].channelBuffers32) { + in = data.inputs[0].channelBuffers32; + chIn = data.inputs[0].numChannels; + } + + sl_vst_io_process ( + mIo, in, data.outputs[0].channelBuffers32, + static_cast (chIn), + static_cast (data.outputs[0].numChannels), + static_cast (data.numSamples)); + + // we always produce audio, never flag the output as silent + data.outputs[0].silenceFlags = 0; + + return kResultOk; +} + +//------------------------------------------------------------------------ +tresult PLUGIN_API Processor::setState (IBStream* state) +{ + if (!state) + return kResultFalse; + + IBStreamer streamer (state, kLittleEndian); + + int32 version = 0; + if (!streamer.readInt32 (version)) + return kResultFalse; + + if (version != kStateVersion) + return kResultFalse; + + int32 mute = 0; + int32 record = 0; + + if (!streamer.readInt32 (mute) || !streamer.readInt32 (record)) + return kResultFalse; + + setParamNormalized (kParamMute, mute ? 1.0 : 0.0); + setParamNormalized (kParamRecord, record ? 1.0 : 0.0); + + return kResultOk; +} + +//------------------------------------------------------------------------ +tresult PLUGIN_API Processor::getState (IBStream* state) +{ + if (!state) + return kResultFalse; + + IBStreamer streamer (state, kLittleEndian); + + streamer.writeInt32 (kStateVersion); + streamer.writeInt32 (mMute ? 1 : 0); + streamer.writeInt32 (mRecord ? 1 : 0); + + return kResultOk; +} + +//------------------------------------------------------------------------ +} // namespace StudioLink diff --git a/app/vst3/src/plugin.h b/app/vst3/src/plugin.h new file mode 100644 index 0000000..a096a91 --- /dev/null +++ b/app/vst3/src/plugin.h @@ -0,0 +1,97 @@ +//------------------------------------------------------------------------ +// StudioLink VST3 - processor and controller +// +// Copyright Sebastian Reimers +// License: MIT (see LICENSE File) +//------------------------------------------------------------------------ + +#pragma once + +#include "public.sdk/source/vst/vstsinglecomponenteffect.h" + +struct sl_vst_io; + +namespace StudioLink { + +//------------------------------------------------------------------------ +/** + * StudioLink as a VST3 effect. + * + * The plug-in has no editor of its own - StudioLink is operated through its + * web frontend, which the "Open Web UI" parameter launches. Processor and + * controller are combined, because the shared engine cannot be split across + * two components anyway. + * + * Audio flow: + * DAW input -> StudioLink local track -> remote peers + * DAW output <- mix of all remote peers + */ +class Processor : public Steinberg::Vst::SingleComponentEffect +{ +public: + Processor () = default; + ~Processor () SMTG_OVERRIDE; + + static Steinberg::FUnknown* createInstance (void*) + { + return static_cast ( + new Processor); + } + + //--- from IPluginBase -------------------------------------------- + Steinberg::tresult PLUGIN_API initialize ( + Steinberg::FUnknown* context) SMTG_OVERRIDE; + Steinberg::tresult PLUGIN_API terminate () SMTG_OVERRIDE; + + //--- from IComponent --------------------------------------------- + Steinberg::tresult PLUGIN_API setActive ( + Steinberg::TBool state) SMTG_OVERRIDE; + Steinberg::tresult PLUGIN_API setState ( + Steinberg::IBStream* state) SMTG_OVERRIDE; + Steinberg::tresult PLUGIN_API getState ( + Steinberg::IBStream* state) SMTG_OVERRIDE; + + //--- from IAudioProcessor ---------------------------------------- + Steinberg::tresult PLUGIN_API setBusArrangements ( + Steinberg::Vst::SpeakerArrangement* inputs, + Steinberg::int32 numIns, + Steinberg::Vst::SpeakerArrangement* outputs, + Steinberg::int32 numOuts) SMTG_OVERRIDE; + Steinberg::tresult PLUGIN_API canProcessSampleSize ( + Steinberg::int32 symbolicSampleSize) SMTG_OVERRIDE; + Steinberg::tresult PLUGIN_API setupProcessing ( + Steinberg::Vst::ProcessSetup& setup) SMTG_OVERRIDE; + Steinberg::tresult PLUGIN_API process ( + Steinberg::Vst::ProcessData& data) SMTG_OVERRIDE; + // The remote peers keep sending even when the DAW input is silent, + // so the host must never stop calling process(). + Steinberg::uint32 PLUGIN_API getTailSamples () SMTG_OVERRIDE + { + return Steinberg::Vst::kInfiniteTail; + } + + //--- from IEditController ----------------------------------------- + Steinberg::tresult PLUGIN_API setParamNormalized ( + Steinberg::Vst::ParamID tag, + Steinberg::Vst::ParamValue value) SMTG_OVERRIDE; + +private: + // Forwards a parameter to the StudioLink thread, but only when it + // actually changed - process() runs on the realtime thread. + void applyParam (Steinberg::Vst::ParamID tag, + Steinberg::Vst::ParamValue value); + void handleParamChanges (Steinberg::Vst::IParameterChanges* changes); + + sl_vst_io* mIo = nullptr; + bool mEngineRef = false; + + bool mMute = false; + bool mRecord = false; + bool mWebUi = false; + + double mSampleRate = 0.0; + Steinberg::int32 mMaxBlockSize = 0; +}; + +//------------------------------------------------------------------------ +} // namespace StudioLink diff --git a/app/vst3/src/version.h b/app/vst3/src/version.h new file mode 100644 index 0000000..2d5f71a --- /dev/null +++ b/app/vst3/src/version.h @@ -0,0 +1,44 @@ +//------------------------------------------------------------------------ +// StudioLink VST3 - version information +// +// Copyright Sebastian Reimers +// License: MIT (see LICENSE File) +//------------------------------------------------------------------------ + +#pragma once + +#include "pluginterfaces/base/fplatform.h" + +#define MAJOR_VERSION_STR "26" +#define MAJOR_VERSION_INT 26 + +#define SUB_VERSION_STR "01" +#define SUB_VERSION_INT 1 + +#define RELEASE_NUMBER_STR "0" +#define RELEASE_NUMBER_INT 0 + +#define BUILD_NUMBER_STR "0" +#define BUILD_NUMBER_INT 0 + +#define FULL_VERSION_STR \ + MAJOR_VERSION_STR "." SUB_VERSION_STR "." RELEASE_NUMBER_STR \ + "." BUILD_NUMBER_STR + +#define VERSION_STR \ + MAJOR_VERSION_STR "." SUB_VERSION_STR "." RELEASE_NUMBER_STR + +#define stringOriginalFilename "studiolink.vst3" +#define stringFileDescription "StudioLink Audio over IP" +#define stringCompanyName "Studio Link" +#define stringCompanyWeb "https://studio-link.de" +#define stringCompanyEmail "mailto:info@studio-link.de" +#define stringLegalCopyright "Copyright (C) 2013-2026 Sebastian Reimers" +#define stringLegalTrademarks \ + "VST is a trademark of Steinberg Media Technologies GmbH" + +#if SMTG_PLATFORM_64 +#define stringPluginName "StudioLink VST3 (64Bit)" +#else +#define stringPluginName "StudioLink VST3" +#endif diff --git a/cmake/FindSampleRate.cmake b/cmake/FindSampleRate.cmake new file mode 100644 index 0000000..dfe16b3 --- /dev/null +++ b/cmake/FindSampleRate.cmake @@ -0,0 +1,8 @@ +find_path(SAMPLERATE_INCLUDE_DIR NAMES samplerate.h HINTS include) +find_library(SAMPLERATE_LIBRARIES NAMES samplerate HINTS lib) + +include(FindPackageHandleStandardArgs) +find_package_handle_standard_args(SampleRate DEFAULT_MSG SAMPLERATE_LIBRARIES + SAMPLERATE_INCLUDE_DIR) + +mark_as_advanced(SAMPLERATE_INCLUDE_DIR SAMPLERATE_LIBRARIES) diff --git a/libsl/CMakeLists.txt b/libsl/CMakeLists.txt index 00ca46e..2f1763f 100644 --- a/libsl/CMakeLists.txt +++ b/libsl/CMakeLists.txt @@ -12,6 +12,7 @@ project(sl LANGUAGES C) find_package(LMDB REQUIRED) find_package(FLAC REQUIRED) +find_package(SampleRate REQUIRED) ############################################################################## # @@ -23,12 +24,14 @@ set(SRCS src/audio.c src/conf.c src/db.c + src/drift.c src/flac.c src/http/client.c src/http/server.c src/main.c src/meters.c src/record.c + src/resample.c src/tracks.c src/ws.c ) @@ -85,7 +88,8 @@ LIST(APPEND COMPILED_RESOURCES ${OUTPUT_FILE}) # # Main target object # -set(LINKLIBS baresip re ${FLAC_LIBRARIES} ${LMDB_LIBRARIES} stdc++) +set(LINKLIBS baresip re ${FLAC_LIBRARIES} ${LMDB_LIBRARIES} + ${SAMPLERATE_LIBRARIES} stdc++) if(WIN32) list(APPEND LINKLIBS winmm setupapi) @@ -93,7 +97,8 @@ endif() add_library(${PROJECT_NAME} STATIC ${SRCS} ${HEADERS} ${COMPILED_RESOURCES}) target_link_libraries(${PROJECT_NAME} PUBLIC ${LINKLIBS}) -target_include_directories(${PROJECT_NAME} PUBLIC include PRIVATE ${CMAKE_CURRENT_BINARY_DIR}) +target_include_directories(${PROJECT_NAME} PUBLIC include + PRIVATE ${CMAKE_CURRENT_BINARY_DIR} ${SAMPLERATE_INCLUDE_DIR}) target_compile_definitions(${PROJECT_NAME} PRIVATE ${RE_DEFINITIONS}) if(WIN32) diff --git a/libsl/include/studiolink.h b/libsl/include/studiolink.h index 535446c..76614c7 100644 --- a/libsl/include/studiolink.h +++ b/libsl/include/studiolink.h @@ -252,6 +252,79 @@ void sl_record(struct auframe *af); int sl_record_close(void); +/****************************************************************************** + * resample.c + */ +struct sl_rsmp; +int sl_rsmp_alloc(struct sl_rsmp **rsmpp, unsigned ch, uint32_t irate, + uint32_t orate, size_t in_max); +size_t sl_rsmp_process(struct sl_rsmp *rsmp, const float *in, size_t in_frm, + float *out, size_t out_max); +void sl_rsmp_set_ratio(struct sl_rsmp *rsmp, double ratio); +double sl_rsmp_get_ratio(const struct sl_rsmp *rsmp); +size_t sl_rsmp_staged(const struct sl_rsmp *rsmp); +void sl_rsmp_reset(struct sl_rsmp *rsmp); + + +/****************************************************************************** + * drift.c + */ +struct sl_drift; + +/** + * Allocate a clock drift controller for one audio buffer + * + * @param driftp Allocated controller + * @param srate Samplerate of the buffer content + * @param ch Channels of the buffer content + * @param target_sz Wanted fill level in bytes + * + * @return 0 if success, otherwise errorcode + */ +int sl_drift_alloc(struct sl_drift **driftp, uint32_t srate, uint8_t ch, + size_t target_sz); + +/** + * Feed the current fill level of the buffer to the controller + * + * @param drift Controller + * @param cur_sz Current fill level in bytes + * @param now Current time in ms (tmr_jiffies) + */ +void sl_drift_update(struct sl_drift *drift, size_t cur_sz, uint64_t now); + +/** + * @return Resample ratio that cancels the drift, 1.0 means no correction + */ +double sl_drift_ratio(const struct sl_drift *drift); + +/** + * @return Applied correction in ppm. This is the negative of the measured + * clock offset between producer and consumer. + */ +double sl_drift_ppm(const struct sl_drift *drift); + +/** + * @return true once the controller has settled and is steering + */ +bool sl_drift_locked(const struct sl_drift *drift); + +/** + * Move the wanted fill level + * + * The setpoint has to clear one hardware block plus the mixer frame size, + * otherwise a large block can empty the buffer between refills no matter + * how well the drift itself is tracked. + * + * @param drift Controller + * @param target_sz Wanted fill level in bytes + */ +void sl_drift_set_target(struct sl_drift *drift, size_t target_sz); + +/** Drop the estimate, e.g. after a device change or a buffer flush */ +void sl_drift_reset(struct sl_drift *drift); + + /****************************************************************************** * flac.c */ diff --git a/libsl/src/audio.c b/libsl/src/audio.c index ce22bf7..831af0a 100644 --- a/libsl/src/audio.c +++ b/libsl/src/audio.c @@ -3,6 +3,12 @@ enum { PTIME = 20, SRATE = 48000, CH = 2 }; +/* + * Largest hardware block we will drift correct. Blocks above this fall back + * to an uncorrected read instead of overrunning the scratch buffers. + */ +enum { PLAY_MAX_FRM = (SRATE * PTIME / 1000) * 4 }; /* 80 ms */ + struct slaudio { struct le le; struct sl_track *local_track; @@ -16,6 +22,21 @@ struct slaudio { } play, src; struct aumix_source *mix_src; /**< filled by local driver */ struct aubuf *ab_mix; + + /* + * ab_mix sits on a clock boundary: it is written by the aumix + * thread, which is paced by the OS monotonic clock, and drained by + * the sound card callback, which runs on the device crystal. The + * two are never identical, so without correction the buffer slowly + * fills or empties until aubuf drops or zero fills a whole frame. + */ + struct { + struct sl_drift *drift; + struct sl_rsmp *rsmp; + int16_t *s16; + float *flin; + float *flout; + } play_dc; }; struct amix { @@ -432,15 +453,67 @@ static void driver_mixh(const int16_t *sampv, size_t sampc, void *arg) static void driver_write_handler(struct auframe *af, void *arg) { - (void)af; struct slaudio *a = arg; + struct auframe rd; + size_t frm, need, staged, n; + double ratio; if (af->fmt != AUFMT_S16LE) { warning("auplay wrong format\n"); return; } - aubuf_read_auframe(a->ab_mix, af); + frm = af->sampc / CH; + + /* no correction available, or an unexpectedly large block */ + if (!a->play_dc.drift || !a->play_dc.rsmp || !frm || + frm > PLAY_MAX_FRM) { + aubuf_read_auframe(a->ab_mix, af); + return; + } + + /* the setpoint must clear one device block plus a mixer frame, or a + large block drains the buffer between refills */ + sl_drift_set_target(a->play_dc.drift, + (frm + (SRATE * PTIME / 1000) * 2) * CH * + sizeof(int16_t)); + + sl_drift_update(a->play_dc.drift, aubuf_cur_size(a->ab_mix), + tmr_jiffies()); + + ratio = sl_drift_ratio(a->play_dc.drift); + + if (ratio != 1.0) + warning("ratio: %f\n", ratio); + + /* ratio is "mixer samples consumed per device sample", the + resampler wants output/input */ + sl_rsmp_set_ratio(a->play_dc.rsmp, 1.0 / ratio); + + need = (size_t)((double)frm * ratio) + 2; + if (need > PLAY_MAX_FRM) + need = PLAY_MAX_FRM; + + staged = sl_rsmp_staged(a->play_dc.rsmp); + need = (need > staged) ? need - staged : 0; + + if (need) { + auframe_init(&rd, AUFMT_S16LE, a->play_dc.s16, need * CH, + SRATE, CH); + aubuf_read_auframe(a->ab_mix, &rd); + auconv_to_float(a->play_dc.flin, AUFMT_S16LE, a->play_dc.s16, + need * CH); + } + + n = sl_rsmp_process(a->play_dc.rsmp, need ? a->play_dc.flin : NULL, + need, a->play_dc.flout, frm); + + if (n < frm) { + memset(a->play_dc.flout + n * CH, 0, + (frm - n) * CH * sizeof(float)); + } + + auconv_to_s16(af->sampv, AUFMT_FLOAT, a->play_dc.flout, frm * CH); } @@ -499,6 +572,8 @@ static int driver_start(struct slaudio *a) } aubuf_flush(a->ab_mix); + sl_drift_reset(a->play_dc.drift); + sl_rsmp_reset(a->play_dc.rsmp); info("slaudio: driver started\n"); @@ -541,6 +616,11 @@ static void slaudio_destructor(void *data) mem_deref(audio->ausrc_st); mem_deref(audio->mix_src); mem_deref(audio->ab_mix); + mem_deref(audio->play_dc.drift); + mem_deref(audio->play_dc.rsmp); + mem_deref(audio->play_dc.s16); + mem_deref(audio->play_dc.flin); + mem_deref(audio->play_dc.flout); } @@ -653,6 +733,32 @@ int sl_audio_alloc(struct slaudio **audiop, struct sl_track *track) aumix_source_enable(a->mix_src, true); + /* Clock drift correction on the playback path. Failing to set it up + is not fatal - we simply fall back to the uncorrected read. */ + err = sl_drift_alloc(&a->play_dc.drift, SRATE, CH, + (SRATE * CH * PTIME) / 1000 * sizeof(int16_t) * + 3); + err |= sl_rsmp_alloc(&a->play_dc.rsmp, CH, SRATE, SRATE, + PLAY_MAX_FRM * 2); + + a->play_dc.s16 = mem_zalloc(PLAY_MAX_FRM * CH * sizeof(int16_t), + NULL); + a->play_dc.flin = mem_zalloc(PLAY_MAX_FRM * CH * sizeof(float), + NULL); + a->play_dc.flout = mem_zalloc(PLAY_MAX_FRM * CH * sizeof(float), + NULL); + + if (err || !a->play_dc.s16 || !a->play_dc.flin || + !a->play_dc.flout) { + warning("slaudio: drift correction disabled (%m)\n", err); + a->play_dc.drift = mem_deref(a->play_dc.drift); + a->play_dc.rsmp = mem_deref(a->play_dc.rsmp); + err = 0; + } + else { + info("slaudio: clock drift correction enabled\n"); + } + out: if (err) { mem_deref(a); diff --git a/libsl/src/drift.c b/libsl/src/drift.c new file mode 100644 index 0000000..f02fef5 --- /dev/null +++ b/libsl/src/drift.c @@ -0,0 +1,182 @@ +/** + * @file drift.c Clock drift estimation and correction + * + * The audio mixer (aumix) is paced by the OS monotonic clock, the sound + * card by its own crystal. The two never agree exactly - a few hundred ppm + * apart is normal - so the buffer between them slowly fills or empties + * until aubuf drops or zero fills a whole 20ms frame. That is an audible + * click every couple of minutes, on a timer. + * + * This is a control loop that watches the fill level of that buffer and + * returns a resample ratio which cancels the drift. + * + * The plant is an integrator: buffer fill is the integral of the rate + * difference between producer and consumer. A proportional controller is + * therefore enough to fully cancel a constant rate offset - the price is a + * small standing offset in fill level, where + * + * kp * offset = drift + * + * With the gain below, 200ppm of drift parks the buffer 10ms away from its + * target, which is well inside the 40ms..200ms that ab_mix provides. No + * integral term is used on purpose: it would remove that offset but adds + * windup, and the offset is not what costs us anything here. + * + * Copyright Sebastian Reimers + * License: MIT (see LICENSE File) + */ + +#include + +/** + * Loop gain in 1/s. Also the reciprocal of the closed loop time constant, + * so 0.02 means the loop settles over ~50s. Slow is deliberate: the + * correction is a pitch shift, and it must stay far below anything a + * listener could hear as wow or flutter. + */ +static const double drift_kp = 0.02; + +/** Measurement filter time constant in s. The fill level is quantised to + * whole mixer frames, so the raw signal jitters by tens of ms. Well + * separated from the loop time constant to keep the loop stable. */ +static const double drift_tau = 8.0; + +/** Hard limit of the correction. Two devices at the usual +/-100ppm cannot + * exceed this; if we hit the clamp something else is wrong and dropping a + * frame is the honest fallback. */ +static const double drift_max = 0.001; /* 1000 ppm */ + +/** Do not steer until the buffer has settled after start or a flush. */ +enum { DRIFT_WARMUP_MS = 3000, DRIFT_DT_MAX_MS = 1000 }; + + +struct sl_drift { + double target; /**< Wanted fill level in seconds */ + double bps; /**< Bytes per second */ + double avg; /**< Filtered fill error in seconds */ + double ratio; /**< Correction, 1.0 means no drift */ + uint64_t ts; /**< Last update */ + uint64_t start; /**< First update after reset */ + bool init; +}; + + +int sl_drift_alloc(struct sl_drift **driftp, uint32_t srate, uint8_t ch, + size_t target_sz) +{ + struct sl_drift *d; + + if (!driftp || !srate || !ch || !target_sz) + return EINVAL; + + d = mem_zalloc(sizeof(*d), NULL); + if (!d) + return ENOMEM; + + d->bps = (double)srate * (double)ch * (double)sizeof(int16_t); + d->target = (double)target_sz / d->bps; + d->ratio = 1.0; + + *driftp = d; + + return 0; +} + + +void sl_drift_set_target(struct sl_drift *d, size_t target_sz) +{ + double target; + + if (!d || !target_sz) + return; + + target = (double)target_sz / d->bps; + + if (target == d->target) + return; + + /* keep the filtered error consistent with the new setpoint instead + of making the loop chase a step */ + d->avg += d->target - target; + d->target = target; +} + + +void sl_drift_reset(struct sl_drift *d) +{ + if (!d) + return; + + d->init = false; + d->avg = 0.0; + d->ratio = 1.0; +} + + +void sl_drift_update(struct sl_drift *d, size_t cur_sz, uint64_t now) +{ + double err, dt, alpha, ratio; + + if (!d) + return; + + err = (double)cur_sz / d->bps - d->target; + + if (!d->init) { + /* seed the filter, do not ramp up from zero */ + d->avg = err; + d->ts = now; + d->start = now; + d->init = true; + return; + } + + if (now <= d->ts) + return; + + dt = (double)(now - d->ts) / 1000.0; + d->ts = now; + + /* a long gap means the stream was stalled, not that it drifted */ + if (dt > (double)DRIFT_DT_MAX_MS / 1000.0) { + sl_drift_reset(d); + return; + } + + /* first order lowpass, sample interval aware */ + alpha = dt / (drift_tau + dt); + d->avg += alpha * (err - d->avg); + + if ((now - d->start) < DRIFT_WARMUP_MS) + return; + + ratio = 1.0 + drift_kp * d->avg; + + if (ratio > 1.0 + drift_max) + ratio = 1.0 + drift_max; + else if (ratio < 1.0 - drift_max) + ratio = 1.0 - drift_max; + + d->ratio = ratio; +} + + +double sl_drift_ratio(const struct sl_drift *d) +{ + return d ? d->ratio : 1.0; +} + + +double sl_drift_ppm(const struct sl_drift *d) +{ + return d ? (d->ratio - 1.0) * 1e6 : 0.0; +} + + +bool sl_drift_locked(const struct sl_drift *d) +{ + if (!d || !d->init) + return false; + + return (d->ts - d->start) >= DRIFT_WARMUP_MS; +} diff --git a/libsl/src/http/server.c b/libsl/src/http/server.c index 2b97f28..5223dd1 100644 --- a/libsl/src/http/server.c +++ b/libsl/src/http/server.c @@ -377,7 +377,7 @@ int sl_http_listen(uint16_t *port) do { #ifdef RELEASE - err = sa_set_str(&srv, "127.0.0.1", port); + err = sa_set_str(&srv, "127.0.0.1", *port); #else err = sa_set_str(&srv, "0.0.0.0", *port); #endif diff --git a/libsl/src/resample.c b/libsl/src/resample.c new file mode 100644 index 0000000..ce6bc51 --- /dev/null +++ b/libsl/src/resample.c @@ -0,0 +1,159 @@ +/** + * @file resample.c Arbitrary ratio samplerate conversion + * + * libre's auresamp only handles integer ratios, which rules out 44.1kHz + * hosts and, more importantly, the tiny fractional ratios that clock drift + * correction needs. libsamplerate is used instead. src_process() does not + * allocate once the state exists, which keeps this realtime safe. + * + * Copyright Sebastian Reimers + * License: MIT (see LICENSE File) + */ + +#include +#include +#include + + +struct sl_rsmp { + SRC_STATE *st; /**< NULL when no conversion is needed */ + double ratio; /**< orate / irate */ + unsigned ch; /**< Interleaved channels */ + float *stage; /**< Input not yet consumed by src */ + size_t stage_frames; + size_t stage_max; +}; + + +static void rsmp_destructor(void *arg) +{ + struct sl_rsmp *r = arg; + + if (r->st) + src_delete(r->st); + + mem_deref(r->stage); +} + + +int sl_rsmp_alloc(struct sl_rsmp **rsmpp, unsigned ch, uint32_t irate, + uint32_t orate, size_t in_max) +{ + struct sl_rsmp *r; + int err = 0; + int serr; + + if (!rsmpp || !ch || !irate || !orate || !in_max) + return EINVAL; + + r = mem_zalloc(sizeof(*r), rsmp_destructor); + if (!r) + return ENOMEM; + + r->ch = ch; + r->ratio = (double)orate / (double)irate; + r->stage_max = in_max; + + r->stage = mem_zalloc(in_max * ch * sizeof(float), NULL); + if (!r->stage) { + err = ENOMEM; + goto out; + } + + /* always allocate: the ratio can be steered at runtime, even when + the nominal rates are identical */ + r->st = src_new(SRC_SINC_FASTEST, (int)ch, &serr); + if (!r->st) { + warning("sl_rsmp: src_new failed: %s\n", src_strerror(serr)); + err = ENOMEM; + goto out; + } + +out: + if (err) + mem_deref(r); + else + *rsmpp = r; + + return err; +} + + +void sl_rsmp_reset(struct sl_rsmp *rsmp) +{ + if (!rsmp) + return; + + rsmp->stage_frames = 0; + + if (rsmp->st) + src_reset(rsmp->st); +} + + +void sl_rsmp_set_ratio(struct sl_rsmp *rsmp, double ratio) +{ + if (!rsmp || ratio <= 0.0) + return; + + /* src_process() interpolates towards the new ratio across the + block, so stepping it here does not click */ + rsmp->ratio = ratio; +} + + +double sl_rsmp_get_ratio(const struct sl_rsmp *rsmp) +{ + return rsmp ? rsmp->ratio : 1.0; +} + + +size_t sl_rsmp_staged(const struct sl_rsmp *rsmp) +{ + return rsmp ? rsmp->stage_frames : 0; +} + + +size_t sl_rsmp_process(struct sl_rsmp *rsmp, const float *in, size_t in_frm, + float *out, size_t out_max) +{ + SRC_DATA sd; + size_t n, rest; + + if (!rsmp || !out || !out_max) + return 0; + + /* stage the new input behind whatever src did not consume yet */ + if (in && in_frm) { + n = rsmp->stage_max - rsmp->stage_frames; + if (in_frm < n) + n = in_frm; + + memcpy(rsmp->stage + rsmp->stage_frames * rsmp->ch, in, + n * rsmp->ch * sizeof(float)); + rsmp->stage_frames += n; + } + + if (!rsmp->stage_frames) + return 0; + + memset(&sd, 0, sizeof(sd)); + sd.data_in = rsmp->stage; + sd.input_frames = (long)rsmp->stage_frames; + sd.data_out = out; + sd.output_frames = (long)out_max; + sd.src_ratio = rsmp->ratio; + sd.end_of_input = 0; + + if (src_process(rsmp->st, &sd)) + return 0; + + n = (size_t)sd.input_frames_used; + rest = rsmp->stage_frames - n; + if (rest) + memmove(rsmp->stage, rsmp->stage + n * rsmp->ch, + rest * rsmp->ch * sizeof(float)); + rsmp->stage_frames = rest; + + return (size_t)sd.output_frames_gen; +} diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 84c9069..b21325d 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -17,6 +17,7 @@ project(test VERSION 0.0.1 LANGUAGES C) set(SRCS src/db.c + src/drift.c src/http.c src/main.c src/tracks.c diff --git a/test/src/drift.c b/test/src/drift.c new file mode 100644 index 0000000..9b5e87f --- /dev/null +++ b/test/src/drift.c @@ -0,0 +1,138 @@ +/** + * @file drift.c Clock drift controller tests + * + * Simulates a mixer producing 20ms frames on the OS clock against a sound + * card consuming blocks on a crystal that is deliberately off by a known + * amount, and checks that the controller cancels it. + */ + +#include +#include +#include +#include +#include "test.h" + +enum { SRATE = 48000, CH = 2, PTIME = 20 }; + +#define FRAME_SZ ((SRATE * CH * PTIME / 1000) * (int)sizeof(int16_t)) +#define BPS ((double)SRATE * CH * (double)sizeof(int16_t)) + + +/** + * Run the loop for a simulated duration + * + * @param ppm Clock offset of the sound card + * @param block Device block size in frames + * @param secs Simulated seconds + * @param ppm_out Measured correction + * @param min_ms Lowest fill level seen after settling + * @param max_ms Highest fill level seen after settling + * + * @return 0 if success, otherwise errorcode + */ +static int simulate(double ppm, unsigned block, unsigned secs, + double *ppm_out, double *min_ms, double *max_ms) +{ + struct sl_drift *drift = NULL; + double target = 2.0 * FRAME_SZ + (double)block * CH * 2; + double fill = target; + double hw_rate = SRATE * (1.0 + ppm * 1e-6); + double dt_cb = (double)block / hw_rate; + double t = 0.0, next_mix = 0.0, next_cb = 0.0; + double lo = 1e18, hi = -1e18; + int err; + + err = sl_drift_alloc(&drift, SRATE, CH, (size_t)target); + if (err) + return err; + + while (t < (double)secs) { + + if (next_mix <= next_cb) { + t = next_mix; + next_mix += (double)PTIME / 1000.0; + fill += FRAME_SZ; + } + else { + t = next_cb; + next_cb += dt_cb; + + sl_drift_update(drift, (size_t)(fill < 0 ? 0 : fill), + (uint64_t)(t * 1000.0)); + + fill -= (double)block * CH * 2 * + sl_drift_ratio(drift); + + if (fill < 0) + fill = 0; + if (fill > 10.0 * FRAME_SZ) + fill = 10.0 * FRAME_SZ; + } + + if (t > (double)secs * 0.6) { + if (fill < lo) + lo = fill; + if (fill > hi) + hi = fill; + } + } + + *ppm_out = sl_drift_ppm(drift); + *min_ms = lo / BPS * 1000.0; + *max_ms = hi / BPS * 1000.0; + + mem_deref(drift); + + return 0; +} + + +int test_sl_drift(void) +{ + static const double ppmv[] = {0, 25, -25, 100, -100, 200, -200}; + static const unsigned blockv[] = {64, 256, 1024, 2048}; + struct sl_drift *drift = NULL; + double got, lo, hi; + size_t i, b; + int err; + + /* a fresh controller must not steer */ + err = sl_drift_alloc(&drift, SRATE, CH, 2 * FRAME_SZ); + TEST_ERR(err); + ASSERT_TRUE(sl_drift_ratio(drift) == 1.0); + ASSERT_TRUE(!sl_drift_locked(drift)); + drift = mem_deref(drift); + + for (b = 0; b < RE_ARRAY_SIZE(blockv); b++) { + + for (i = 0; i < RE_ARRAY_SIZE(ppmv); i++) { + + err = simulate(ppmv[i], blockv[b], 600, &got, &lo, + &hi); + TEST_ERR(err); + + /* the correction is the negative of the drift */ + if (fabs(got + ppmv[i]) > 15.0) { + warning("drift: %f ppm block %u:" + " correction %f ppm\n", + ppmv[i], blockv[b], got); + err = EINVAL; + goto out; + } + + /* buffer must stay clear of both ends of aubuf */ + if (lo < 2.0 || hi > 190.0) { + warning("drift: %f ppm block %u:" + " fill %f..%f ms\n", + ppmv[i], blockv[b], lo, hi); + err = EINVAL; + goto out; + } + } + } + +out: + mem_deref(drift); + + return err; +} diff --git a/test/src/main.c b/test/src/main.c index d6d366c..5d59470 100644 --- a/test/src/main.c +++ b/test/src/main.c @@ -16,6 +16,7 @@ struct test { static const struct test tests[] = { TEST(test_sl_db), + TEST(test_sl_drift), TEST(test_sl_http), TEST(test_tracks), }; diff --git a/test/src/test.h b/test/src/test.h index 3b0dc96..76b4be7 100644 --- a/test/src/test.h +++ b/test/src/test.h @@ -37,5 +37,6 @@ int sl_main_timeout(uint32_t timeout_ms); /* test cases */ int test_sl_db(void); +int test_sl_drift(void); int test_sl_http(void); int test_tracks(void); diff --git a/versions.mk b/versions.mk index 069527c..6ba3943 100644 --- a/versions.mk +++ b/versions.mk @@ -11,4 +11,6 @@ PORTAUDIO_MIRROR := https://github.com/studio-link-3rdparty SAMPLERATE_MIRROR := https://github.com/studio-link-3rdparty LIBRE_VERSION := main BARESIP_VERSION := main +VST3SDK_VERSION := v3.8.0_build_66 +VST3SDK_MIRROR := https://github.com/steinbergmedia/vst3sdk.git CACHE_VERSION := 2026012901 # github cache invalidation workaround