-- -- Second audio track for any streaming service. -- -- Copyright (C) 2020 by RatWithAShotgun -- Modified in 2026 by PSiStream LLC. Changes are listed below. -- -- Derived from OBS-multitrack-stream: -- https://github.com/ratwithacompiler/OBS-multitrack-stream -- -- This program is free software; you can redistribute it and/or -- modify it under the terms of the GNU General Public License -- as published by the Free Software Foundation; either version 2 -- of the License, or (at your option) any later version. -- -- This program is distributed in the hope that it will be useful, -- but WITHOUT ANY WARRANTY; without even the implied warranty of -- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -- GNU General Public License for more details. -- -- You should have received a copy of the GNU General Public License -- along with this program. If not, see . -- -- -- CHANGES FROM THE ORIGINAL (2026, PSiStream LLC) -- * Logs through script_log so output appears in the Script Log, not stdout. -- * Reports the output id and reads the encoder back, to see whether the -- multitrack path kept it. -- * Holds one encoder reference for the script's lifetime. The original -- re-fetched by name on every update, which leaks a reference each time, -- and it cannot simply release it because obs_output_set_audio_encoder does -- not take its own reference. -- * Fixes get_encoder() being called with no argument on the active-output -- path, where it always returned nil. -- * Makes the settings table local; it was a global. -- obs = obslua local g_track = 2 -- OBS audio track (1-6) carrying the clean mix local g_bitrate = 160 local g_enabled = true local g_encoder = nil -- held for the script's lifetime; see note above local ENCODER_NAME = "PSISTREAM_SECOND_AUDIO_V1" local function log(fmt, ...) local ok, msg = pcall(string.format, fmt, ...) obs.script_log(obs.LOG_INFO, ok and msg or fmt) end local function warn(fmt, ...) local ok, msg = pcall(string.format, fmt, ...) obs.script_log(obs.LOG_WARNING, ok and msg or fmt) end -- release_encoder drops our reference. Only safe when the encoder is not -- currently attached to a live output. local function release_encoder() if g_encoder ~= nil then obs.obs_encoder_release(g_encoder) g_encoder = nil end end local function build_encoder(track) release_encoder() local audio = obs.obs_get_audio() if audio == nil then warn("no OBS audio context; cannot create an encoder") return nil end -- mixer_idx is zero-based: OBS "Track 2" is index 1. local enc = obs.obs_audio_encoder_create("ffmpeg_aac", ENCODER_NAME, nil, track - 1, nil) if enc == nil then warn("failed to create the AAC encoder for track %d", track) return nil end local settings = obs.obs_data_create() obs.obs_data_set_int(settings, "bitrate", g_bitrate) obs.obs_encoder_update(enc, settings) obs.obs_data_release(settings) obs.obs_encoder_set_audio(enc, audio) g_encoder = enc log("created encoder on OBS track %d at %d kbps", track, g_bitrate) return enc end local function attach(output) if output == nil then warn("no streaming output to attach to") return end local out_id = obs.obs_output_get_id(output) or "?" log("streaming output id = %s", out_id) -- Under Enhanced Broadcasting this is NOT rtmp_output. Seeing which one it -- is tells us whether the multitrack path is in play at all. if not g_enabled then log("disabled; clearing any second track") obs.obs_output_set_audio_encoder(output, nil, 1) return end if obs.obs_output_active(output) then warn("output already active; a second track can only be attached before it starts") return end local enc = build_encoder(g_track) if enc == nil then obs.obs_output_set_audio_encoder(output, nil, 1) return end -- Index 1 is the second audio track on the output. Note this does NOT take a -- reference, which is why g_encoder holds ours. obs.obs_output_set_audio_encoder(output, enc, 1) -- Read it back. If the multitrack path ignores or overwrites index 1, this is -- where it shows, before a single frame is sent. local check = obs.obs_output_get_audio_encoder(output, 1) if check == nil then warn("attached, but index 1 reads back EMPTY: this output rejected the second track") else log("attached: index 1 now holds %s", obs.obs_encoder_get_name(check) or "?") end end local function on_event(event) if event == obs.OBS_FRONTEND_EVENT_STREAMING_STARTING then local output = obs.obs_frontend_get_streaming_output() attach(output) obs.obs_output_release(output) elseif event == obs.OBS_FRONTEND_EVENT_STREAMING_STOPPED then local output = obs.obs_frontend_get_streaming_output() if output ~= nil then obs.obs_output_set_audio_encoder(output, nil, 1) obs.obs_output_release(output) end release_encoder() end end function script_properties() local props = obs.obs_properties_create() obs.obs_properties_add_bool(props, "enabled", "Enabled") obs.obs_properties_add_int(props, "audio_track", "Clean-mix audio track (1-6)", 1, 6, 1) obs.obs_properties_add_int(props, "bitrate", "Bitrate (kbps)", 60, 320, 1) return props end function script_description() return [[PSiStream: second audio track for any service

Sends a second audio track alongside your stream, the way Twitch's VOD Track option does, but for any service including a named one such as PSiStream. OBS hides that option unless the service is called "Twitch"; the restriction is in the settings screen, not in the streaming code, so this attaches the track directly.

Pick the track carrying your music-free mix. Track 1 is your main stream audio, so Track 2 is the usual choice. Set which sources feed it under Edit, then Advanced Audio Properties.

Must be enabled before you start streaming. Changes do not apply to a live stream.

Check the Script Log after going live: it reports the output type and whether the track was accepted. Under Enhanced Broadcasting OBS uses a different output, and whether it keeps the second track is exactly what this is here to find out.]] end function script_defaults(settings) obs.obs_data_set_default_bool(settings, "enabled", true) obs.obs_data_set_default_int(settings, "audio_track", 2) obs.obs_data_set_default_int(settings, "bitrate", 160) end function script_update(settings) g_enabled = obs.obs_data_get_bool(settings, "enabled") g_bitrate = obs.obs_data_get_int(settings, "bitrate") local track = obs.obs_data_get_int(settings, "audio_track") if track and track >= 1 and track <= 6 then g_track = track end log("settings: enabled=%s track=%d bitrate=%d", tostring(g_enabled), g_track, g_bitrate) end function script_load(settings) obs.obs_frontend_add_event_callback(on_event) end function script_unload() release_encoder() end