This commit is contained in:
Flatlogic Bot 2026-07-03 20:03:39 +00:00
parent 4f209aac8e
commit 2447ac66a4
10 changed files with 997 additions and 1 deletions

View File

@ -34,6 +34,7 @@
<ul class="navbar-nav ms-auto align-items-lg-center gap-lg-2">
<li class="nav-item"><a class="nav-link" href="{% url 'home' %}#workflow">Workflow</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'call_list' %}">History</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'audio_capture' %}">Audio</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'webhook_list' %}">Webhooks</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/">Admin</a></li>
<li class="nav-item"><a class="btn btn-signal ms-lg-2" href="{% url 'call_create' %}">New pilot call</a></li>

View File

@ -0,0 +1,132 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Computer Audio Capture | {{ project_name }}{% endblock %}
{% block meta_description %}Capture microphone input and browser-approved tab or system audio locally before connecting the workflow to Ytel.{% endblock %}
{% block content %}
<header class="page-hero audio-hero">
<div class="container">
<div class="row align-items-end g-4">
<div class="col-lg-8">
<span class="eyebrow">Computer audio first</span>
<h1 class="page-title mt-3 mb-3">Capture mic input and shared output audio in the browser.</h1>
<p class="hero-copy mb-0">This prototype lets you test call audio capture before the Ytel-specific integration. Your browser will ask permission for the microphone and for a tab/window/screen with audio.</p>
</div>
<div class="col-lg-4">
<aside class="glass-card audio-guardrail" aria-label="Audio capture guardrails">
<strong>Privacy guardrail</strong>
<p>No silent background listening. Audio stays local on this page until we add an explicit upload or transcription step.</p>
</aside>
</div>
</div>
</div>
</header>
<section class="section-pad">
<div class="container">
<div class="row g-4">
<div class="col-xl-8">
<article class="audio-console glass-card" aria-labelledby="capture-title">
<div class="panel-heading">
<span>Step 1</span>
<h2 id="capture-title">Connect audio sources</h2>
</div>
<div class="audio-status-grid mb-4" aria-live="polite">
<div class="audio-source-card" data-source-card="mic">
<span class="status-pill muted" id="mic-status">Not connected</span>
<h3>Microphone input</h3>
<p>Captures your voice or headset microphone.</p>
<div class="audio-meter" aria-hidden="true"><span id="mic-meter"></span></div>
</div>
<div class="audio-source-card" data-source-card="system">
<span class="status-pill muted" id="system-status">Not connected</span>
<h3>Computer/tab output</h3>
<p>Use browser screen sharing and select a source with audio enabled.</p>
<div class="audio-meter" aria-hidden="true"><span id="system-meter"></span></div>
</div>
<div class="audio-source-card" data-source-card="mix">
<span class="status-pill muted" id="mix-status">Waiting</span>
<h3>Combined recording mix</h3>
<p>Records whichever approved sources are active.</p>
<div class="audio-meter" aria-hidden="true"><span id="mix-meter"></span></div>
</div>
</div>
<div class="audio-actions">
<button class="btn btn-signal" type="button" id="connect-mic">Enable microphone</button>
<button class="btn btn-outline-signal" type="button" id="connect-system">Share computer/tab audio</button>
<button class="btn btn-signal" type="button" id="start-recording" disabled>Start local recording</button>
<button class="btn btn-outline-signal danger" type="button" id="stop-recording" disabled>Stop recording</button>
<button class="btn btn-light reset-btn" type="button" id="reset-audio" disabled>Reset</button>
</div>
<div class="recording-result mt-4" id="recording-result" hidden>
<div>
<span class="small-label">Local test file</span>
<h3 class="mb-2">Recording ready</h3>
<p class="text-muted-custom mb-3">Preview it here or download it. Next, we can add secure upload + transcription.</p>
</div>
<audio controls id="recording-preview"></audio>
<a class="btn btn-outline-signal" id="download-recording" download="reliefsignal-audio-test.webm">Download WebM</a>
</div>
<video id="shared-video-preview" playsinline muted hidden></video>
</article>
</div>
<div class="col-xl-4">
<aside class="detail-panel h-100" aria-labelledby="how-title">
<div class="panel-heading">
<span>Notes</span>
<h2 id="how-title">How this works</h2>
</div>
<ul class="readiness-list audio-note-list">
<li><span>Mic</span><strong>Browser permission</strong></li>
<li><span>Output audio</span><strong>Share tab/screen audio</strong></li>
<li><span>Storage</span><strong>Local browser blob</strong></li>
<li><span>Next step</span><strong>Upload/transcribe</strong></li>
</ul>
<div class="audio-tip mt-4">
<strong>Best first test</strong>
<p>Open the Ytel webphone or any sample call audio in a browser tab, then choose that tab when clicking <em>Share computer/tab audio</em>. In Chrome, make sure the share dialog has audio enabled.</p>
</div>
<div class="audio-tip subtle mt-3">
<strong>Browser limitation</strong>
<p>Some browsers only share tab audio, not full device output. If system audio is not available, use a Chrome tab source or a virtual audio device later.</p>
</div>
</aside>
</div>
</div>
<div class="row g-4 mt-1">
<div class="col-lg-7">
<section class="detail-panel" aria-labelledby="event-log-title">
<div class="panel-heading">
<span>Live</span>
<h2 id="event-log-title">Capture log</h2>
</div>
<ol class="audio-log" id="audio-log" aria-live="polite">
<li>Ready. Connect the microphone or shared audio source to begin.</li>
</ol>
</section>
</div>
<div class="col-lg-5">
<section class="detail-panel" aria-labelledby="next-title">
<div class="panel-heading">
<span>After test</span>
<h2 id="next-title">Recommended next slice</h2>
</div>
<p class="text-muted-custom">Once capture works on your computer, the next practical build is a secure upload/transcription endpoint that turns the local recording into a pilot call transcript.</p>
<a class="btn btn-signal" href="{% url 'call_create' %}">Open intake form</a>
</section>
</div>
</div>
</div>
</section>
{% endblock %}
{% block head %}
<script src="{% static 'js/audio-capture.js' %}?v={{ deployment_timestamp }}" defer></script>
{% endblock %}

View File

@ -1,12 +1,13 @@
from django.urls import path
from .views import call_create, call_detail, call_list, home, webhook_generic, webhook_list
from .views import audio_capture, call_create, call_detail, call_list, home, webhook_generic, webhook_list
urlpatterns = [
path('', home, name='home'),
path('calls/', call_list, name='call_list'),
path('calls/new/', call_create, name='call_create'),
path('calls/<int:pk>/', call_detail, name='call_detail'),
path('audio/', audio_capture, name='audio_capture'),
path('webhooks/', webhook_list, name='webhook_list'),
path('webhooks/generic/', webhook_generic, name='webhook_generic'),
]

View File

@ -99,6 +99,14 @@ def call_detail(request, pk):
def audio_capture(request):
context = {
'project_name': 'ReliefSignal',
'meta_description': 'Browser-based computer audio capture prototype for microphone input, shared tab/system output audio, local level monitoring, and local recording tests.',
}
return render(request, 'core/audio_capture.html', context)
def webhook_list(request):
events = WebhookEvent.objects.all()[:25]
context = {

View File

@ -332,3 +332,100 @@ body {
}
.endpoint-code code { color: #ecfff9; font-size: .88rem; }
.webhook-preview { display: inline-block; max-width: 18rem; }
.audio-hero {
background: radial-gradient(circle at 82% 18%, rgba(20,184,166,.22), transparent 28%), linear-gradient(135deg, #fff8ed 0%, #ecfff9 100%);
}
.audio-guardrail {
border-radius: 1.4rem;
padding: 1.25rem;
}
.audio-guardrail strong, .audio-tip strong { display: block; color: var(--rs-ink); margin-bottom: .35rem; }
.audio-guardrail p, .audio-tip p { color: var(--rs-muted); margin-bottom: 0; line-height: 1.6; }
.audio-console { border-radius: 1.8rem; padding: 1.45rem; }
.audio-status-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem;
}
.audio-source-card {
border: 1px solid var(--rs-line);
border-radius: 1.35rem;
background: #fffdf8;
padding: 1rem;
}
.audio-source-card h3 {
margin: .85rem 0 .35rem;
font-size: 1.05rem;
font-weight: 900;
letter-spacing: -.025em;
color: var(--rs-ink);
}
.audio-source-card p { min-height: 3.2rem; color: var(--rs-muted); margin-bottom: .85rem; }
.status-pill.muted { color: var(--rs-muted); background: #f3f4f1; }
.status-pill.active { color: #07564f; background: var(--rs-mint); }
.status-pill.recording { color: #8a3d00; background: #fff0d8; }
.audio-meter {
height: .78rem;
overflow: hidden;
border-radius: 999px;
background: rgba(16,34,31,.08);
}
.audio-meter span {
display: block;
width: 0%;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, var(--rs-primary), var(--rs-accent), var(--rs-secondary));
transition: width .08s linear;
}
.audio-actions { display: flex; flex-wrap: wrap; gap: .75rem; }
.btn-outline-signal {
color: var(--rs-primary);
border: 1px solid rgba(15,118,110,.28);
background: rgba(255,255,255,.74);
font-weight: 900;
border-radius: 999px;
padding: .8rem 1.05rem;
}
.btn-outline-signal:hover { color: #fff; background: var(--rs-primary); border-color: var(--rs-primary); }
.btn-outline-signal.danger { color: #9a3412; border-color: rgba(249,115,22,.35); }
.btn-outline-signal.danger:hover { color: #fff; background: var(--rs-secondary); border-color: var(--rs-secondary); }
.reset-btn { border-radius: 999px; padding: .8rem 1.05rem; font-weight: 900; }
.recording-result {
display: grid;
gap: 1rem;
border-radius: 1.35rem;
border: 1px solid rgba(20,184,166,.22);
background: #ecfff9;
padding: 1rem;
}
.recording-result audio { width: 100%; }
.audio-note-list strong { max-width: 12rem; }
.audio-tip {
border-radius: 1.1rem;
padding: 1rem;
background: #fffdf8;
border: 1px solid var(--rs-line);
}
.audio-tip.subtle { background: #f7fbf7; }
.audio-log {
list-style-position: inside;
display: grid;
gap: .7rem;
margin: 0;
padding: 0;
color: var(--rs-muted);
font-weight: 700;
}
.audio-log li {
border-bottom: 1px solid rgba(16,34,31,.08);
padding-bottom: .7rem;
}
.audio-log li:last-child { border-bottom: 0; padding-bottom: 0; }
@media (max-width: 991px) {
.audio-status-grid { grid-template-columns: 1fr; }
.audio-source-card p { min-height: auto; }
}

330
static/js/audio-capture.js Normal file
View File

@ -0,0 +1,330 @@
(() => {
const state = {
audioContext: null,
destination: null,
micStream: null,
systemStream: null,
mixedStream: null,
recorder: null,
chunks: [],
animationFrame: null,
analyzers: {},
downloadUrl: null,
};
const els = {
connectMic: document.getElementById('connect-mic'),
connectSystem: document.getElementById('connect-system'),
start: document.getElementById('start-recording'),
stop: document.getElementById('stop-recording'),
reset: document.getElementById('reset-audio'),
micStatus: document.getElementById('mic-status'),
systemStatus: document.getElementById('system-status'),
mixStatus: document.getElementById('mix-status'),
micMeter: document.getElementById('mic-meter'),
systemMeter: document.getElementById('system-meter'),
mixMeter: document.getElementById('mix-meter'),
log: document.getElementById('audio-log'),
result: document.getElementById('recording-result'),
preview: document.getElementById('recording-preview'),
download: document.getElementById('download-recording'),
sharedVideo: document.getElementById('shared-video-preview'),
};
if (!navigator.mediaDevices || !window.MediaRecorder) {
log('This browser does not support the required MediaDevices/MediaRecorder APIs. Try current Chrome or Edge over HTTPS.');
[els.connectMic, els.connectSystem, els.start].forEach((button) => {
if (button) button.disabled = true;
});
return;
}
els.connectMic?.addEventListener('click', connectMicrophone);
els.connectSystem?.addEventListener('click', connectSystemAudio);
els.start?.addEventListener('click', startRecording);
els.stop?.addEventListener('click', stopRecording);
els.reset?.addEventListener('click', resetAudio);
window.addEventListener('beforeunload', cleanupTracks);
async function ensureAudioGraph() {
if (state.audioContext) return;
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
state.audioContext = new AudioContextClass();
state.destination = state.audioContext.createMediaStreamDestination();
state.mixedStream = state.destination.stream;
state.analyzers.mix = createAnalyser();
const mixSource = state.audioContext.createMediaStreamSource(state.mixedStream);
mixSource.connect(state.analyzers.mix);
updateStatus(els.mixStatus, 'Waiting', 'muted');
startMeters();
}
async function connectMicrophone() {
try {
await ensureAudioGraph();
await state.audioContext.resume();
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
});
replaceStream('micStream', stream);
connectStream(stream, 'mic');
updateStatus(els.micStatus, 'Connected', 'active');
log('Microphone connected.');
refreshButtons();
} catch (error) {
handleCaptureError('microphone', error);
}
}
async function connectSystemAudio() {
try {
await ensureAudioGraph();
await state.audioContext.resume();
const stream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: {
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
},
});
if (!stream.getAudioTracks().length) {
stopStream(stream);
throw new Error('No audio track was shared. Reopen sharing and enable tab/system audio.');
}
replaceStream('systemStream', stream);
connectStream(stream, 'system');
els.sharedVideo.srcObject = stream;
els.sharedVideo.play().catch(() => {});
updateStatus(els.systemStatus, 'Connected', 'active');
log('Computer/tab audio connected. Keep the shared source open while recording.');
stream.getTracks().forEach((track) => {
track.addEventListener('ended', () => {
if (state.systemStream === stream) {
updateStatus(els.systemStatus, 'Stopped', 'muted');
log('Shared computer/tab audio stopped by the browser.');
state.systemStream = null;
refreshButtons();
}
});
});
refreshButtons();
} catch (error) {
handleCaptureError('computer/tab audio', error);
}
}
function connectStream(stream, sourceName) {
const audioTracks = stream.getAudioTracks();
if (!audioTracks.length) return;
const audioOnlyStream = new MediaStream(audioTracks);
const source = state.audioContext.createMediaStreamSource(audioOnlyStream);
const analyser = createAnalyser();
state.analyzers[sourceName] = analyser;
source.connect(analyser);
source.connect(state.destination);
updateStatus(els.mixStatus, 'Mix ready', 'active');
}
function createAnalyser() {
const analyser = state.audioContext.createAnalyser();
analyser.fftSize = 256;
analyser.smoothingTimeConstant = 0.78;
return analyser;
}
function startMeters() {
if (state.animationFrame) return;
const data = new Uint8Array(128);
const draw = () => {
drawMeter(state.analyzers.mic, els.micMeter, data);
drawMeter(state.analyzers.system, els.systemMeter, data);
drawMeter(state.analyzers.mix, els.mixMeter, data);
state.animationFrame = requestAnimationFrame(draw);
};
draw();
}
function drawMeter(analyser, element, data) {
if (!element) return;
if (!analyser) {
element.style.width = '0%';
return;
}
analyser.getByteTimeDomainData(data);
let sum = 0;
for (const value of data) {
const centered = value - 128;
sum += centered * centered;
}
const rms = Math.sqrt(sum / data.length);
const width = Math.min(100, Math.max(3, rms * 4.6));
element.style.width = `${width}%`;
}
function startRecording() {
if (!state.mixedStream || !state.mixedStream.getAudioTracks().length) {
log('Connect at least one audio source before recording.');
return;
}
cleanupRecordingUrl();
state.chunks = [];
const options = pickRecorderOptions();
state.recorder = new MediaRecorder(state.mixedStream, options);
state.recorder.addEventListener('dataavailable', (event) => {
if (event.data && event.data.size > 0) state.chunks.push(event.data);
});
state.recorder.addEventListener('stop', () => {
const mimeType = state.recorder?.mimeType || 'audio/webm';
const blob = new Blob(state.chunks, { type: mimeType });
state.downloadUrl = URL.createObjectURL(blob);
els.preview.src = state.downloadUrl;
els.download.href = state.downloadUrl;
els.download.download = `reliefsignal-audio-test-${new Date().toISOString().replace(/[:.]/g, '-')}.webm`;
els.result.hidden = false;
log(`Recording ready (${formatBytes(blob.size)}).`);
refreshButtons();
});
state.recorder.start(1000);
updateStatus(els.mixStatus, 'Recording', 'recording');
log('Local recording started.');
refreshButtons();
}
function stopRecording() {
if (state.recorder && state.recorder.state !== 'inactive') {
state.recorder.stop();
updateStatus(els.mixStatus, 'Mix ready', hasAnySource() ? 'active' : 'muted');
log('Recording stopped.');
refreshButtons();
}
}
function resetAudio() {
if (state.recorder && state.recorder.state !== 'inactive') {
state.recorder.stop();
}
cleanupTracks();
cleanupRecordingUrl();
state.micStream = null;
state.systemStream = null;
state.mixedStream = null;
state.destination = null;
state.analyzers = {};
state.audioContext?.close().catch(() => {});
state.audioContext = null;
state.recorder = null;
state.chunks = [];
if (state.animationFrame) cancelAnimationFrame(state.animationFrame);
state.animationFrame = null;
els.preview.removeAttribute('src');
els.download.removeAttribute('href');
els.result.hidden = true;
els.sharedVideo.srcObject = null;
updateStatus(els.micStatus, 'Not connected', 'muted');
updateStatus(els.systemStatus, 'Not connected', 'muted');
updateStatus(els.mixStatus, 'Waiting', 'muted');
[els.micMeter, els.systemMeter, els.mixMeter].forEach((meter) => {
if (meter) meter.style.width = '0%';
});
log('Audio capture reset.');
refreshButtons();
}
function refreshButtons() {
const recording = state.recorder && state.recorder.state === 'recording';
const hasSource = hasAnySource();
els.start.disabled = !hasSource || recording;
els.stop.disabled = !recording;
els.reset.disabled = !hasSource && !state.downloadUrl;
els.connectMic.disabled = Boolean(state.micStream) || recording;
els.connectSystem.disabled = Boolean(state.systemStream) || recording;
}
function hasAnySource() {
return Boolean(
state.micStream?.getAudioTracks().some((track) => track.readyState === 'live') ||
state.systemStream?.getAudioTracks().some((track) => track.readyState === 'live')
);
}
function pickRecorderOptions() {
const candidates = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/mp4',
];
const mimeType = candidates.find((type) => MediaRecorder.isTypeSupported(type));
return mimeType ? { mimeType } : {};
}
function replaceStream(key, stream) {
if (state[key]) stopStream(state[key]);
state[key] = stream;
}
function stopStream(stream) {
stream?.getTracks().forEach((track) => track.stop());
}
function cleanupTracks() {
stopStream(state.micStream);
stopStream(state.systemStream);
}
function cleanupRecordingUrl() {
if (state.downloadUrl) {
URL.revokeObjectURL(state.downloadUrl);
state.downloadUrl = null;
}
}
function updateStatus(element, label, mode) {
if (!element) return;
element.textContent = label;
element.classList.remove('active', 'muted', 'recording');
element.classList.add(mode);
}
function log(message) {
if (!els.log) return;
const item = document.createElement('li');
item.textContent = `${new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })} — ${message}`;
els.log.prepend(item);
}
function handleCaptureError(label, error) {
const message = error?.name === 'NotAllowedError'
? `Permission was not granted for ${label}.`
: error?.message || `Could not connect ${label}.`;
log(message);
}
function formatBytes(bytes) {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
return `${(bytes / Math.pow(1024, index)).toFixed(index ? 1 : 0)} ${units[index]}`;
}
})();

View File

@ -332,3 +332,100 @@ body {
}
.endpoint-code code { color: #ecfff9; font-size: .88rem; }
.webhook-preview { display: inline-block; max-width: 18rem; }
.audio-hero {
background: radial-gradient(circle at 82% 18%, rgba(20,184,166,.22), transparent 28%), linear-gradient(135deg, #fff8ed 0%, #ecfff9 100%);
}
.audio-guardrail {
border-radius: 1.4rem;
padding: 1.25rem;
}
.audio-guardrail strong, .audio-tip strong { display: block; color: var(--rs-ink); margin-bottom: .35rem; }
.audio-guardrail p, .audio-tip p { color: var(--rs-muted); margin-bottom: 0; line-height: 1.6; }
.audio-console { border-radius: 1.8rem; padding: 1.45rem; }
.audio-status-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem;
}
.audio-source-card {
border: 1px solid var(--rs-line);
border-radius: 1.35rem;
background: #fffdf8;
padding: 1rem;
}
.audio-source-card h3 {
margin: .85rem 0 .35rem;
font-size: 1.05rem;
font-weight: 900;
letter-spacing: -.025em;
color: var(--rs-ink);
}
.audio-source-card p { min-height: 3.2rem; color: var(--rs-muted); margin-bottom: .85rem; }
.status-pill.muted { color: var(--rs-muted); background: #f3f4f1; }
.status-pill.active { color: #07564f; background: var(--rs-mint); }
.status-pill.recording { color: #8a3d00; background: #fff0d8; }
.audio-meter {
height: .78rem;
overflow: hidden;
border-radius: 999px;
background: rgba(16,34,31,.08);
}
.audio-meter span {
display: block;
width: 0%;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, var(--rs-primary), var(--rs-accent), var(--rs-secondary));
transition: width .08s linear;
}
.audio-actions { display: flex; flex-wrap: wrap; gap: .75rem; }
.btn-outline-signal {
color: var(--rs-primary);
border: 1px solid rgba(15,118,110,.28);
background: rgba(255,255,255,.74);
font-weight: 900;
border-radius: 999px;
padding: .8rem 1.05rem;
}
.btn-outline-signal:hover { color: #fff; background: var(--rs-primary); border-color: var(--rs-primary); }
.btn-outline-signal.danger { color: #9a3412; border-color: rgba(249,115,22,.35); }
.btn-outline-signal.danger:hover { color: #fff; background: var(--rs-secondary); border-color: var(--rs-secondary); }
.reset-btn { border-radius: 999px; padding: .8rem 1.05rem; font-weight: 900; }
.recording-result {
display: grid;
gap: 1rem;
border-radius: 1.35rem;
border: 1px solid rgba(20,184,166,.22);
background: #ecfff9;
padding: 1rem;
}
.recording-result audio { width: 100%; }
.audio-note-list strong { max-width: 12rem; }
.audio-tip {
border-radius: 1.1rem;
padding: 1rem;
background: #fffdf8;
border: 1px solid var(--rs-line);
}
.audio-tip.subtle { background: #f7fbf7; }
.audio-log {
list-style-position: inside;
display: grid;
gap: .7rem;
margin: 0;
padding: 0;
color: var(--rs-muted);
font-weight: 700;
}
.audio-log li {
border-bottom: 1px solid rgba(16,34,31,.08);
padding-bottom: .7rem;
}
.audio-log li:last-child { border-bottom: 0; padding-bottom: 0; }
@media (max-width: 991px) {
.audio-status-grid { grid-template-columns: 1fr; }
.audio-source-card p { min-height: auto; }
}

View File

@ -0,0 +1,330 @@
(() => {
const state = {
audioContext: null,
destination: null,
micStream: null,
systemStream: null,
mixedStream: null,
recorder: null,
chunks: [],
animationFrame: null,
analyzers: {},
downloadUrl: null,
};
const els = {
connectMic: document.getElementById('connect-mic'),
connectSystem: document.getElementById('connect-system'),
start: document.getElementById('start-recording'),
stop: document.getElementById('stop-recording'),
reset: document.getElementById('reset-audio'),
micStatus: document.getElementById('mic-status'),
systemStatus: document.getElementById('system-status'),
mixStatus: document.getElementById('mix-status'),
micMeter: document.getElementById('mic-meter'),
systemMeter: document.getElementById('system-meter'),
mixMeter: document.getElementById('mix-meter'),
log: document.getElementById('audio-log'),
result: document.getElementById('recording-result'),
preview: document.getElementById('recording-preview'),
download: document.getElementById('download-recording'),
sharedVideo: document.getElementById('shared-video-preview'),
};
if (!navigator.mediaDevices || !window.MediaRecorder) {
log('This browser does not support the required MediaDevices/MediaRecorder APIs. Try current Chrome or Edge over HTTPS.');
[els.connectMic, els.connectSystem, els.start].forEach((button) => {
if (button) button.disabled = true;
});
return;
}
els.connectMic?.addEventListener('click', connectMicrophone);
els.connectSystem?.addEventListener('click', connectSystemAudio);
els.start?.addEventListener('click', startRecording);
els.stop?.addEventListener('click', stopRecording);
els.reset?.addEventListener('click', resetAudio);
window.addEventListener('beforeunload', cleanupTracks);
async function ensureAudioGraph() {
if (state.audioContext) return;
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
state.audioContext = new AudioContextClass();
state.destination = state.audioContext.createMediaStreamDestination();
state.mixedStream = state.destination.stream;
state.analyzers.mix = createAnalyser();
const mixSource = state.audioContext.createMediaStreamSource(state.mixedStream);
mixSource.connect(state.analyzers.mix);
updateStatus(els.mixStatus, 'Waiting', 'muted');
startMeters();
}
async function connectMicrophone() {
try {
await ensureAudioGraph();
await state.audioContext.resume();
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
});
replaceStream('micStream', stream);
connectStream(stream, 'mic');
updateStatus(els.micStatus, 'Connected', 'active');
log('Microphone connected.');
refreshButtons();
} catch (error) {
handleCaptureError('microphone', error);
}
}
async function connectSystemAudio() {
try {
await ensureAudioGraph();
await state.audioContext.resume();
const stream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: {
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
},
});
if (!stream.getAudioTracks().length) {
stopStream(stream);
throw new Error('No audio track was shared. Reopen sharing and enable tab/system audio.');
}
replaceStream('systemStream', stream);
connectStream(stream, 'system');
els.sharedVideo.srcObject = stream;
els.sharedVideo.play().catch(() => {});
updateStatus(els.systemStatus, 'Connected', 'active');
log('Computer/tab audio connected. Keep the shared source open while recording.');
stream.getTracks().forEach((track) => {
track.addEventListener('ended', () => {
if (state.systemStream === stream) {
updateStatus(els.systemStatus, 'Stopped', 'muted');
log('Shared computer/tab audio stopped by the browser.');
state.systemStream = null;
refreshButtons();
}
});
});
refreshButtons();
} catch (error) {
handleCaptureError('computer/tab audio', error);
}
}
function connectStream(stream, sourceName) {
const audioTracks = stream.getAudioTracks();
if (!audioTracks.length) return;
const audioOnlyStream = new MediaStream(audioTracks);
const source = state.audioContext.createMediaStreamSource(audioOnlyStream);
const analyser = createAnalyser();
state.analyzers[sourceName] = analyser;
source.connect(analyser);
source.connect(state.destination);
updateStatus(els.mixStatus, 'Mix ready', 'active');
}
function createAnalyser() {
const analyser = state.audioContext.createAnalyser();
analyser.fftSize = 256;
analyser.smoothingTimeConstant = 0.78;
return analyser;
}
function startMeters() {
if (state.animationFrame) return;
const data = new Uint8Array(128);
const draw = () => {
drawMeter(state.analyzers.mic, els.micMeter, data);
drawMeter(state.analyzers.system, els.systemMeter, data);
drawMeter(state.analyzers.mix, els.mixMeter, data);
state.animationFrame = requestAnimationFrame(draw);
};
draw();
}
function drawMeter(analyser, element, data) {
if (!element) return;
if (!analyser) {
element.style.width = '0%';
return;
}
analyser.getByteTimeDomainData(data);
let sum = 0;
for (const value of data) {
const centered = value - 128;
sum += centered * centered;
}
const rms = Math.sqrt(sum / data.length);
const width = Math.min(100, Math.max(3, rms * 4.6));
element.style.width = `${width}%`;
}
function startRecording() {
if (!state.mixedStream || !state.mixedStream.getAudioTracks().length) {
log('Connect at least one audio source before recording.');
return;
}
cleanupRecordingUrl();
state.chunks = [];
const options = pickRecorderOptions();
state.recorder = new MediaRecorder(state.mixedStream, options);
state.recorder.addEventListener('dataavailable', (event) => {
if (event.data && event.data.size > 0) state.chunks.push(event.data);
});
state.recorder.addEventListener('stop', () => {
const mimeType = state.recorder?.mimeType || 'audio/webm';
const blob = new Blob(state.chunks, { type: mimeType });
state.downloadUrl = URL.createObjectURL(blob);
els.preview.src = state.downloadUrl;
els.download.href = state.downloadUrl;
els.download.download = `reliefsignal-audio-test-${new Date().toISOString().replace(/[:.]/g, '-')}.webm`;
els.result.hidden = false;
log(`Recording ready (${formatBytes(blob.size)}).`);
refreshButtons();
});
state.recorder.start(1000);
updateStatus(els.mixStatus, 'Recording', 'recording');
log('Local recording started.');
refreshButtons();
}
function stopRecording() {
if (state.recorder && state.recorder.state !== 'inactive') {
state.recorder.stop();
updateStatus(els.mixStatus, 'Mix ready', hasAnySource() ? 'active' : 'muted');
log('Recording stopped.');
refreshButtons();
}
}
function resetAudio() {
if (state.recorder && state.recorder.state !== 'inactive') {
state.recorder.stop();
}
cleanupTracks();
cleanupRecordingUrl();
state.micStream = null;
state.systemStream = null;
state.mixedStream = null;
state.destination = null;
state.analyzers = {};
state.audioContext?.close().catch(() => {});
state.audioContext = null;
state.recorder = null;
state.chunks = [];
if (state.animationFrame) cancelAnimationFrame(state.animationFrame);
state.animationFrame = null;
els.preview.removeAttribute('src');
els.download.removeAttribute('href');
els.result.hidden = true;
els.sharedVideo.srcObject = null;
updateStatus(els.micStatus, 'Not connected', 'muted');
updateStatus(els.systemStatus, 'Not connected', 'muted');
updateStatus(els.mixStatus, 'Waiting', 'muted');
[els.micMeter, els.systemMeter, els.mixMeter].forEach((meter) => {
if (meter) meter.style.width = '0%';
});
log('Audio capture reset.');
refreshButtons();
}
function refreshButtons() {
const recording = state.recorder && state.recorder.state === 'recording';
const hasSource = hasAnySource();
els.start.disabled = !hasSource || recording;
els.stop.disabled = !recording;
els.reset.disabled = !hasSource && !state.downloadUrl;
els.connectMic.disabled = Boolean(state.micStream) || recording;
els.connectSystem.disabled = Boolean(state.systemStream) || recording;
}
function hasAnySource() {
return Boolean(
state.micStream?.getAudioTracks().some((track) => track.readyState === 'live') ||
state.systemStream?.getAudioTracks().some((track) => track.readyState === 'live')
);
}
function pickRecorderOptions() {
const candidates = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/mp4',
];
const mimeType = candidates.find((type) => MediaRecorder.isTypeSupported(type));
return mimeType ? { mimeType } : {};
}
function replaceStream(key, stream) {
if (state[key]) stopStream(state[key]);
state[key] = stream;
}
function stopStream(stream) {
stream?.getTracks().forEach((track) => track.stop());
}
function cleanupTracks() {
stopStream(state.micStream);
stopStream(state.systemStream);
}
function cleanupRecordingUrl() {
if (state.downloadUrl) {
URL.revokeObjectURL(state.downloadUrl);
state.downloadUrl = null;
}
}
function updateStatus(element, label, mode) {
if (!element) return;
element.textContent = label;
element.classList.remove('active', 'muted', 'recording');
element.classList.add(mode);
}
function log(message) {
if (!els.log) return;
const item = document.createElement('li');
item.textContent = `${new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })} — ${message}`;
els.log.prepend(item);
}
function handleCaptureError(label, error) {
const message = error?.name === 'NotAllowedError'
? `Permission was not granted for ${label}.`
: error?.message || `Could not connect ${label}.`;
log(message);
}
function formatBytes(bytes) {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
return `${(bytes / Math.pow(1024, index)).toFixed(index ? 1 : 0)} ${units[index]}`;
}
})();