Sound Tracer
Example apps / sound-tracer / v0.3.0
Overlay appneeds rednode
The game's sounds, live: every event it posts to its audio engine and every sound it loads, each with a button to play it again.
The sounds on this page are made up and play as a tone: in the game the plugin hooks its audio engine and plays the real recordings.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Sound Tracer</title>
<script src="rednode://ui/rednode-ui.js"></script>
<script type="importmap">{ "imports": { "rednode": "rednode://sdk/index.js" } }</script>
<style>
/* Sound Tracer (REDnode Sound Tools, SP2): the game's sound events and loads, live.
The window never scrolls; each column is its own scrolling list of the newest rows. */
html, body { margin: 0; width: 900px; height: 600px; overflow: hidden; background: transparent; }
rn-window { position: absolute; inset: 6px; }
.bar { display: flex; gap: 8px; align-items: center; flex: none; }
.bar rn-input { flex: 1; min-width: 0; }
.line { margin: 0; flex: none; font: 12px var(--rn-mono); color: var(--rn-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.line.bad { color: var(--rn-danger); }
.line.good { color: var(--rn-fg); }
.cols { flex: 1; min-height: 0; display: grid; grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); gap: 10px; }
section { min-width: 0; min-height: 0; display: flex; flex-direction: column; border: 1px solid var(--rn-line); }
section > header { flex: none; display: flex; justify-content: space-between; padding: 5px 8px;
font: 700 12px var(--rn-display); letter-spacing: .12em; text-transform: uppercase; color: var(--rn-accent);
border-bottom: 1px solid var(--rn-line); }
section > header span { font: 12px var(--rn-mono); letter-spacing: 0; text-transform: none; color: var(--rn-dim); }
.list { flex: 1; min-height: 0; overflow-x: hidden; overflow-y: auto; font: 12px var(--rn-mono); }
.row { display: flex; align-items: center; gap: 6px; padding: 2px 6px; color: var(--rn-fg); }
.row:nth-child(even) { background: color-mix(in srgb, var(--rn-panel-2) 45%, transparent); }
.row:hover { background: color-mix(in srgb, var(--rn-accent) 14%, transparent); }
.row.hidden { display: none; }
.t { color: var(--rn-dim); flex: none; }
.n { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.n small { color: var(--rn-dim); font-size: 11px; margin-left: 6px; }
.n.id { flex: 1 0 auto; }
.tag { color: var(--rn-dim); font-size: 11px; flex: 0 1 auto; min-width: 0; max-width: 110px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.row button { flex: none; width: 22px; height: 18px; padding: 0; font: 11px var(--rn-mono); cursor: pointer;
color: var(--rn-accent-2); background: transparent; border: 1px solid color-mix(in srgb, var(--rn-accent-2) 50%, transparent); }
.row button:hover:not(:disabled) { background: color-mix(in srgb, var(--rn-accent-2) 18%, transparent); }
.row button:disabled { opacity: .3; cursor: default; }
.empty { padding: 10px 8px; color: var(--rn-dim); font: 12px var(--rn-mono); }
</style>
</head>
<body>
<rn-window id="win" title="Sound Tracer" draggable closable>
<div class="bar">
<rn-button id="run">Start trace</rn-button>
<rn-button id="pause" variant="ghost" disabled>Pause</rn-button>
<rn-button id="clear" variant="ghost">Clear</rn-button>
<rn-input id="filter" placeholder="Filter: a name, an id or an object"></rn-input>
</div>
<p id="stats" class="line">Not tracing. Start a trace, then play: every sound event and load shows here.</p>
<div class="cols">
<section>
<header>Played <span id="playedCount"></span></header>
<div class="list" id="played"><div class="empty">Events the game posts to its audio engine</div></div>
</section>
<section>
<header>Loaded <span id="loadedCount"></span></header>
<div class="list" id="loaded"><div class="empty">Sounds the game loads by id</div></div>
</section>
</div>
<div class="bar">
<rn-input id="paste" placeholder="Paste an event name or id, or a sound id, then Enter"></rn-input>
<rn-button id="play">Play</rn-button>
<rn-button id="stopAll" variant="ghost" disabled title="Stop every sound and event the Tracer started">Stop all</rn-button>
</div>
<p id="said" class="line"></p>
</rn-window>
<script type="module">
import { audio, ui } from 'rednode';
// Each column keeps its newest rows only, so a long trace never grows without limit.
// Every row also goes to red4ext/logs/rednode-sound-trace.log, written by the host.
const KEEP = 2000;
const $ = (id) => document.getElementById(id);
const names = new Map(); // event id -> name, as the trace brings them
let stop = null;
let started = 0; // when the trace started: rows' clock times count from it
let since = 0; // when the counts started: the trace's start or the last Clear
let paused = false;
let waiting = { played: [], loaded: [] };
let counts = { posts: 0, loads: 0, missing: 0, dropped: 0 };
let events = new Set();
let filter = '';
const two = (n) => String(n).padStart(2, '0');
const clock = (t) => {
const d = new Date(t);
return `${two(d.getHours())}:${two(d.getMinutes())}:${two(d.getSeconds())}.${String(Math.floor(d.getMilliseconds() / 100))}`;
};
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
function say(text, tone = '') {
$('said').textContent = text;
$('said').className = `line ${tone}`;
}
// ---- trace ----
$('run').addEventListener('click', async () => {
if (stop) {
const s = stop;
stop = null;
await s().catch(() => {});
setPaused(false);
$('run').textContent = 'Start trace';
$('pause').setAttribute('disabled', '');
showStats();
return;
}
try {
stop = await audio.trace(onBatch);
started = since = Date.now();
counts = { posts: 0, loads: 0, missing: 0, dropped: 0 };
events = new Set();
$('run').textContent = 'Stop trace';
$('pause').removeAttribute('disabled');
showStats();
} catch (err) {
$('stats').textContent = `Could not start the trace: ${err.message}`;
$('stats').className = 'line bad';
}
});
function onBatch({ posts, loads, dropped }) {
counts.dropped += dropped;
const played = posts.map((p) => {
counts.posts++;
events.add(p.event);
if (p.name) names.set(p.event, p.name);
return playedRow(p);
});
const loaded = loads.map((l) => {
counts.loads++;
if (!l.found) counts.missing++;
return loadedRow(l);
});
add('played', played);
add('loaded', loaded);
showStats();
}
function showStats() {
const s = Math.max(1, (Date.now() - since) / 1000);
const named = [...events].filter((id) => names.has(id)).length;
const time = `${Math.floor(s / 60)}:${two(Math.floor(s % 60))}`;
$('stats').textContent =
`${stop ? `Tracing ${time}` : 'Stopped'}: ${counts.posts} played (${(counts.posts / s).toFixed(1)}/s) of ${events.size} events, ` +
`${named} named; ${counts.loads} loaded, ${counts.missing} not in the index` +
(counts.dropped ? `; ${counts.dropped} lost` : '') +
(paused ? `; paused, ${waiting.played.length + waiting.loaded.length} new` : '');
$('stats').className = counts.dropped ? 'line bad' : 'line good';
$('playedCount').textContent = counts.posts ? String(counts.posts) : '';
$('loadedCount').textContent = counts.loads ? String(counts.loads) : '';
}
// ---- rows ----
function playedRow(p) {
const row = document.createElement('div');
row.className = 'row';
const label = p.name ?? `#${p.event}`;
row.dataset.key = p.name ?? String(p.event);
row.dataset.kind = 'event';
row.dataset.text = `${label} ${p.event} ${p.object}`.toLowerCase();
row.dataset.event = String(p.event);
row.dataset.pk = `event:${p.event}`;
// recordings is undefined when the host could not read the game's sound index: let Play try.
const rec = p.recordings;
const playTip =
rec === 0
? 'No recordings in the game\'s sound index: a stop event, or its sound lives in a Wwise bank'
: `Play ${label}'s recordings${rec ? ` (${rec})` : ''} in turn, as recorded: only you hear them, wherever the sound is in the game`;
const gameTip = p.name
? `Post ${p.name} through the game, on V: the game's own mix, but a positional sound may be silent away from its source`
: 'No name for this event: the game posts events by name';
row.innerHTML =
`<span class="t">${clock(started + p.ms)}</span>` +
`<span class="n" title="Event ${p.event} on game object ${esc(p.object)}${rec !== undefined ? `; ${rec} recordings` : ''}">${esc(label)}${p.name ? `<small>${p.event}</small>` : ''}</span>` +
`<span class="tag">${p.playing ? esc(p.object) : 'failed'}</span>` +
`<button data-act="play" title="${esc(playTip)}"${rec === 0 ? ' disabled' : ''}>▶</button>` +
`<button data-act="game" title="${esc(gameTip)}"${p.name ? '' : ' disabled'}>G</button>` +
stopButton(row.dataset.pk) +
`<button data-act="copy" title="Copy ${esc(row.dataset.key)}">⧉</button>`;
return row;
}
function loadedRow(l) {
const row = document.createElement('div');
row.className = 'row';
row.dataset.key = String(l.sound);
row.dataset.kind = 'sound';
row.dataset.pk = `sound:${l.sound}`;
row.dataset.text = `${l.sound}${l.found ? '' : ' not in the index'}`;
const playTip = l.found ? `Play sound ${l.sound} as recorded, without the game's effects` : 'Not in the opus index (likely inside a Wwise bank): cannot be played by id';
row.innerHTML =
`<span class="t">${clock(started + l.ms)}</span>` +
`<span class="n id">${l.sound}</span>` +
(l.found ? '' : '<span class="tag" title="Not in the opus index">not indexed</span>') +
`<button data-act="play" title="${esc(playTip)}"${l.found ? '' : ' disabled'}>▶</button>` +
stopButton(row.dataset.pk) +
`<button data-act="copy" title="Copy ${l.sound}">⧉</button>`;
return row;
}
function add(which, rows) {
if (!rows.length) return;
if (paused) {
const w = waiting[which];
w.push(...rows);
if (w.length > KEEP) w.splice(0, w.length - KEEP);
return;
}
const list = $(which);
list.querySelector('.empty')?.remove();
const atEnd = list.scrollTop + list.clientHeight >= list.scrollHeight - 8;
const frag = document.createDocumentFragment();
for (const row of rows) {
if (filter && !row.dataset.text.includes(filter)) row.classList.add('hidden');
frag.append(row);
}
list.append(frag);
while (list.childElementCount > KEEP) list.firstElementChild.remove();
if (atEnd) list.scrollTop = list.scrollHeight;
}
function setPaused(on) {
paused = on;
$('pause').textContent = on ? 'Resume' : 'Pause';
if (!on) {
const w = waiting;
waiting = { played: [], loaded: [] };
add('played', w.played);
add('loaded', w.loaded);
}
showStats();
}
$('pause').addEventListener('click', () => setPaused(!paused));
$('clear').addEventListener('click', () => {
for (const which of ['played', 'loaded']) $(which).replaceChildren();
waiting = { played: [], loaded: [] };
counts = { posts: 0, loads: 0, missing: 0, dropped: 0 };
events = new Set();
since = Date.now();
showStats();
});
$('filter').addEventListener('input', (e) => {
filter = e.detail.value.trim().toLowerCase();
for (const row of document.querySelectorAll('.list .row')) {
row.classList.toggle('hidden', !!filter && !row.dataset.text.includes(filter));
}
});
for (const which of ['played', 'loaded']) {
$(which).addEventListener('click', (e) => {
const button = e.target.closest('button[data-act]');
if (!button || button.disabled) return;
const row = button.closest('.row');
if (button.dataset.act === 'copy') copy(row.dataset.key);
else if (button.dataset.act === 'stop') stopRow(row.dataset.pk);
else if (button.dataset.act === 'game') postEvent(row.dataset.key, row.dataset.pk);
else if (row.dataset.kind === 'event') playRecordings(Number(row.dataset.event), names.get(Number(row.dataset.event)) ?? `#${row.dataset.event}`);
else playSound(Number(row.dataset.key));
});
}
// ---- copy and play ----
async function copy(text) {
try {
await ui.copy(text);
say(`Copied ${text}`);
} catch (err) {
say(`Could not copy ${text}: ${err.message}`, 'bad');
}
}
// What the Tracer has playing, by row key ('event:<id>' or 'sound:<id>'), each with how to
// stop it. A row's Stop is on while its key has something here. A sound leaves when it
// ends; an event posted through the game has no end the Tracer can see, so it stays
// stoppable until stopped.
const active = new Map(); // row key -> Set of stop functions
function stopButton(pk) {
return `<button data-act="stop" title="Stop"${active.has(pk) ? '' : ' disabled'}>■</button>`;
}
function refresh(pk) {
for (const b of document.querySelectorAll(`.row[data-pk="${CSS.escape(pk)}"] button[data-act=stop]`)) b.disabled = !active.has(pk);
if (active.size) $('stopAll').removeAttribute('disabled');
else $('stopAll').setAttribute('disabled', '');
}
function addActive(pk, fn) {
if (!active.has(pk)) active.set(pk, new Set());
active.get(pk).add(fn);
refresh(pk);
}
function dropActive(pk, fn) {
const set = active.get(pk);
if (!set) return;
set.delete(fn);
if (!set.size) active.delete(pk);
refresh(pk);
}
async function stopRow(pk, quiet = false) {
const set = active.get(pk);
if (!set) return;
active.delete(pk);
refresh(pk);
const results = await Promise.allSettled([...set].map((fn) => fn()));
const failed = results.find((r) => r.status === 'rejected');
if (failed) say(`Could not stop: ${failed.reason?.message ?? failed.reason}`, 'bad');
else if (!quiet) {
const id = pk.slice(pk.indexOf(':') + 1);
say(`Stopped ${pk.startsWith('event:') ? names.get(Number(id)) ?? `#${id}` : id}`);
}
}
$('stopAll').addEventListener('click', async () => {
const keys = [...active.keys()];
await Promise.all(keys.map((pk) => stopRow(pk, true)));
say(`Stopped ${keys.length === 1 ? 'one row' : `${keys.length} rows`}`);
});
// A sound playing through REDnode's engine stays stoppable until it ends.
function track(pk, s) {
const stopFn = () => s.stop();
addActive(pk, stopFn);
s.ended.then(() => dropActive(pk, stopFn));
}
// An event's recordings through REDnode's engine, the next one each press: heard wherever
// the sound is in the game. Pressing again stops the one still playing.
async function playRecordings(event, label) {
const pk = `event:${event}`;
try {
await stopRow(pk, true);
const s = await audio.playGame({ event });
track(pk, s);
say(`Playing ${label}: recording ${s.variation + 1} of ${s.variations} (sound ${s.sound}, ${s.seconds.toFixed(2)} s), as recorded`, 'good');
} catch (err) {
say(`${label}: ${err.message}`, 'bad');
}
}
// An event posted through the game, on V: the game's own mix and logic.
async function postEvent(name, pk = `event:${name}`) {
try {
const e = await audio.event(name);
addActive(pk, () => e.stop());
say(`Posted ${name} ${e.on === 'player' ? 'on V' : 'with no entity (no player yet)'}, through the game`, 'good');
} catch (err) {
say(`${name}: ${err.message}`, 'bad');
}
}
// One sound by id; playing it again first stops the copy already playing.
async function startSound(id) {
const pk = `sound:${id}`;
await stopRow(pk, true);
const s = await audio.playGame(id);
track(pk, s);
say(`Playing sound ${id} (${s.seconds.toFixed(2)} s), as recorded`, 'good');
}
async function playSound(id) {
try {
await startSound(id);
} catch (err) {
say(`Sound ${id}: ${err.message}`, 'bad');
}
}
// The box: an event (a name, or an id with recordings) plays its recordings; a number
// that is no event's is a sound id; an event with no recordings is posted through the game.
async function playPasted() {
const text = $('paste').value.trim().replace(/^#/, '');
if (!text) return;
try {
const { event, sounds } = await audio.eventSounds(text);
const label = /^\d+$/.test(text) ? names.get(event) ?? `#${event}` : text;
if (sounds.length) return playRecordings(event, label);
if (/^\d+$/.test(text)) return playSound(Number(text));
return postEvent(text, `event:${event}`);
} catch (err) {
say(`${text}: ${err.message}`, 'bad');
}
}
$('play').addEventListener('click', playPasted);
$('paste').addEventListener('submit', playPasted);
</script>
</body>
</html>
import { audio, ui } from 'rednode';
// Each column keeps its newest rows only, so a long trace never grows without limit.
// Every row also goes to red4ext/logs/rednode-sound-trace.log, written by the host.
const KEEP = 2000;
const $ = (id) => document.getElementById(id);
const names = new Map(); // event id -> name, as the trace brings them
let stop = null;
let started = 0; // when the trace started: rows' clock times count from it
let since = 0; // when the counts started: the trace's start or the last Clear
let paused = false;
let waiting = { played: [], loaded: [] };
let counts = { posts: 0, loads: 0, missing: 0, dropped: 0 };
let events = new Set();
let filter = '';
const two = (n) => String(n).padStart(2, '0');
const clock = (t) => {
const d = new Date(t);
return `${two(d.getHours())}:${two(d.getMinutes())}:${two(d.getSeconds())}.${String(Math.floor(d.getMilliseconds() / 100))}`;
};
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
function say(text, tone = '') {
$('said').textContent = text;
$('said').className = `line ${tone}`;
}
// ---- trace ----
$('run').addEventListener('click', async () => {
if (stop) {
const s = stop;
stop = null;
await s().catch(() => {});
setPaused(false);
$('run').textContent = 'Start trace';
$('pause').setAttribute('disabled', '');
showStats();
return;
}
try {
stop = await audio.trace(onBatch);
started = since = Date.now();
counts = { posts: 0, loads: 0, missing: 0, dropped: 0 };
events = new Set();
$('run').textContent = 'Stop trace';
$('pause').removeAttribute('disabled');
showStats();
} catch (err) {
$('stats').textContent = `Could not start the trace: ${err.message}`;
$('stats').className = 'line bad';
}
});
function onBatch({ posts, loads, dropped }) {
counts.dropped += dropped;
const played = posts.map((p) => {
counts.posts++;
events.add(p.event);
if (p.name) names.set(p.event, p.name);
return playedRow(p);
});
const loaded = loads.map((l) => {
counts.loads++;
if (!l.found) counts.missing++;
return loadedRow(l);
});
add('played', played);
add('loaded', loaded);
showStats();
}
function showStats() {
const s = Math.max(1, (Date.now() - since) / 1000);
const named = [...events].filter((id) => names.has(id)).length;
const time = `${Math.floor(s / 60)}:${two(Math.floor(s % 60))}`;
$('stats').textContent =
`${stop ? `Tracing ${time}` : 'Stopped'}: ${counts.posts} played (${(counts.posts / s).toFixed(1)}/s) of ${events.size} events, ` +
`${named} named; ${counts.loads} loaded, ${counts.missing} not in the index` +
(counts.dropped ? `; ${counts.dropped} lost` : '') +
(paused ? `; paused, ${waiting.played.length + waiting.loaded.length} new` : '');
$('stats').className = counts.dropped ? 'line bad' : 'line good';
$('playedCount').textContent = counts.posts ? String(counts.posts) : '';
$('loadedCount').textContent = counts.loads ? String(counts.loads) : '';
}
// ---- rows ----
function playedRow(p) {
const row = document.createElement('div');
row.className = 'row';
const label = p.name ?? `#${p.event}`;
row.dataset.key = p.name ?? String(p.event);
row.dataset.kind = 'event';
row.dataset.text = `${label} ${p.event} ${p.object}`.toLowerCase();
row.dataset.event = String(p.event);
row.dataset.pk = `event:${p.event}`;
// recordings is undefined when the host could not read the game's sound index: let Play try.
const rec = p.recordings;
const playTip =
rec === 0
? 'No recordings in the game\'s sound index: a stop event, or its sound lives in a Wwise bank'
: `Play ${label}'s recordings${rec ? ` (${rec})` : ''} in turn, as recorded: only you hear them, wherever the sound is in the game`;
const gameTip = p.name
? `Post ${p.name} through the game, on V: the game's own mix, but a positional sound may be silent away from its source`
: 'No name for this event: the game posts events by name';
row.innerHTML =
`<span class="t">${clock(started + p.ms)}</span>` +
`<span class="n" title="Event ${p.event} on game object ${esc(p.object)}${rec !== undefined ? `; ${rec} recordings` : ''}">${esc(label)}${p.name ? `<small>${p.event}</small>` : ''}</span>` +
`<span class="tag">${p.playing ? esc(p.object) : 'failed'}</span>` +
`<button data-act="play" title="${esc(playTip)}"${rec === 0 ? ' disabled' : ''}>▶</button>` +
`<button data-act="game" title="${esc(gameTip)}"${p.name ? '' : ' disabled'}>G</button>` +
stopButton(row.dataset.pk) +
`<button data-act="copy" title="Copy ${esc(row.dataset.key)}">⧉</button>`;
return row;
}
function loadedRow(l) {
const row = document.createElement('div');
row.className = 'row';
row.dataset.key = String(l.sound);
row.dataset.kind = 'sound';
row.dataset.pk = `sound:${l.sound}`;
row.dataset.text = `${l.sound}${l.found ? '' : ' not in the index'}`;
const playTip = l.found ? `Play sound ${l.sound} as recorded, without the game's effects` : 'Not in the opus index (likely inside a Wwise bank): cannot be played by id';
row.innerHTML =
`<span class="t">${clock(started + l.ms)}</span>` +
`<span class="n id">${l.sound}</span>` +
(l.found ? '' : '<span class="tag" title="Not in the opus index">not indexed</span>') +
`<button data-act="play" title="${esc(playTip)}"${l.found ? '' : ' disabled'}>▶</button>` +
stopButton(row.dataset.pk) +
`<button data-act="copy" title="Copy ${l.sound}">⧉</button>`;
return row;
}
function add(which, rows) {
if (!rows.length) return;
if (paused) {
const w = waiting[which];
w.push(...rows);
if (w.length > KEEP) w.splice(0, w.length - KEEP);
return;
}
const list = $(which);
list.querySelector('.empty')?.remove();
const atEnd = list.scrollTop + list.clientHeight >= list.scrollHeight - 8;
const frag = document.createDocumentFragment();
for (const row of rows) {
if (filter && !row.dataset.text.includes(filter)) row.classList.add('hidden');
frag.append(row);
}
list.append(frag);
while (list.childElementCount > KEEP) list.firstElementChild.remove();
if (atEnd) list.scrollTop = list.scrollHeight;
}
function setPaused(on) {
paused = on;
$('pause').textContent = on ? 'Resume' : 'Pause';
if (!on) {
const w = waiting;
waiting = { played: [], loaded: [] };
add('played', w.played);
add('loaded', w.loaded);
}
showStats();
}
$('pause').addEventListener('click', () => setPaused(!paused));
$('clear').addEventListener('click', () => {
for (const which of ['played', 'loaded']) $(which).replaceChildren();
waiting = { played: [], loaded: [] };
counts = { posts: 0, loads: 0, missing: 0, dropped: 0 };
events = new Set();
since = Date.now();
showStats();
});
$('filter').addEventListener('input', (e) => {
filter = e.detail.value.trim().toLowerCase();
for (const row of document.querySelectorAll('.list .row')) {
row.classList.toggle('hidden', !!filter && !row.dataset.text.includes(filter));
}
});
for (const which of ['played', 'loaded']) {
$(which).addEventListener('click', (e) => {
const button = e.target.closest('button[data-act]');
if (!button || button.disabled) return;
const row = button.closest('.row');
if (button.dataset.act === 'copy') copy(row.dataset.key);
else if (button.dataset.act === 'stop') stopRow(row.dataset.pk);
else if (button.dataset.act === 'game') postEvent(row.dataset.key, row.dataset.pk);
else if (row.dataset.kind === 'event') playRecordings(Number(row.dataset.event), names.get(Number(row.dataset.event)) ?? `#${row.dataset.event}`);
else playSound(Number(row.dataset.key));
});
}
// ---- copy and play ----
async function copy(text) {
try {
await ui.copy(text);
say(`Copied ${text}`);
} catch (err) {
say(`Could not copy ${text}: ${err.message}`, 'bad');
}
}
// What the Tracer has playing, by row key ('event:<id>' or 'sound:<id>'), each with how to
// stop it. A row's Stop is on while its key has something here. A sound leaves when it
// ends; an event posted through the game has no end the Tracer can see, so it stays
// stoppable until stopped.
const active = new Map(); // row key -> Set of stop functions
function stopButton(pk) {
return `<button data-act="stop" title="Stop"${active.has(pk) ? '' : ' disabled'}>■</button>`;
}
function refresh(pk) {
for (const b of document.querySelectorAll(`.row[data-pk="${CSS.escape(pk)}"] button[data-act=stop]`)) b.disabled = !active.has(pk);
if (active.size) $('stopAll').removeAttribute('disabled');
else $('stopAll').setAttribute('disabled', '');
}
function addActive(pk, fn) {
if (!active.has(pk)) active.set(pk, new Set());
active.get(pk).add(fn);
refresh(pk);
}
function dropActive(pk, fn) {
const set = active.get(pk);
if (!set) return;
set.delete(fn);
if (!set.size) active.delete(pk);
refresh(pk);
}
async function stopRow(pk, quiet = false) {
const set = active.get(pk);
if (!set) return;
active.delete(pk);
refresh(pk);
const results = await Promise.allSettled([...set].map((fn) => fn()));
const failed = results.find((r) => r.status === 'rejected');
if (failed) say(`Could not stop: ${failed.reason?.message ?? failed.reason}`, 'bad');
else if (!quiet) {
const id = pk.slice(pk.indexOf(':') + 1);
say(`Stopped ${pk.startsWith('event:') ? names.get(Number(id)) ?? `#${id}` : id}`);
}
}
$('stopAll').addEventListener('click', async () => {
const keys = [...active.keys()];
await Promise.all(keys.map((pk) => stopRow(pk, true)));
say(`Stopped ${keys.length === 1 ? 'one row' : `${keys.length} rows`}`);
});
// A sound playing through REDnode's engine stays stoppable until it ends.
function track(pk, s) {
const stopFn = () => s.stop();
addActive(pk, stopFn);
s.ended.then(() => dropActive(pk, stopFn));
}
// An event's recordings through REDnode's engine, the next one each press: heard wherever
// the sound is in the game. Pressing again stops the one still playing.
async function playRecordings(event, label) {
const pk = `event:${event}`;
try {
await stopRow(pk, true);
const s = await audio.playGame({ event });
track(pk, s);
say(`Playing ${label}: recording ${s.variation + 1} of ${s.variations} (sound ${s.sound}, ${s.seconds.toFixed(2)} s), as recorded`, 'good');
} catch (err) {
say(`${label}: ${err.message}`, 'bad');
}
}
// An event posted through the game, on V: the game's own mix and logic.
async function postEvent(name, pk = `event:${name}`) {
try {
const e = await audio.event(name);
addActive(pk, () => e.stop());
say(`Posted ${name} ${e.on === 'player' ? 'on V' : 'with no entity (no player yet)'}, through the game`, 'good');
} catch (err) {
say(`${name}: ${err.message}`, 'bad');
}
}
// One sound by id; playing it again first stops the copy already playing.
async function startSound(id) {
const pk = `sound:${id}`;
await stopRow(pk, true);
const s = await audio.playGame(id);
track(pk, s);
say(`Playing sound ${id} (${s.seconds.toFixed(2)} s), as recorded`, 'good');
}
async function playSound(id) {
try {
await startSound(id);
} catch (err) {
say(`Sound ${id}: ${err.message}`, 'bad');
}
}
// The box: an event (a name, or an id with recordings) plays its recordings; a number
// that is no event's is a sound id; an event with no recordings is posted through the game.
async function playPasted() {
const text = $('paste').value.trim().replace(/^#/, '');
if (!text) return;
try {
const { event, sounds } = await audio.eventSounds(text);
const label = /^\d+$/.test(text) ? names.get(event) ?? `#${event}` : text;
if (sounds.length) return playRecordings(event, label);
if (/^\d+$/.test(text)) return playSound(Number(text));
return postEvent(text, `event:${event}`);
} catch (err) {
say(`${text}: ${err.message}`, 'bad');
}
}
$('play').addEventListener('click', playPasted);
$('paste').addEventListener('submit', playPasted);
REDnode runs JavaScript. The rednode package also has TypeScript types, so an editor checks these calls; compile TypeScript to .mjs first.
{
"id": "sound-tracer",
"name": "Sound Tracer",
"version": "0.3.0",
"author": "Edgemodders",
"description": "Lists the game's sounds live: the events it posts to Wwise (Played) and the sounds it loads (Loaded). Filter, pause, copy, and play any row again. Part of REDnode Sound Tools.",
"entry": "index.html",
"kind": "window",
"width": 900,
"height": 600,
"anchor": "top-left",
"x": 80,
"y": 110,
"open": false,
"requires": {
"rednode": ">=0.41.0"
}
}
The manifest names the package and says how REDnode runs it. Its fields are in Overlay apps.
How it works
audio.trace hands over batches about ten times a second. Played rows are events, named when REDnode knows the name (REDbridge's list, loaded by the host, so no permission is needed). Loaded rows are sound ids.
Each column is its own scrolling list that keeps the newest 2,000 rows; the window around them has a fixed size and never scrolls. Rows stick to the bottom unless you scroll up.
Play on an event plays its own recordings in turn through REDnode's engine (audio.playGame with { event }), heard wherever the sound is in the game; it is greyed for an event with none, such as a stop event. G posts the event through the game on V instead, with the game's mix. Play on a Loaded row plays that one recording. Stop beside each stops it; Stop all stops everything the Tracer started.
The host also writes every row to red4ext/logs/rednode-sound-trace.log, so nothing is lost when a column drops old rows.
Part of REDnode Sound Tools.
What it uses
audio.trace audio.event audio.playGame a window that never scrolls
Try this
- Press Start trace and watch both columns fill.
- Type katana in the filter.
- Press Pause, wait, then Resume: the rows that came in the meantime are added.
- Paste 746693166 in the box at the bottom and press Enter.
Use it
Copy the files into red4ext/plugins/REDnode/apps/sound-tracer/ in the game folder, or start your own package from them: change id and name in the manifest first. The whole folder is on GitHub.