diff --git a/obs-plugin/README.md b/obs-plugin/README.md index 718258aab..3dcddbceb 100644 --- a/obs-plugin/README.md +++ b/obs-plugin/README.md @@ -35,7 +35,10 @@ When you start streaming, the plugin does three things in order. the shape of OBS's [GoLiveApi `PostData`](https://github.com/obsproject/obs-studio/blob/master/frontend/utility/models/multitrack-video.hpp). `authentication` carries the stream key, and `client.supported_codecs` is - `["h264"]`. + `["h264"]`. As a Streamplace extension, `preferences.audio_tracks` lists + OBS's six mixer tracks as `{"track_id", "name"}`, with the names set in + Settings → Output → Audio (empty when unnamed), so the server can ask for + named tracks as stems. 2. It reads the response as a GoLiveApi `Config`: - `status.result: "error"` stops the stream and shows `status.html_en_us`. - `ingest_endpoints`: the plugin uses the first entry whose `protocol` is @@ -45,7 +48,10 @@ When you start streaming, the plugin does three things in order. becomes one video track. - `audio_configurations.live` creates one audio encoder per entry. `codec` is `aac` or `opus`, and `track_id` is the zero-based OBS audio mixer track - to encode. + to encode. Each such track is written with `tkhd.alternate_group` set to + `track_id + 1`, so the AAC and Opus encodes of one mixer track share a + group, and with the mixer track's name (or "Track N") as its title in + `trak/udta/name`, the box ffmpeg writes a track title to. - If either list is empty, the plugin uses OBS's own encoders for that kind of track. 3. It sends `POST` to the ingest URL with `Transfer-Encoding: chunked` and a diff --git a/obs-plugin/meson.build b/obs-plugin/meson.build index dcf349b16..b4b5f7c68 100644 --- a/obs-plugin/meson.build +++ b/obs-plugin/meson.build @@ -5,23 +5,27 @@ project( meson_version: '>=1.1', ) -# Headers only: the plugin resolves libobs symbols from the OBS process that loads it. -libobs_dep = subproject('obs-studio').get_variable('libobs_dep') +# Headers only: the plugin resolves libobs and frontend API symbols from the +# OBS process that loads it. +frontend_api_dep = subproject('obs-studio').get_variable('frontend_api_dep') c_args = ['-DPLUGIN_VERSION="@0@"'.format(get_option('plugin_version'))] link_args = [] link_with = [] -deps = [libobs_dep, dependency('threads')] +deps = [frontend_api_dep, dependency('threads')] if host_machine.system() == 'windows' - # Windows needs an import library for obs.dll; obs.def lists what the plugin uses. + # Windows needs import libraries for obs.dll and obs-frontend-api.dll; the + # .def files list what the plugin uses. dlltool = find_program('x86_64-w64-mingw32-dlltool') - link_with += custom_target( - 'obs-import-lib', - input: 'obs.def', - output: 'libobs.dll.a', - command: [dlltool, '--input-def', '@INPUT@', '--output-lib', '@OUTPUT@', '--dllname', 'obs.dll'], - ) + foreach dll : ['obs', 'obs-frontend-api'] + link_with += custom_target( + dll + '-import-lib', + input: dll + '.def', + output: 'lib' + dll + '.dll.a', + command: [dlltool, '--input-def', '@INPUT@', '--output-lib', '@OUTPUT@', '--dllname', dll + '.dll'], + ) + endforeach deps += meson.get_compiler('c').find_library('ws2_32') # One self-contained DLL, without libwinpthread or libgcc beside it. link_args += ['-static'] diff --git a/obs-plugin/obs-frontend-api.def b/obs-plugin/obs-frontend-api.def new file mode 100644 index 000000000..f9a972433 --- /dev/null +++ b/obs-plugin/obs-frontend-api.def @@ -0,0 +1,5 @@ +; obs-frontend-api functions the plugin calls. The Windows build links against an +; import library generated from this list; a missing entry fails that link. +LIBRARY obs-frontend-api.dll +EXPORTS + obs_frontend_get_profile_config diff --git a/obs-plugin/obs.def b/obs-plugin/obs.def index 89cfec564..07143a06d 100644 --- a/obs-plugin/obs.def +++ b/obs-plugin/obs.def @@ -14,6 +14,7 @@ EXPORTS bmalloc bmemdup brealloc + config_get_string dstr_copy dstr_ncat dstr_ncopy diff --git a/obs-plugin/src/fmp4.c b/obs-plugin/src/fmp4.c index 8f83e1eb5..8acc8f3bd 100644 --- a/obs-plugin/src/fmp4.c +++ b/obs-plugin/src/fmp4.c @@ -68,6 +68,7 @@ void fmp4_free(struct fmp4_mux *mux) obs_encoder_packet_release(&track->samples.array[j].packet); da_free(track->samples); bfree(track->config); + bfree(track->label); } da_free(mux->tracks); mux->sequence = 0; @@ -89,6 +90,8 @@ size_t fmp4_add_track(struct fmp4_mux *mux, const struct fmp4_track *info) track->bitrate = info->bitrate; track->config = bmemdup(info->config, info->config_size); track->config_size = info->config_size; + track->alternate_group = info->alternate_group; + track->label = info->label ? bstrdup(info->label) : NULL; return mux->tracks.num - 1; } @@ -118,7 +121,7 @@ static void write_tkhd(struct serializer *s, const struct fmp4_track *track) s_wb32(s, 0); // duration write_zeros(s, 8); // reserved s_wb16(s, 0); // layer - s_wb16(s, 0); // alternate_group + s_wb16(s, track->alternate_group); s_wb16(s, is_video(track) ? 0 : 0x0100); // volume s_wb16(s, 0); // reserved write_matrix(s); @@ -339,6 +342,16 @@ static void write_trak(struct serializer *s, const struct fmp4_track *track) box_end(s, minf); box_end(s, mdia); + + /* The track title, in ffmpeg's form: a udta holding a name box whose + * payload is the bare UTF-8 string. */ + if (track->label && *track->label) { + int64_t udta = box_begin(s, "udta"); + int64_t name = box_begin(s, "name"); + s_write(s, track->label, strlen(track->label)); + box_end(s, name); + box_end(s, udta); + } box_end(s, trak); } diff --git a/obs-plugin/src/fmp4.h b/obs-plugin/src/fmp4.h index c065cbb67..9553c08a3 100644 --- a/obs-plugin/src/fmp4.h +++ b/obs-plugin/src/fmp4.h @@ -37,6 +37,11 @@ struct fmp4_track { /* avcC payload for H.264, AudioSpecificConfig for AAC, OpusHead for Opus. */ uint8_t *config; size_t config_size; + /* tkhd.alternate_group: tracks sharing a nonzero value are the same + * content in different codecs. 0 when unknown. */ + uint16_t alternate_group; + /* Track title written as udta/name, or NULL. */ + char *label; bool started; /* Packets before zero (encoder priming), in timescale units; undone by an edit list. */ diff --git a/obs-plugin/src/streamplace-output.c b/obs-plugin/src/streamplace-output.c index 9a9018260..90f8d0989 100644 --- a/obs-plugin/src/streamplace-output.c +++ b/obs-plugin/src/streamplace-output.c @@ -1,9 +1,12 @@ #include #include +#include #include #include +#include #include +#include #include #include @@ -43,6 +46,9 @@ struct sp_output { struct array_output_data queue; uint64_t total_bytes; + /* Written on the connect thread before capture begins, read after. */ + struct dstr mixer_names[MAX_AUDIO_MIXES]; /* empty when the streamer left a track unnamed */ + int audio_mixers[MAX_OUTPUT_AUDIO_ENCODERS]; /* mixer per audio encoder we created, else -1 */ /* OBS's own encoders from its output settings, put back when the server * asks for none of a kind. */ obs_encoder_t *obs_video, *obs_audio; @@ -59,7 +65,20 @@ static bool is_stopping(struct sp_output *sp) /* --------------------------------------------------------------------------- * Stream configuration */ -static obs_data_t *build_config_request(const char *key) +/* Mixer track names from Settings → Output → Audio, which is how a streamer + * names stems ("Music"). */ +static void read_mixer_names(struct sp_output *sp) +{ + config_t *profile = obs_frontend_get_profile_config(); + for (int i = 0; i < MAX_AUDIO_MIXES; i++) { + char key[16]; + snprintf(key, sizeof(key), "Track%dName", i + 1); + const char *name = profile ? config_get_string(profile, "AdvOut", key) : NULL; + dstr_copy(&sp->mixer_names[i], name ? name : ""); + } +} + +static obs_data_t *build_config_request(struct sp_output *sp, const char *key) { obs_data_t *post = obs_data_create(); obs_data_set_string(post, "service", "Streamplace"); @@ -100,6 +119,17 @@ static obs_data_t *build_config_request(const char *key) obs_data_set_int(preferences, "audio_samples_per_sec", oai.samples_per_sec); obs_data_set_int(preferences, "audio_channels", get_audio_channels(oai.speakers)); } + /* Streamplace extension: the mixer tracks, so the server can ask for named ones as stems. */ + obs_data_array_t *audio_tracks = obs_data_array_create(); + for (int i = 0; i < MAX_AUDIO_MIXES; i++) { + obs_data_t *track = obs_data_create(); + obs_data_set_int(track, "track_id", i); + obs_data_set_string(track, "name", dstr_is_empty(&sp->mixer_names[i]) ? "" : sp->mixer_names[i].array); + obs_data_array_push_back(audio_tracks, track); + obs_data_release(track); + } + obs_data_set_array(preferences, "audio_tracks", audio_tracks); + obs_data_array_release(audio_tracks); obs_data_set_obj(post, "preferences", preferences); obs_data_release(preferences); @@ -107,9 +137,9 @@ static obs_data_t *build_config_request(const char *key) } /* obs_data has no arrays of strings, so the codec list is patched in as JSON. */ -static char *config_request_json(const char *key) +static char *config_request_json(struct sp_output *sp, const char *key) { - obs_data_t *post = build_config_request(key); + obs_data_t *post = build_config_request(sp, key); struct dstr json = {0}; dstr_copy(&json, obs_data_get_json(post)); dstr_replace(&json, "\"supported_codecs\":\"h264\"", "\"supported_codecs\":[\"h264\"]"); @@ -117,7 +147,7 @@ static char *config_request_json(const char *key) return json.array; } -static obs_data_t *fetch_config(const char *server, const char *key, struct dstr *err) +static obs_data_t *fetch_config(struct sp_output *sp, const char *server, const char *key, struct dstr *err) { struct dstr url = {0}; dstr_copy(&url, server); @@ -125,7 +155,7 @@ static obs_data_t *fetch_config(const char *server, const char *key, struct dstr dstr_resize(&url, url.len - 1); dstr_cat(&url, CONFIG_PATH); - char *body = config_request_json(key); + char *body = config_request_json(sp, key); struct dstr response = {0}; long status = 0; obs_data_t *config = NULL; @@ -348,10 +378,12 @@ static bool create_audio_encoders(struct sp_output *sp, obs_data_array_t *config struct dstr name = {0}; dstr_printf(&name, "streamplace audio %d", (int)i); encoders[i] = obs_audio_encoder_create(type, name.array, settings, (size_t)mixer, NULL); - if (encoders[i]) + if (encoders[i]) { obs_encoder_set_audio(encoders[i], obs_get_audio()); - else + sp->audio_mixers[i] = mixer; + } else { dstr_printf(err, "Could not create audio encoder '%s'", type); + } ok = encoders[i] != NULL; obs_data_release(settings); dstr_free(&name); @@ -460,7 +492,11 @@ static bool add_video_track(struct sp_output *sp, obs_encoder_t *encoder, struct return true; } -static bool add_audio_track(struct sp_output *sp, obs_encoder_t *encoder, struct dstr *err) +/* idx is the encoder's output audio index. Encoders the plugin created from + * the server's configuration carry their mixer track: each becomes an + * alternate group (the AAC and Opus encodes of one mix share it), titled with + * the mixer track's name. */ +static bool add_audio_track(struct sp_output *sp, obs_encoder_t *encoder, size_t idx, struct dstr *err) { const char *codec = obs_encoder_get_codec(encoder); uint8_t *extra = NULL; @@ -488,7 +524,18 @@ static bool add_audio_track(struct sp_output *sp, obs_encoder_t *encoder, struct return false; } + struct dstr label = {0}; + int mixer = sp->audio_mixers[idx]; + if (mixer >= 0) { + track.alternate_group = (uint16_t)(mixer + 1); + if (dstr_is_empty(&sp->mixer_names[mixer])) + dstr_printf(&label, "Track %d", mixer + 1); + else + dstr_copy_dstr(&label, &sp->mixer_names[mixer]); + track.label = label.array; + } fmp4_add_track(&sp->mux, &track); + dstr_free(&label); return true; } @@ -503,7 +550,7 @@ static bool start_mux(struct sp_output *sp, struct dstr *err) } while (sp->audio_tracks < MAX_OUTPUT_AUDIO_ENCODERS && (encoder = obs_output_get_audio_encoder(sp->output, sp->audio_tracks))) { - if (!add_audio_track(sp, encoder, err)) + if (!add_audio_track(sp, encoder, sp->audio_tracks, err)) return false; sp->audio_tracks++; } @@ -683,7 +730,8 @@ static bool connect_and_start(struct sp_output *sp, struct dstr *err) return false; } - obs_data_t *config = fetch_config(server, key ? key : "", err); + read_mixer_names(sp); + obs_data_t *config = fetch_config(sp, server, key ? key : "", err); if (!config) return false; @@ -756,6 +804,8 @@ static void sp_destroy(void *data) obs_encoder_release(sp->obs_audio); pthread_cond_destroy(&sp->cond); pthread_mutex_destroy(&sp->mutex); + for (int i = 0; i < MAX_AUDIO_MIXES; i++) + dstr_free(&sp->mixer_names[i]); bfree(sp); } @@ -778,6 +828,8 @@ static bool sp_start(void *data) sp->video_tracks = 0; sp->audio_tracks = 0; sp->total_bytes = 0; + for (int i = 0; i < MAX_OUTPUT_AUDIO_ENCODERS; i++) + sp->audio_mixers[i] = -1; remember_obs_encoder(&sp->obs_video, obs_output_get_video_encoder2(sp->output, 0)); remember_obs_encoder(&sp->obs_audio, obs_output_get_audio_encoder(sp->output, 0)); diff --git a/obs-plugin/subprojects/packagefiles/obs-studio/meson.build b/obs-plugin/subprojects/packagefiles/obs-studio/meson.build index b3762e392..aa31c9cab 100644 --- a/obs-plugin/subprojects/packagefiles/obs-studio/meson.build +++ b/obs-plugin/subprojects/packagefiles/obs-studio/meson.build @@ -1,3 +1,7 @@ project('obs-studio', 'c', version: '30.2.3') libobs_dep = declare_dependency(include_directories: include_directories('libobs', is_system: true)) +frontend_api_dep = declare_dependency( + include_directories: include_directories('UI/obs-frontend-api', is_system: true), + dependencies: libobs_dep, +)