diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc
index 653b1c8..b6c95e7 100644
Binary files a/core/__pycache__/urls.cpython-311.pyc and b/core/__pycache__/urls.cpython-311.pyc differ
diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc
index 0d3d593..6013022 100644
Binary files a/core/__pycache__/views.cpython-311.pyc and b/core/__pycache__/views.cpython-311.pyc differ
diff --git a/core/templates/base.html b/core/templates/base.html
index 526ca8a..313975a 100644
--- a/core/templates/base.html
+++ b/core/templates/base.html
@@ -34,6 +34,7 @@
Workflow
History
+ Audio
Webhooks
Admin
New pilot call
diff --git a/core/templates/core/audio_capture.html b/core/templates/core/audio_capture.html
new file mode 100644
index 0000000..630de9a
--- /dev/null
+++ b/core/templates/core/audio_capture.html
@@ -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 %}
+
+
+
+
+
Computer audio first
+
Capture mic input and shared output audio in the browser.
+
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.
+
+
+
+ Privacy guardrail
+ No silent background listening. Audio stays local on this page until we add an explicit upload or transcription step.
+
+
+
+
+
+
+
+
+
+
+
+
+ Step 1
+
Connect audio sources
+
+
+
+
+
Not connected
+
Microphone input
+
Captures your voice or headset microphone.
+
+
+
+
Not connected
+
Computer/tab output
+
Use browser screen sharing and select a source with audio enabled.
+
+
+
+
Waiting
+
Combined recording mix
+
Records whichever approved sources are active.
+
+
+
+
+
+ Enable microphone
+ Share computer/tab audio
+ Start local recording
+ Stop recording
+ Reset
+
+
+
+
+
Local test file
+
Recording ready
+
Preview it here or download it. Next, we can add secure upload + transcription.
+
+
+
Download WebM
+
+
+
+
+
+
+
+
+
+ Notes
+
How this works
+
+
+ Mic Browser permission
+ Output audio Share tab/screen audio
+ Storage Local browser blob
+ Next step Upload/transcribe
+
+
+
Best first test
+
Open the Ytel webphone or any sample call audio in a browser tab, then choose that tab when clicking Share computer/tab audio . In Chrome, make sure the share dialog has audio enabled.
+
+
+
Browser limitation
+
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.
+
+
+
+
+
+
+
+
+
+ Live
+
Capture log
+
+
+ Ready. Connect the microphone or shared audio source to begin.
+
+
+
+
+
+
+ After test
+
Recommended next slice
+
+ 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.
+ Open intake form
+
+
+
+
+
+{% endblock %}
+
+{% block head %}
+
+{% endblock %}
diff --git a/core/urls.py b/core/urls.py
index adf3301..e7983e8 100644
--- a/core/urls.py
+++ b/core/urls.py
@@ -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//', 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'),
]
diff --git a/core/views.py b/core/views.py
index 54a130c..832b6a3 100644
--- a/core/views.py
+++ b/core/views.py
@@ -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 = {
diff --git a/static/css/custom.css b/static/css/custom.css
index 7186a01..ed6ca17 100644
--- a/static/css/custom.css
+++ b/static/css/custom.css
@@ -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; }
+}
diff --git a/static/js/audio-capture.js b/static/js/audio-capture.js
new file mode 100644
index 0000000..dadd7a3
--- /dev/null
+++ b/static/js/audio-capture.js
@@ -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]}`;
+ }
+})();
diff --git a/staticfiles/css/custom.css b/staticfiles/css/custom.css
index 7186a01..ed6ca17 100644
--- a/staticfiles/css/custom.css
+++ b/staticfiles/css/custom.css
@@ -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; }
+}
diff --git a/staticfiles/js/audio-capture.js b/staticfiles/js/audio-capture.js
new file mode 100644
index 0000000..dadd7a3
--- /dev/null
+++ b/staticfiles/js/audio-capture.js
@@ -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]}`;
+ }
+})();