Skip to content

Papyrus API — Overview & Concepts

AudioUtil exposes three Hidden script namespaces, all backed by the one DLL. You call them as global native functions (AudioUtil.PlayVoice(...)) — there are no forms to fill and nothing to attach in the Creation Kit.

Script Purpose Reference
AudioUtil The core player: voice / SFX / file / folder playback, handles, groups, channels, lipsync. AudioUtil
AudioUtilPPA Optional bridge to the third-party Accurate Penetration plugin. Inert unless PPA is installed and enabled. AudioUtilPPA
TomlUtil Generic, audio-independent TOML file reader. Any mod may use it for its own settings. TomlUtil

The source scripts (Scripts\Source\AudioUtil.psc etc.) ship with the mod and carry the same documentation inline.

Calling from another SKSE plugin (C++)?

The PlayFile / PlayFileWithLipSync natives and the mouth-claim calls are also exported as a native C++ inter-plugin API — same code path, no Papyrus round-trip, resolved at runtime with GetProcAddress (no link-time dependency). See C++ API (SKSE plugins).

Is the DLL installed?

GetAPIVersion() returns 0 when the DLL is missing, so it doubles as a presence probe. Both AudioUtil and TomlUtil expose their own version; they are independent counters.

if AudioUtil.GetAPIVersion() == 0
    Debug.Notification("AudioUtil is not installed")
    return
endif

The current API version is 11 for AudioUtil (v2 added GetSlotVariation, v3 GetResolvingSlot, v4 GetHandlePath, v5 captions, v6 the tag-scored natives, v7 IsGamePaused, v8 the mouth claims and their listing, v9 the PPA penetration site, v10 the one-call PPA snapshot, v11 PlayVoiceFromSlotEx / PlayFileWithLipSyncEx) and 2 for TomlUtil (v2 added the typed writers). The version increases whenever natives are added, so guard a call on >= N to avoid a missing-native error on an older install.

Existing signatures never change

Papyrus fills a defaulted parameter in at compile time, in the calling script. The game then refuses any call whose argument count differs from the function's ("Incorrect number of arguments passed"), so a new argument on an existing native would break every mod already compiled against it. New options therefore arrive as new natives (for example PlayVoiceFromSlotEx next to PlayVoiceFromSlot), and a script compiled against an older AudioUtil keeps working on a newer one.

Concepts shared by every Play* call

Every playback function (PlayVoice, PlayVoiceFromSlot, PlaySFX, PlayFile, PlayFolder) shares the same model.

Handle

Every Play* returns an int instance handle:

  • > 0 — success. The handle stays valid until the sound ends, is stopped, or is replaced on its channel.
  • 0 — nothing played (unknown category, empty folder, invalid actor, …).

Failures are logged to AudioUtil.log, never thrown. Calls on a dead or 0 handle are safe no-ops, so you rarely need to guard them.

Volume

volume is the 0.0–1.0 base volume of this instance. The effective volume is:

effective = volume × group_volume × group_duck_factor × voice_slider

…and it is re-applied live whenever the group's volume or duck changes. voice_slider is the game's Voice volume, applied to voice lines only (the PlayVoice family and the *WithLipSync calls, see [general] voice_slider); every other sound gets 1.0. A sound that follows an NPC is also faded with distance by the engine, see below.

Group

A group is a volume / duck bucket the instance joins. Any string; "" = no group. [groups] in the TOML sets startup volumes; SetGroupVolume / DuckGroup change them at runtime and affect all current and future members of the group. Conventional names: pc_high / pc_low, partner_high / partner_low, sfx, oneshot — but any name works and is created on first use.

Channel

A channel is an exclusivity lane. Any string; "" = none. Starting a sound on a channel first stops whatever previously played on that channel. Use one channel per logical stream (e.g. one per actor's body SFX) so a new line replaces the old instead of stacking.

File selection — the shuffle bag

A category or folder with N files acts as a shuffle bag: random order, no repeats until the bag empties, then it reshuffles. You don't pick individual files for voice/SFX categories — AudioUtil spreads them out for you. The reshuffle also guards the seam between bags — the first clip of a fresh bag is never the same file that just played, so you never hear a back-to-back repeat.

Weighting by duplication. In an explicit [slot.categories] file list you can list the same file more than once to make it more likely — a clip listed twice is drawn about twice as often. The no-back-to-back guard compares the file path, not the list position, so a weighted clip still never plays twice in a row (unless it's the only distinct file in the pool, where a repeat is unavoidable).

3D positioning

Sounds follow the given actor's position while playing. A sound that follows an NPC plays through the game's own dialogue output model, like a real NPC line: placed in the speakers where the actor stands and faded with distance by the engine ([general] dialogue_output_model). A sound that follows the player stays centered. Pass a None actor to play unattached (2D, at the listener).

Blocking vs. fire-and-forget

The Play* natives return immediately. For "play this line and wait until it finishes" there are non-native Papyrus wrappers (PlayVoiceAndWait, PlaySFXAndWait, Play, WaitForHandle) that poll IsHandlePlaying + Utility.Wait — no suspended VM stack, no latent-function marshalling. They block the calling Papyrus thread only.