/*
* profile_audio.js — User's audio device selection (browser-side).
*
* RX output (speaker/headphones where the user hears the receive audio) —
* setSinkId on the global audioCtx, saved in localStorage['ham_audio_sinkId'].
* TX input (microphone) — saved in localStorage['ham_audio_micId'],
* used by ws.js/_txMic and the tx_eq.js monitor.
*/
window.ProfileAudio = (function() {
let _testCtx = null;
let _meterStream = null;
let _meterRaf = null;
// Filter: exclude virtual/cable devices from the default suggestion
function _isVirtual(label) {
const l = (label || '').toLowerCase();
return l.includes('virtual') || l.includes('cable') ||
l.includes('vb-audio') || l.includes('voicemeeter');
}
async function load() {
try {
// Request microphone access to get device labels
// (without permission, enumerateDevices returns empty labels)
try {
const s = await navigator.mediaDevices.getUserMedia({ audio: true });
s.getTracks().forEach(t => t.stop());
} catch(e) { /* user declined — show what we can */ }
const devs = await navigator.mediaDevices.enumerateDevices();
const outputs = devs.filter(d => d.kind === 'audiooutput');
const inputs = devs.filter(d => d.kind === 'audioinput');
const outSel = document.getElementById('profile-audio-out');
const inSel = document.getElementById('profile-audio-in');
const savedOut = localStorage.getItem('ham_audio_sinkId') || '';
const savedIn = localStorage.getItem('ham_audio_micId') || '';
if (outSel) {
outSel.innerHTML = outputs.map(d => {
const virt = _isVirtual(d.label) ? ' ⚠' : '';
const sel = d.deviceId === savedOut ? 'selected' : '';
return ``;
}).join('') || ``;
}
if (inSel) {
inSel.innerHTML = inputs.map(d => {
const virt = _isVirtual(d.label) ? ' ⚠' : '';
const sel = d.deviceId === savedIn ? 'selected' : '';
return ``;
}).join('') || ``;
}
} catch(e) {
console.warn('[profile-audio] load error:', e);
}
}
function setOutput(deviceId) {
if (!deviceId) return;
localStorage.setItem('ham_audio_sinkId', deviceId);
// Apply immediately on the global audioCtx if it exists
const ctx = window.audioCtx || window.AppAudio?.ctx;
if (ctx && ctx.setSinkId) {
ctx.setSinkId(deviceId).catch(e => console.warn('[profile-audio] setSinkId:', e));
}
window.UI?.showToast?.(I18n.t('profile_toast_speaker_saved'), 'info');
}
function setInput(deviceId) {
if (!deviceId) return;
localStorage.setItem('ham_audio_micId', deviceId);
window.UI?.showToast?.(I18n.t('profile_toast_mic_saved'), 'info');
}
// Speaker test — play a short tone (1 kHz, 0.5s) on the selected output
async function testOutput() {
try {
if (_testCtx) { try { _testCtx.close(); } catch(e){} }
_testCtx = new (window.AudioContext || window.webkitAudioContext)();
const sinkId = localStorage.getItem('ham_audio_sinkId');
if (sinkId && _testCtx.setSinkId) {
try { await _testCtx.setSinkId(sinkId); } catch(e) {}
}
const osc = _testCtx.createOscillator();
const gain = _testCtx.createGain();
osc.frequency.value = 1000;
osc.type = 'sine';
gain.gain.setValueAtTime(0.0001, _testCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.3, _testCtx.currentTime + 0.05);
gain.gain.exponentialRampToValueAtTime(0.0001, _testCtx.currentTime + 0.5);
osc.connect(gain); gain.connect(_testCtx.destination);
osc.start();
osc.stop(_testCtx.currentTime + 0.5);
window.UI?.showToast?.(I18n.t('profile_toast_test_tone'), 'info');
} catch(e) {
window.UI?.showToast?.(I18n.t('profile_toast_speaker_test_err') + e.message, 'error');
}
}
// Microphone test — show the level for 5s on the selected input
async function testInput() {
const meter = document.getElementById('profile-audio-meter');
const level = document.getElementById('profile-audio-level');
const hint = document.getElementById('profile-audio-hint');
if (!meter || !level) return;
// Stop the previous test if it's still running
stopMeter();
const micId = localStorage.getItem('ham_audio_micId');
try {
// Must match _txMic's constraints in ws.js exactly (the REAL TX
// capture) - reported live as "test shows a much stronger signal
// than what actually reaches the radio". Root cause: this used to
// just pass `true`/deviceId with no explicit constraints, so
// Chrome's own defaults applied - autoGainControl:true in
// particular auto-boosts a quiet mic, showing a level the real
// (AGC-off, raw) TX path never produces.
const audioConstraint = {
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
};
if (micId) audioConstraint.deviceId = { exact: micId };
const constraints = { audio: audioConstraint };
_meterStream = await navigator.mediaDevices.getUserMedia(constraints);
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const src = ctx.createMediaStreamSource(_meterStream);
const analyser = ctx.createAnalyser();
analyser.fftSize = 512;
src.connect(analyser);
const data = new Uint8Array(analyser.fftSize);
meter.style.display = 'block';
let peak = 0;
const startTs = Date.now();
function tick() {
analyser.getByteTimeDomainData(data);
// Compute RMS
let sum = 0;
for (let i = 0; i < data.length; i++) {
const v = (data[i] - 128) / 128;
sum += v * v;
}
const rms = Math.sqrt(sum / data.length);
const pct = Math.min(100, rms * 300);
level.style.width = pct + '%';
if (pct > peak) peak = pct;
const elapsed = (Date.now() - startTs) / 1000;
if (elapsed < 5) {
if (hint) hint.textContent = I18n.t('profile_mic_hint_speaking').replace('{s}', (5-elapsed).toFixed(0)).replace('{peak}', peak.toFixed(0));
_meterRaf = requestAnimationFrame(tick);
} else {
// Summary
if (hint) {
if (peak < 5) {
hint.textContent = I18n.t('profile_mic_hint_low').replace('{peak}', peak.toFixed(0));
hint.style.color = 'var(--red)';
} else if (peak > 90) {
hint.textContent = I18n.t('profile_mic_hint_high').replace('{peak}', peak.toFixed(0));
hint.style.color = 'var(--amber)';
} else {
hint.textContent = I18n.t('profile_mic_hint_ok').replace('{peak}', peak.toFixed(0));
hint.style.color = 'var(--green)';
}
}
level.style.width = '0%';
stopMeter();
ctx.close();
}
}
tick();
} catch(e) {
if (hint) { hint.textContent = I18n.t('profile_mic_hint_error') + e.message; hint.style.color = 'var(--red)'; }
window.UI?.showToast?.(I18n.t('profile_toast_mic_open_err') + e.message, 'error');
}
}
function stopMeter() {
if (_meterRaf) { cancelAnimationFrame(_meterRaf); _meterRaf = null; }
if (_meterStream) {
_meterStream.getTracks().forEach(t => t.stop());
_meterStream = null;
}
}
function _escape(s) {
return String(s || '').replace(/[&<>"']/g, m => ({
'&':'&','<':'<','>':'>','"':'"',"'":'''
}[m]));
}
return { load, setOutput, setInput, testOutput, testInput, stopMeter };
})();