--[[ clip-notify.lua v3 ─────────────────────────────────────────────────────── "Clip Saved" chime for OBS Studio. Plays a sound + logs to OBS when the replay buffer saves. Install: 1. Place this file + your .wav in the same folder. 2. OBS → Tools → Scripts → "+" → select clip-notify.lua 3. Set your chime file & volume, hit Test. ─────────────────────────────────────────────────────── --]] obs = obslua local cfg = { play_sound = true, sound_path = "", chime_volume = 80, -- 0–100 notify_on_record = false, } -- ── Platform ────────────────────────────────────────── local is_windows = package.config:sub(1,1) == "\\" local is_mac, is_linux = false, false if not is_windows then local f = io.popen("uname -s 2>/dev/null") if f then local u = f:read("*l"); f:close() is_mac = (u == "Darwin") is_linux = not is_mac end end -- ── FFI (Windows only) ──────────────────────────────── local ffi, winmm local ffi_ok = false if is_windows then local ok; ok, ffi = pcall(require, "ffi") if ok then ffi.cdef[[ /* winmm */ int __stdcall PlaySoundA(const char* pszSound, void* hmod, unsigned long fdwSound); unsigned long __stdcall waveOutSetVolume(void* hwo, unsigned long dwVolume); unsigned long __stdcall waveOutGetVolume(void* hwo, unsigned long* pdwVolume); ]] local ok2; ok2, winmm = pcall(ffi.load, "winmm") ffi_ok = ok2 if ffi_ok then obs.blog(obs.LOG_INFO, "[clip-notify] winmm FFI ready") else obs.blog(obs.LOG_WARNING, "[clip-notify] winmm failed to load — sound disabled") end end end -- ── Volume helpers ──────────────────────────────────── -- waveOutSetVolume: low word = left, high word = right (each 0x0000–0xFFFF) local function pct_to_dword(pct) local v = math.floor((math.max(0, math.min(100, pct)) / 100) * 0xFFFF) return v + v * 0x10000 end -- ── Sound playback ──────────────────────────────────── local SND_FILENAME = 0x00020000 local SND_ASYNC = 0x00000001 local SND_NODEFAULT = 0x00000002 local function play_chime(path) if not cfg.play_sound or path == "" then return end if is_windows then if not ffi_ok then return end -- Save current wave-out volume local prev = ffi.new("unsigned long[1]", 0) winmm.waveOutGetVolume(nil, prev) local saved = prev[0] -- Apply our volume winmm.waveOutSetVolume(nil, pct_to_dword(cfg.chime_volume)) -- Fire async — returns immediately winmm.PlaySoundA(path, nil, SND_FILENAME + SND_ASYNC + SND_NODEFAULT) -- Restore original volume after the chime has finished (1.5s headroom) obs.timer_add(function() winmm.waveOutSetVolume(nil, saved) obs.remove_current_callback() end, 1500) elseif is_mac then -- afplay -v accepts 0.0–1.0 local vol = string.format("%.2f", cfg.chime_volume / 100) os.execute(string.format('afplay -v %s "%s" &', vol, path)) elseif is_linux then -- paplay --volume: 0–65536 (100% = 65536) local vol = math.floor((cfg.chime_volume / 100) * 65536) os.execute(string.format( 'paplay --volume=%d "%s" 2>/dev/null || aplay "%s" 2>/dev/null &', vol, path, path)) end end -- ── Handlers ───────────────────────────────────────── local function on_clip_saved() play_chime(cfg.sound_path) local filename = "" if obs.obs_frontend_get_last_replay ~= nil then local raw = obs.obs_frontend_get_last_replay() if raw and raw ~= "" then filename = raw:match("([^/\\]+)$") or raw end end obs.blog(obs.LOG_INFO, "[clip-notify] Clip saved → " .. (filename ~= "" and filename or "done")) end local function on_recording_saved() if not cfg.notify_on_record then return end play_chime(cfg.sound_path) obs.blog(obs.LOG_INFO, "[clip-notify] Recording saved.") end local function on_event(event) if event == obs.OBS_FRONTEND_EVENT_REPLAY_BUFFER_SAVED then on_clip_saved() elseif event == obs.OBS_FRONTEND_EVENT_RECORDING_STOPPED then on_recording_saved() end end -- ── Script UI ───────────────────────────────────────── function script_properties() local p = obs.obs_properties_create() obs.obs_properties_add_bool(p, "play_sound", "Enable chime sound") obs.obs_properties_add_path(p, "sound_path", "Chime sound file (.wav)", obs.OBS_PATH_FILE, "WAV Files (*.wav);;All Files (*.*)", script_path()) obs.obs_properties_add_int_slider(p, "chime_volume", "Volume", 0, 100, 1) obs.obs_properties_add_bool(p, "notify_on_record", "Also chime when a recording is saved") obs.obs_properties_add_button(p, "test_btn", "▶ Test Chime Now", function() on_clip_saved(); return true end) return p end function script_defaults(s) obs.obs_data_set_default_bool (s, "play_sound", true) obs.obs_data_set_default_string(s, "sound_path", "") obs.obs_data_set_default_int (s, "chime_volume", 80) obs.obs_data_set_default_bool (s, "notify_on_record", false) end function script_update(s) cfg.play_sound = obs.obs_data_get_bool (s, "play_sound") cfg.sound_path = obs.obs_data_get_string(s, "sound_path") cfg.chime_volume = obs.obs_data_get_int (s, "chime_volume") cfg.notify_on_record = obs.obs_data_get_bool (s, "notify_on_record") if cfg.sound_path == "" then cfg.sound_path = script_path() .. "clip-saved.wav" end end function script_load(s) script_update(s) obs.obs_frontend_add_event_callback(on_event) obs.blog(obs.LOG_INFO, "[clip-notify] v3 loaded.") end function script_unload() obs.blog(obs.LOG_INFO, "[clip-notify] Unloaded.") end function script_description() return [[
Plays a chime the moment your replay buffer clip is saved.
Windows: native winmm — no subprocess, instant.
macOS: afplay | Linux: paplay / aplay