gg
This commit is contained in:
parent
4f209aac8e
commit
2447ac66a4
Binary file not shown.
Binary file not shown.
@ -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>
|
||||
|
||||
132
core/templates/core/audio_capture.html
Normal file
132
core/templates/core/audio_capture.html
Normal 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 %}
|
||||
@ -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'),
|
||||
]
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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
330
static/js/audio-capture.js
Normal 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]}`;
|
||||
}
|
||||
})();
|
||||
@ -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
staticfiles/js/audio-capture.js
Normal file
330
staticfiles/js/audio-capture.js
Normal 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]}`;
|
||||
}
|
||||
})();
|
||||
Loading…
x
Reference in New Issue
Block a user