// Copyright (C) 2026 half144 and PS5 React contributors // SPDX-License-Identifier: GPL-3.0-or-later // Additional attribution term: see LICENSE-ATTRIBUTION. #include "host_api.hpp" #include "baked_sounds.h" #include "video_audio.hpp" #include "audio/mixer.hpp" #include #include #include #include namespace { struct Sound { const char* name; std::vector samples; // interleaved stereo, the layout the mixer reads hui::audio::Clip clip; float pitch; // the baked rate over the mixer's std::int64_t duration_us; std::int64_t busy_until_us = 0; }; // play() only posts a command to this mixer; the platform's audio thread renders it. hui::audio::Mixer mixer; std::vector sounds; bool audio = false; std::int64_t now_us() { timespec now = {}; clock_gettime(CLOCK_MONOTONIC, &now); return static_cast(now.tv_sec) * 1000000 + now.tv_nsec / 1000; } double clamp(double value, double low, double high) { // Written so NaN collapses to `low`. return value > low ? (value < high ? value : high) : low; } JSValue play(JSContext* ctx, JSValueConst, int argc, JSValueConst* argv) { if (argc < 3) return JS_ThrowTypeError(ctx, "sound.play: expected name, volume and pan"); double volume = 0, pan = 0; if (JS_ToFloat64(ctx, &volume, argv[1]) || JS_ToFloat64(ctx, &pan, argv[2])) return JS_EXCEPTION; const char* name = JS_ToCString(ctx, argv[0]); if (!name) return JS_EXCEPTION; Sound* sound = nullptr; for (Sound& candidate : sounds) if (!std::strcmp(candidate.name, name)) sound = &candidate; if (!sound) { const JSValue error = JS_ThrowPlainError(ctx, "sound.play %s: no imported .wav has this name", name); JS_FreeCString(ctx, name); return error; } JS_FreeCString(ctx, name); // One voice per sound: a held D-pad repeats the focus tick instead of stacking it. const std::int64_t now = now_us(); if (!audio || now < sound->busy_until_us) return JS_NewBool(ctx, false); hui::audio::PlayParams params; params.gain = static_cast(clamp(volume, 0, 1)); params.pan = static_cast(clamp(pan, -1, 1)); params.pitch = sound->pitch; params.bus = hui::audio::Bus::ui; if (!mixer.play_clip(&sound->clip, params)) return JS_NewBool(ctx, false); sound->busy_until_us = now + sound->duration_us; return JS_NewBool(ctx, true); } JSValue set_volume(JSContext* ctx, JSValueConst, int argc, JSValueConst* argv) { double volume = 0; if (!argc) return JS_ThrowTypeError(ctx, "sound.setVolume: expected a volume"); if (JS_ToFloat64(ctx, &volume, argv[0])) return JS_EXCEPTION; mixer.set_master_gain(static_cast(clamp(volume, 0, 1))); return JS_UNDEFINED; } } // namespace namespace video_audio { constexpr std::size_t kSlot = 0; bool start() { if (!audio) audio = host::start_audio(mixer); return audio; } void route(hui::audio::StreamRing* ring) { if (audio) mixer.swap_stream(kSlot, ring); } void set_gain(float gain) { if (audio) mixer.set_stream_gain(kSlot, gain, 0.0f); } } // namespace video_audio bool ps5_react_start_sound() { // Apps without sounds open no audio device and start no audio thread, until a video plays. if (!ps5_react_sound_count) return false; sounds.reserve(ps5_react_sound_count); for (std::uint32_t i = 0; i < ps5_react_sound_count; ++i) { const BakedSound& baked = ps5_react_sounds[i]; Sound& sound = sounds.emplace_back(); sound.name = baked.name; sound.samples.resize(static_cast(baked.frames) * 2); for (std::uint32_t frame = 0; frame < baked.frames; ++frame) for (std::uint32_t channel = 0; channel < 2; ++channel) sound.samples[frame * 2 + channel] = baked.samples[frame * baked.channels + (baked.channels == 2 ? channel : 0)] / 32768.0f; sound.clip = {sound.samples.data(), baked.frames}; sound.pitch = static_cast(baked.rate) / hui::audio::kSampleRate; sound.duration_us = static_cast(baked.frames) * 1000000 / baked.rate; } audio = host::start_audio(mixer); return audio; } void ps5_react_stop_sound() { if (audio) host::stop_audio(); audio = false; sounds.clear(); } JSValue ps5_react_sound_api(JSContext* ctx) { JSValue out = JS_NewObject(ctx); JS_SetPropertyStr(ctx, out, "play", JS_NewCFunction(ctx, play, "play", 3)); JS_SetPropertyStr(ctx, out, "setVolume", JS_NewCFunction(ctx, set_volume, "setVolume", 1)); return out; }