Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
39266b8
feat(kiosk): stream relay with a hop policy per upstream: every resol…
Oct 5, 2026
9f61ffd
feat(kiosk): display tickets: one stream for one display under /displ…
Oct 5, 2026
3941d7a
feat(kiosk): media session on the server (queue, state, volume capped…
Oct 5, 2026
310d2d9
feat(kiosk): play sources as adapters (contract 1) and the first one:…
Oct 5, 2026
4a6eb37
feat(kiosk): play, transport and now-playing verbs: a confident stati…
Oct 5, 2026
976bd0b
feat(kiosk): wire the media session, stations and play verbs into dis…
Oct 5, 2026
dff60ed
feat(kiosk): panel station editor with a Home network tick (off by de…
Oct 5, 2026
6537c64
feat(kiosk): one audio element and a now-playing chip on the page, th…
Oct 5, 2026
f634e0a
docs(kiosk): audio on a display (stations and home-network stations, …
Oct 5, 2026
a6a6ffe
fix(kiosk): the relay's public test stays address-only and the host-a…
Oct 5, 2026
3592d71
fix(kiosk): the relay's public rule also refuses an address on any of…
Oct 5, 2026
99d8a70
fix(kiosk): after the rebase onto main, the screen guard leaves the m…
Oct 6, 2026
8f9e61b
fix(kiosk): station names by voice, playback words over music, phone …
Oct 6, 2026
a7b2b60
fix(kiosk): a playback request is only one about the music; one mic o…
Oct 6, 2026
ede990a
fix(kiosk): Spanish comparatives change the playback; a single ordina…
Oct 6, 2026
c4477dc
fix(kiosk): a spoken reply always settles — the player ends a reply w…
Oct 7, 2026
7297c72
fix(kiosk): the station-name STT prompt is off — it made the speech m…
Oct 7, 2026
fdeb806
fix(kiosk): a bare 'Pause.' or 'Play.' with something loaded is answe…
Oct 7, 2026
d0ef32f
fix(kiosk): tool-call syntax a model writes as text is never captione…
Oct 7, 2026
d9f8817
fix(kiosk): display volume steps are perceptual — each step is 5 dB o…
Oct 7, 2026
3933134
docs(kiosk): call-sign hotwords instead of the name prompt, a one-sou…
Oct 7, 2026
038949f
fix(kiosk): the phone mic goes only once the turn is done and nothing…
Oct 7, 2026
41dc145
fix(kiosk): STT hotwords are a switch that is off by default, sent on…
Oct 7, 2026
cd0df92
fix(kiosk): asking for the state the music is already in sends the wh…
Oct 7, 2026
ae4497d
fix(voice): the repetition-loop filter keeps real repeated speech (a …
Oct 7, 2026
358d4f2
fix(kiosk): a bare 'Play.' with nothing loaded is answered at once ('…
Oct 7, 2026
26b080a
fix(kiosk): each display's stored loudest-volume cap moves once, at b…
Oct 7, 2026
4cd34fe
docs(kiosk): hotwords are an opt-in switch for English displays, long…
Oct 7, 2026
167f9f8
fix(kiosk): while a choice is pending a bare 'Play.' answers it — one…
Oct 7, 2026
65f0c5f
feat(voice): every turn records whether speech-to-text hotwords were …
Oct 7, 2026
701352b
fix(voice): a pretty-printed JSON tool call split after its brace, an…
Oct 7, 2026
0b0cfe3
fix(kiosk): a pause request while the music is already paused is answ…
Oct 7, 2026
35f0ef4
fix(kiosk): each volume step is 10 dB (5 dB was still hard to hear on…
Oct 7, 2026
0c657d2
docs(kiosk): 10 dB volume steps, pause while paused, the sentence's o…
Oct 7, 2026
23d7814
chore(kiosk): kiosk 0.3.6
Oct 7, 2026
7409084
fix(kiosk): volume 0–100 spans −50…0 dB (0 is mute) and each Louder/Q…
Oct 7, 2026
debe750
fix(kiosk): only a trailing 'thank you' or 'thanks' is ignored after …
Oct 7, 2026
a3d031c
test(kiosk): wired tests destroy the server's open sockets on close, …
Oct 7, 2026
1c084a4
docs(kiosk): the volume range and steps
Oct 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bundles/kiosk/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"id": "kiosk",
"name": "Kiosk display",
"version": "0.2.2",
"version": "0.3.6",
"type": "mcp-server",
"author": "Crow",
"category": "hardware",
Expand Down
84 changes: 80 additions & 4 deletions bundles/kiosk/panel/kiosk.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,10 @@ export const CLIENT_SCRIPT = `
if (b.bot !== a.bot && b.bot) { p.bound_bot_id = b.bot; any = true; }
if (b.stt !== a.stt) { p.stt_profile_id = b.stt; any = true; }
if (b.tts !== a.tts) { p.tts_profile_id = b.tts; any = true; }
if (b.fu !== a.fu || b.mem !== a.mem || b.vad !== a.vad || b.sm !== a.sm || b.th !== a.th || b.pf !== a.pf) {
if (b.fu !== a.fu || b.mem !== a.mem || b.vad !== a.vad || b.sm !== a.sm || b.th !== a.th || b.pf !== a.pf || b.mv !== a.mv || b.pml !== a.pml) {
p.kiosk_settings = { follow_up: b.fu, memory_integration: b.mem };
if (b.mv !== a.mv) p.kiosk_settings.max_volume = b.mv;
if (b.pml !== a.pml && b.pml !== null) p.kiosk_settings.pause_media_on_listen = b.pml;
if (b.vad !== a.vad) p.kiosk_settings.vad_hangover_ms = b.vad;
if (b.sm !== a.sm) p.kiosk_settings.stt_model = b.sm;
if (b.th !== a.th) p.kiosk_settings.theme = b.th;
Expand Down Expand Up @@ -164,16 +166,25 @@ export const CLIENT_SCRIPT = `
var pf = pick([['', S.profile_unset], ['pi3', S.profile_pi3], ['phone', S.profile_phone], ['tablet', S.profile_tablet], ['desktop', S.profile_desktop]], ks.profile || '');
var vad = el('input'); vad.type = 'number'; vad.min = '300'; vad.max = '900'; vad.step = '50'; vad.value = String(ks.vad_hangover_ms || 450);
function vadValue() { var n = parseInt(vad.value, 10); return isFinite(n) ? Math.min(900, Math.max(300, n)) : (initial ? initial.vad : (ks.vad_hangover_ms || 450)); }
function current() { return { bot: bot.value, stt: stt.value, tts: tts.value, fu: fu.checked, mem: mem.checked, vad: vadValue(), sm: sm.value, th: th.value, pf: pf.value }; }
var mv = pick([10, 20, 30, 40, 50, 60, 70, 80, 90, 100].map(function (n) { return [String(n), n + '%']; }), String(ks.max_volume || 100));
// F3: shown as it takes effect — the stored choice, else on for a phone or a tablet. Sent only once the operator
// ticks or unticks it (a default is never written); until then it follows the display type chosen here.
var handheld = function (v) { return v === 'phone' || v === 'tablet'; };
var pmlSet = typeof ks.pause_media_on_listen === 'boolean';
var pml = el('input'); pml.type = 'checkbox'; pml.checked = pmlSet ? ks.pause_media_on_listen : handheld(ks.profile);
pml.addEventListener('change', function () { pmlSet = true; });
pf.addEventListener('change', function () { if (!pmlSet) pml.checked = handheld(pf.value); });
function current() { return { bot: bot.value, stt: stt.value, tts: tts.value, fu: fu.checked, mem: mem.checked, vad: vadValue(), sm: sm.value, th: th.value, pf: pf.value, mv: parseInt(mv.value, 10) || 100, pml: pmlSet ? pml.checked : null }; }
var initial = null;
initial = current();
var fit = fitLine(data.bots, bot, function () { return mem.checked; });
mem.addEventListener('change', fit.show);
[[S.bot, bot], [S.stt, stt], [S.stt_model, sm], [S.tts, tts], [S.profile, pf], [S.vad_wait, vad], [S.follow_up, fu], [S.memory, mem], [S.theme, th]].forEach(function (pair) { var l = el('label', null, pair[0]); l.appendChild(pair[1]); card.appendChild(l); if (pair[1] === bot) card.appendChild(fit.node); });
[[S.bot, bot], [S.stt, stt], [S.stt_model, sm], [S.tts, tts], [S.profile, pf], [S.vad_wait, vad], [S.follow_up, fu], [S.memory, mem], [S.theme, th], [S.max_volume, mv], [S.pause_media_on_listen, pml]].forEach(function (pair) { var l = el('label', null, pair[0]); l.appendChild(pair[1]); card.appendChild(l); if (pair[1] === bot) card.appendChild(fit.node); });
card.appendChild(el('p', 'kk-dim', S.vad_wait_hint));
card.appendChild(el('p', 'kk-dim', S.memory_warn + ' ' + S.memory_hint));
card.appendChild(el('p', 'kk-dim', S.profile_hint + (ks.profile_source === 'guessed' ? ' ' + S.profile_guessed : '')));
card.appendChild(el('p', 'kk-dim', S.theme_hint));
card.appendChild(el('p', 'kk-dim', S.pause_media_hint));
var msg = el('span', 'kk-msg');
var save = el('button', 'btn btn-primary btn-sm', S.save); save.type = 'button';
save.addEventListener('click', function () {
Expand Down Expand Up @@ -207,6 +218,65 @@ export const CLIENT_SCRIPT = `
return card;
}

/** Station presets: this instance's own list (name, spoken names, stream address). Saved as a whole; one bad row refuses the save. */
function renderStations() {
var box = document.getElementById('kk-stations'); if (!box) return;
api('GET', '/api/kiosk/admin/stations').then(function (j) {
clear(box);
box.appendChild(el('h2', null, S.stations_title));
box.appendChild(el('p', 'kk-dim', S.stations_intro));
var rows = el('div', 'kk-stations');
var msg = el('p', 'kk-msg');
function row(st) {
var r = el('div', 'kk-station');
var name = el('input'); name.maxLength = 60; name.placeholder = S.station_name; name.value = st.name || '';
var al = el('input'); al.maxLength = 220; al.placeholder = S.station_aliases; al.value = (st.aliases || []).join(', ');
var url = el('input'); url.maxLength = 500; url.placeholder = 'https://'; url.value = st.url || ''; url.type = 'url';
var loc = el('input'); loc.type = 'checkbox'; loc.checked = st.local === true; loc.title = S.station_local_hint;
var locL = el('label', 'kk-local', S.station_local); locL.insertBefore(loc, locL.firstChild);
var res = el('span', 'kk-msg');
var test = el('button', 'btn btn-secondary btn-sm', S.station_test); test.type = 'button';
test.addEventListener('click', function () {
res.textContent = S.station_testing;
api('POST', '/api/kiosk/admin/stations/test', { url: url.value, local: loc.checked }).then(function (t) {
res.textContent = t.ok ? fill(S.station_test_ok, { type: t.content_type || '' }) : (S['station_err_' + t.error] || S.station_err_unreachable);
});
});
var del = el('button', 'btn btn-secondary btn-sm', S.station_remove); del.type = 'button';
del.addEventListener('click', function () { rows.removeChild(r); });
[name, al, url, locL, test, del, res].forEach(function (n) { r.appendChild(n); });
r.read = function () { var v = { name: name.value, aliases: al.value.split(',').map(function (x) { return x.trim(); }).filter(Boolean).slice(0, 5), url: url.value.trim() }; if (loc.checked) v.local = true; return v; };
return r;
}
(j.stations || []).forEach(function (st) { rows.appendChild(row(st)); });
var add = el('button', 'btn btn-secondary btn-sm', S.station_add); add.type = 'button';
add.addEventListener('click', function () { if (rows.children.length < (j.max || 50)) rows.appendChild(row({})); });
var save = el('button', 'btn btn-primary btn-sm', S.save); save.type = 'button';
save.addEventListener('click', function () {
var list = [];
for (var i = 0; i < rows.children.length; i++) { var v = rows.children[i].read(); if (v.name || v.url) list.push(v); }
// A home-network station is checked against its policy (the same Test) before anything is saved.
var local = list.filter(function (x) { return x.local; });
var checks = local.map(function (x) { return api('POST', '/api/kiosk/admin/stations/test', { url: x.url, local: true }); });
Promise.all(checks).then(function (results) {
for (var j = 0; j < results.length; j++) {
if (!results[j].ok) { msg.textContent = fill(S.station_local_refused, { name: local[j].name }) + ' ' + (S['station_err_' + results[j].error] || S.station_err_unreachable); return; }
}
api('POST', '/api/kiosk/admin/stations', { stations: list }).then(function (k) {
msg.textContent = k.ok ? S.saved : k.error === 'local_check_failed' ? fill(S.station_local_refused, { name: k.station || '' }) + ' ' + (S['station_err_' + k.reason] || S.station_err_unreachable)
: k.error === 'command_name' ? fill(S.station_command_name, { word: k.word || '' }) : (S['station_' + k.error] || k.error || '');
if (k.ok) renderStations();
});
});
});
box.appendChild(rows);
// A station saved before command words were refused keeps playing; it is left out of the speech hint and named here.
if (j.command_names && j.command_names.length) box.appendChild(el('p', 'kk-warn', fill(S.station_command_saved, { words: j.command_names.join(', ') })));
var bar = el('div', 'kk-bar'); [add, save, msg].forEach(function (n) { bar.appendChild(n); });
box.appendChild(bar);
});
}

function render(data) {
state = data;
renderPair(data);
Expand All @@ -222,11 +292,12 @@ export const CLIENT_SCRIPT = `
api('GET', '/api/kiosk/admin/displays').then(function (j) {
if (!j.devices || !state) return;
var changed = JSON.stringify(j.pending) !== JSON.stringify(state.pending) || j.devices.length !== state.devices.length;
if (changed && !document.activeElement.closest('#kk-root form, #kk-root section, #kk-dash')) render(j);
if (changed && !document.activeElement.closest('#kk-root form, #kk-root section, #kk-dash, #kk-stations')) render(j);
else state = j;
});
}
load();
renderStations();
window.__kkRefresh = setInterval(refreshPending, 5000);
})();
`;
Expand All @@ -247,6 +318,10 @@ const STYLES = `
.kk-bar { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
.kk-form { display: grid; gap: 4px; max-width: 420px; }
.kk-form input[name=code] { font-size: 1.4rem; letter-spacing: .15em; width: 9ch; }
.kk-station { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 6px 0; }
.kk-station input { min-width: 10ch; flex: 1 1 12ch; }
.kk-station input[type=url] { flex: 2 1 24ch; }
.kk-station .kk-local { margin: 0; gap: 4px; }
.kk-row { font-family: ui-monospace, monospace; font-size: .8rem; margin: 2px 0; }
`;

Expand All @@ -273,6 +348,7 @@ export default {
<div id="kk-pair" class="kk-card"></div>
<div id="kk-dash" class="kk-card" hidden></div>
<div id="kk-devices"></div>
<div id="kk-stations" class="kk-card"></div>
</div>
<script type="application/json" id="kk-strings">${json}</script>
<script>${CLIENT_SCRIPT}<\/script>`;
Expand Down
1 change: 1 addition & 0 deletions bundles/kiosk/panel/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ const runtime = createKioskRuntime({
// Smoke 2026-10-04: the first inference after a whisper start took 8.2 s even with the
// model preloaded, so warm every paired display's STT now (retried until whisper answers).
runtime.bootWarmup().catch(() => {});
runtime.migrateVolumeCaps().catch(() => {}); // rev 7b (M2): old linear caps → the 5 dB scale, once

export default function kioskRouter(dashboardAuth) { return runtime.router(dashboardAuth); }
export function setupWebSocket(server) { return runtime.attachUpgrade(server); }
85 changes: 77 additions & 8 deletions bundles/kiosk/public/audio.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,26 @@
/** Mic capture (AEC/NS/AGC on, spec §7.3) and in-page playback (so Chromium's echo canceller sees it). */
import { playStartPerfTime } from "./metrics.js";

/** The capture worklet is added to an AudioContext once: a phone opens the mic again at every tap (F6). */
const worklets = new WeakSet();
/**
* Re-smoke 2026-10-07 (G12): on a phone the reply's sources sometimes never ended (the output clock stalled
* while Android switched audio mode), so the page held the music and the bird until Stop. The player now
* bounds every reply: past the scheduled end + DRAIN_GRACE_MS it stops what is left and drains (onStall says so);
* an mp3 decode that does not answer within DECODE_MAX_MS is dropped.
*/
export const DRAIN_GRACE_MS = 2000;
/** Review of rev 7 (M1): a reply is cut only once its audio clock has stopped moving — a late but running sink (a Bluetooth
* speaker waking) re-arms the check — and never held past twice its length plus STALL_CAP_EXTRA_MS. */
export const STALL_CAP_EXTRA_MS = 5000;
export const DECODE_MAX_MS = 5000;
export async function openMic(ctx, onFrame) {
const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, channelCount: 1 } });
await ctx.audioWorklet.addModule("/display/assets/pcm-worklet.js");
if (!worklets.has(ctx)) {
try { await ctx.audioWorklet.addModule("/display/assets/pcm-worklet.js"); }
catch (err) { stream.getTracks().forEach((t) => t.stop()); throw err; }
worklets.add(ctx);
}
const src = ctx.createMediaStreamSource(stream);
const node = new AudioWorkletNode(ctx, "pcm-capture", { numberOfInputs: 1, numberOfOutputs: 0 });
node.port.onmessage = (e) => onFrame(e.data.pcm, e.data.rms, performance.now());
Expand All @@ -15,6 +32,31 @@ export async function openMic(ctx, onFrame) {
};
}

/**
* One microphone at a time (review H3): every caller of acquire() during an open waits for the SAME open, so a
* second tap can never start a second stream. release() during an open closes what that open returns. open() →
* Promise<mic with close()>; now() → ms (the open time is kept for the turn's metrics).
*/
export function createMicGate(open, now = () => performance.now()) {
let mic = null, pending = null, dropped = false, lastMs = null;
return {
get current() { return mic; },
opening: () => pending !== null,
acquire() {
if (mic) return Promise.resolve(mic);
if (pending) { dropped = false; return pending; }
dropped = false;
const t0 = now();
pending = Promise.resolve().then(open).then(
(m) => { pending = null; if (dropped) { try { m.close(); } catch {} return null; } mic = m; lastMs = now() - t0; return m; },
(err) => { pending = null; throw err; });
return pending;
},
release() { if (pending) dropped = true; if (mic) { const m = mic; mic = null; try { m.close(); } catch {} } },
takeOpenMs() { const v = lastMs; lastMs = null; return v; },
};
}

/**
* Sequential playback of the server's sentence buffers. `playing` covers buffers still
* being decoded (the server's idle can arrive before an mp3 decode finishes, ruling F3).
Expand All @@ -23,13 +65,13 @@ export async function openMic(ctx, onFrame) {
* frames still in flight are dropped until the next begin() (tts_start). onFirstPlay(at, tag) carries the
* tag given to begin() so the page attributes the play time to the right turn.
*/
export function createPlayer(ctx, { onLevel, onFirstPlay, onDrained }) {
export function createPlayer(ctx, { onLevel, onFirstPlay, onDrained, onStall = () => {} }) {
const analyser = ctx.createAnalyser();
analyser.fftSize = 256;
analyser.connect(ctx.destination);
const data = new Uint8Array(analyser.fftSize);
const sources = new Set();
let codec = "pcm", rate = 24000, nextAt = 0, first = true, tag = null, level = null, chain = Promise.resolve(), gen = 0, pending = 0, muted = false;
let codec = "pcm", rate = 24000, nextAt = 0, first = true, tag = null, level = null, chain = Promise.resolve(), gen = 0, pending = 0, muted = false, watch = null, lastClock = 0, capAt = 0, audioMs = 0;
const startLevel = () => {
if (level) return;
level = setInterval(() => { // ~15 Hz beak level, only while audio plays
Expand All @@ -40,7 +82,30 @@ export function createPlayer(ctx, { onLevel, onFirstPlay, onDrained }) {
}, 66);
};
const stopLevel = () => { clearInterval(level); level = null; onLevel(0); };
const maybeDrained = () => { if (!sources.size && !pending) { stopLevel(); onDrained(); } };
const maybeDrained = () => { if (!sources.size && !pending) { clearTimeout(watch); watch = null; capAt = 0; stopLevel(); onDrained(); } };
const stopAll = () => { for (const s of sources) { s.onended = null; try { s.stop(); } catch {} } sources.clear(); };
const left = () => Math.max(0, (nextAt - ctx.currentTime) * 1000);
// Looked at past the scheduled end + DRAIN_GRACE_MS: a clock that moved since the last look gets another look (M1).
const check = () => {
watch = null;
if (!sources.size) return;
const moved = ctx.currentTime > lastClock;
lastClock = ctx.currentTime;
if (moved && Date.now() < capAt) { watch = setTimeout(check, left() + DRAIN_GRACE_MS); return; }
onStall({ left: sources.size, ctxState: String(ctx.state || ""), clockMoved: moved });
stopAll(); nextAt = 0;
maybeDrained();
};
const arm = () => {
clearTimeout(watch);
lastClock = ctx.currentTime;
capAt = Math.max(capAt, Date.now() + 2 * left() + STALL_CAP_EXTRA_MS);
watch = setTimeout(check, left() + DRAIN_GRACE_MS);
};
const decode = (buf) => new Promise((res, rej) => {
const cap = setTimeout(() => rej(new Error("decode timeout")), DECODE_MAX_MS);
ctx.decodeAudioData(buf.slice(0)).then((ab) => { clearTimeout(cap); res(ab); }, (err) => { clearTimeout(cap); rej(err); });
});
async function play(buf, g) {
let ab;
if (codec === "pcm") {
Expand All @@ -49,7 +114,7 @@ export function createPlayer(ctx, { onLevel, onFirstPlay, onDrained }) {
const ch = ab.getChannelData(0);
for (let i = 0; i < i16.length; i++) ch[i] = i16[i] / 32768;
} else {
ab = await ctx.decodeAudioData(buf.slice(0));
ab = await decode(buf);
}
if (g !== gen) return;
const src = ctx.createBufferSource();
Expand All @@ -58,8 +123,10 @@ export function createPlayer(ctx, { onLevel, onFirstPlay, onDrained }) {
const when = Math.max(ctx.currentTime + 0.02, nextAt);
src.start(when);
nextAt = when + ab.duration;
audioMs += ab.duration * 1000;
sources.add(src);
src.onended = () => { sources.delete(src); maybeDrained(); };
arm();
startLevel(); // every start: a drained inter-sentence gap stopped it
if (first) {
first = false;
Expand All @@ -68,7 +135,7 @@ export function createPlayer(ctx, { onLevel, onFirstPlay, onDrained }) {
}
return {
// nextAt is NOT reset: an announcement that follows a turn queues after it instead of overlapping.
begin(c, sr, t = null) { codec = c === "mp3" ? "mp3" : "pcm"; rate = sr || 24000; first = true; tag = t; muted = false; },
begin(c, sr, t = null) { codec = c === "mp3" ? "mp3" : "pcm"; rate = sr || 24000; first = true; tag = t; muted = false; audioMs = 0; },
push(buf) {
if (muted) return; // in-flight audio from before a barge (until the next tts_start)
const g = gen;
Expand All @@ -83,10 +150,12 @@ export function createPlayer(ctx, { onLevel, onFirstPlay, onDrained }) {
gen++;
muted = true;
pending = 0;
for (const s of sources) { s.onended = null; try { s.stop(); } catch {} }
sources.clear(); nextAt = 0; chain = Promise.resolve(); stopLevel();
clearTimeout(watch); watch = null; capAt = 0;
stopAll(); nextAt = 0; chain = Promise.resolve(); stopLevel();
},
get playing() { return sources.size > 0 || pending > 0; },
get sampling() { return level != null; },
/** r7b H3: audio scheduled since the last begin() (ms; counts only). */
get audioMs() { return Math.round(audioMs); },
};
}
Loading
Loading