Compare commits

...

2 Commits

Author SHA1 Message Date
Flatlogic Bot
2447ac66a4 gg 2026-07-03 20:03:39 +00:00
Flatlogic Bot
4f209aac8e g 2026-07-03 19:38:20 +00:00
33 changed files with 2730 additions and 188 deletions

Binary file not shown.

View File

@ -1,3 +1,26 @@
from django.contrib import admin from django.contrib import admin
# Register your models here. from .models import PilotCall, WebhookEvent
@admin.register(PilotCall)
class PilotCallAdmin(admin.ModelAdmin):
list_display = ('client_name', 'phone', 'call_source', 'ytel_audio_status', 'logics_api_status', 'created_at')
list_filter = ('call_source', 'ytel_audio_status', 'logics_api_status', 'levy', 'garnishment')
search_fields = ('client_name', 'phone', 'email', 'transcript', 'agent_notes')
readonly_fields = ('created_at', 'updated_at')
@admin.register(WebhookEvent)
class WebhookEventAdmin(admin.ModelAdmin):
list_display = ('provider', 'display_event_type', 'external_id', 'delivery_id', 'source_ip', 'processed', 'received_at')
list_filter = ('provider', 'event_type', 'processed', 'received_at')
search_fields = ('provider', 'event_type', 'external_id', 'delivery_id', 'raw_body', 'notes')
readonly_fields = ('provider', 'event_type', 'external_id', 'delivery_id', 'source_ip', 'content_type', 'headers', 'query_params', 'payload', 'raw_body', 'received_at')
list_editable = ('processed',)
fieldsets = (
('Routing summary', {'fields': ('provider', 'event_type', 'external_id', 'delivery_id', 'source_ip', 'content_type', 'received_at')}),
('Payload', {'fields': ('query_params', 'headers', 'payload', 'raw_body')}),
('Review', {'fields': ('processed', 'notes')}),
)

46
core/forms.py Normal file
View File

@ -0,0 +1,46 @@
from django import forms
from .models import PilotCall
class PilotCallForm(forms.ModelForm):
class Meta:
model = PilotCall
fields = [
'client_name', 'phone', 'email', 'call_source', 'ytel_audio_status', 'logics_api_status',
'transcript', 'irs_debt', 'state_debt', 'tax_years', 'filing_status', 'employment_type',
'occupation', 'monthly_gross_income', 'spouse_income', 'mortgage', 'rent', 'utilities',
'car_payment', 'insurance', 'student_loans', 'credit_cards', 'assets', 'business_expenses',
'irs_payment_plan', 'levy', 'garnishment', 'best_callback_time', 'agent_notes',
]
widgets = {
'client_name': forms.TextInput(attrs={'placeholder': 'Maria Johnson'}),
'phone': forms.TextInput(attrs={'placeholder': '(555) 013-8842'}),
'email': forms.EmailInput(attrs={'placeholder': 'client@example.com'}),
'transcript': forms.Textarea(attrs={'rows': 8, 'placeholder': 'Paste the latest transcript segment from the call…'}),
'assets': forms.Textarea(attrs={'rows': 3, 'placeholder': 'Home equity, vehicles, business assets…'}),
'agent_notes': forms.Textarea(attrs={'rows': 4, 'placeholder': 'Summary, objections, follow-up plan…'}),
'irs_debt': forms.NumberInput(attrs={'placeholder': '24000'}),
'state_debt': forms.NumberInput(attrs={'placeholder': '5000'}),
'monthly_gross_income': forms.NumberInput(attrs={'placeholder': '7200'}),
'spouse_income': forms.NumberInput(attrs={'placeholder': '3800'}),
'mortgage': forms.NumberInput(attrs={'placeholder': '3500'}),
'rent': forms.NumberInput(attrs={'placeholder': '0'}),
'utilities': forms.NumberInput(attrs={'placeholder': '450'}),
'car_payment': forms.NumberInput(attrs={'placeholder': '620'}),
'insurance': forms.NumberInput(attrs={'placeholder': '310'}),
'student_loans': forms.NumberInput(attrs={'placeholder': '250'}),
'credit_cards': forms.NumberInput(attrs={'placeholder': '900'}),
'business_expenses': forms.NumberInput(attrs={'placeholder': '1800'}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field in self.fields.values():
base_class = 'form-check-input' if isinstance(field.widget, forms.CheckboxInput) else 'form-control'
if isinstance(field.widget, forms.Select):
base_class = 'form-select'
field.widget.attrs['class'] = f"{field.widget.attrs.get('class', '')} {base_class}".strip()
self.fields['client_name'].required = True
self.fields['phone'].required = True
self.fields['transcript'].required = True

View File

@ -0,0 +1,56 @@
# Generated by Django 5.2.7 on 2026-07-03 19:19
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='PilotCall',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('client_name', models.CharField(max_length=140)),
('phone', models.CharField(max_length=40)),
('email', models.EmailField(blank=True, max_length=254)),
('call_source', models.CharField(choices=[('ytel', 'Ytel'), ('ringcentral', 'RingCentral fallback'), ('manual', 'Manual/recording test')], default='ytel', max_length=24)),
('ytel_audio_status', models.CharField(choices=[('unconfirmed', 'Not confirmed'), ('available', 'Available'), ('blocked', 'Blocked / not exposed'), ('partner', 'Needs enterprise/partner access')], default='unconfirmed', max_length=24)),
('logics_api_status', models.CharField(choices=[('unconfirmed', 'Not confirmed'), ('available', 'Available'), ('blocked', 'Blocked / not exposed'), ('partner', 'Needs enterprise/partner access')], default='unconfirmed', max_length=24)),
('transcript', models.TextField(help_text='Paste a live transcript segment or recorded-call transcript for the pilot.')),
('irs_debt', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
('state_debt', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
('tax_years', models.CharField(blank=True, max_length=120)),
('filing_status', models.CharField(blank=True, max_length=80)),
('employment_type', models.CharField(blank=True, max_length=120)),
('occupation', models.CharField(blank=True, max_length=120)),
('monthly_gross_income', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
('spouse_income', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
('mortgage', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
('rent', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
('utilities', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
('car_payment', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
('insurance', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
('student_loans', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
('credit_cards', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
('assets', models.TextField(blank=True)),
('business_expenses', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
('irs_payment_plan', models.BooleanField(default=False)),
('levy', models.BooleanField(default=False)),
('garnishment', models.BooleanField(default=False)),
('best_callback_time', models.CharField(blank=True, max_length=120)),
('agent_notes', models.TextField(blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'pilot call',
'verbose_name_plural': 'pilot calls',
'ordering': ['-created_at'],
},
),
]

View File

@ -0,0 +1,38 @@
# Generated by Django 5.2.7 on 2026-07-03 19:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='WebhookEvent',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('provider', models.CharField(default='generic', max_length=60)),
('event_type', models.CharField(blank=True, max_length=120)),
('external_id', models.CharField(blank=True, max_length=160)),
('delivery_id', models.CharField(blank=True, max_length=160)),
('source_ip', models.GenericIPAddressField(blank=True, null=True)),
('content_type', models.CharField(blank=True, max_length=180)),
('headers', models.JSONField(blank=True, default=dict)),
('query_params', models.JSONField(blank=True, default=dict)),
('payload', models.JSONField(blank=True, default=dict)),
('raw_body', models.TextField(blank=True)),
('processed', models.BooleanField(default=False)),
('notes', models.TextField(blank=True)),
('received_at', models.DateTimeField(auto_now_add=True)),
],
options={
'verbose_name': 'webhook event',
'verbose_name_plural': 'webhook events',
'ordering': ['-received_at'],
'indexes': [models.Index(fields=['provider', 'event_type'], name='core_webhoo_provide_870e6a_idx'), models.Index(fields=['received_at'], name='core_webhoo_receive_97109f_idx')],
},
),
]

View File

@ -1,3 +1,151 @@
from django.db import models from decimal import Decimal
# Create your models here. from django.db import models
from django.urls import reverse
class PilotCall(models.Model):
SOURCE_YTEL = 'ytel'
SOURCE_RINGCENTRAL = 'ringcentral'
SOURCE_MANUAL = 'manual'
CALL_SOURCE_CHOICES = [
(SOURCE_YTEL, 'Ytel'),
(SOURCE_RINGCENTRAL, 'RingCentral fallback'),
(SOURCE_MANUAL, 'Manual/recording test'),
]
STATUS_UNCONFIRMED = 'unconfirmed'
STATUS_AVAILABLE = 'available'
STATUS_BLOCKED = 'blocked'
STATUS_PARTNER = 'partner'
INTEGRATION_STATUS_CHOICES = [
(STATUS_UNCONFIRMED, 'Not confirmed'),
(STATUS_AVAILABLE, 'Available'),
(STATUS_BLOCKED, 'Blocked / not exposed'),
(STATUS_PARTNER, 'Needs enterprise/partner access'),
]
client_name = models.CharField(max_length=140)
phone = models.CharField(max_length=40)
email = models.EmailField(blank=True)
call_source = models.CharField(max_length=24, choices=CALL_SOURCE_CHOICES, default=SOURCE_YTEL)
ytel_audio_status = models.CharField(max_length=24, choices=INTEGRATION_STATUS_CHOICES, default=STATUS_UNCONFIRMED)
logics_api_status = models.CharField(max_length=24, choices=INTEGRATION_STATUS_CHOICES, default=STATUS_UNCONFIRMED)
transcript = models.TextField(help_text='Paste a live transcript segment or recorded-call transcript for the pilot.')
irs_debt = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
state_debt = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
tax_years = models.CharField(max_length=120, blank=True)
filing_status = models.CharField(max_length=80, blank=True)
employment_type = models.CharField(max_length=120, blank=True)
occupation = models.CharField(max_length=120, blank=True)
monthly_gross_income = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
spouse_income = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
mortgage = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
rent = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
utilities = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
car_payment = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
insurance = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
student_loans = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
credit_cards = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
assets = models.TextField(blank=True)
business_expenses = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
irs_payment_plan = models.BooleanField(default=False)
levy = models.BooleanField(default=False)
garnishment = models.BooleanField(default=False)
best_callback_time = models.CharField(max_length=120, blank=True)
agent_notes = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-created_at']
verbose_name = 'pilot call'
verbose_name_plural = 'pilot calls'
def __str__(self):
return f'{self.client_name} · {self.get_call_source_display()}'
def get_absolute_url(self):
return reverse('call_detail', args=[self.pk])
@property
def required_fields(self):
return [
('IRS debt', self.irs_debt),
('Tax years', self.tax_years),
('Employment', self.employment_type),
('Monthly income', self.monthly_gross_income),
('Best callback', self.best_callback_time),
]
@property
def completion_score(self):
if not self.required_fields:
return 0
completed = sum(1 for _label, value in self.required_fields if self._has_value(value))
return round((completed / len(self.required_fields)) * 100)
@property
def missing_required_labels(self):
return [label for label, value in self.required_fields if not self._has_value(value)]
@property
def urgent_flags(self):
flags = []
if self.levy:
flags.append('Levy mentioned')
if self.garnishment:
flags.append('Wage garnishment mentioned')
if self.irs_debt and self.irs_debt > Decimal('50000'):
flags.append('Debt over $50,000')
if self.monthly_gross_income is not None and self.monthly_gross_income <= 0:
flags.append('No monthly income captured')
return flags
@staticmethod
def _has_value(value):
if value is None:
return False
if isinstance(value, str):
return bool(value.strip())
return True
class WebhookEvent(models.Model):
PROVIDER_GENERIC = 'generic'
provider = models.CharField(max_length=60, default=PROVIDER_GENERIC)
event_type = models.CharField(max_length=120, blank=True)
external_id = models.CharField(max_length=160, blank=True)
delivery_id = models.CharField(max_length=160, blank=True)
source_ip = models.GenericIPAddressField(null=True, blank=True)
content_type = models.CharField(max_length=180, blank=True)
headers = models.JSONField(default=dict, blank=True)
query_params = models.JSONField(default=dict, blank=True)
payload = models.JSONField(default=dict, blank=True)
raw_body = models.TextField(blank=True)
processed = models.BooleanField(default=False)
notes = models.TextField(blank=True)
received_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-received_at']
indexes = [
models.Index(fields=['provider', 'event_type']),
models.Index(fields=['received_at']),
]
verbose_name = 'webhook event'
verbose_name_plural = 'webhook events'
def __str__(self):
label = self.event_type or self.external_id or f'event #{self.pk}'
return f'{self.provider}: {label}'
@property
def display_event_type(self):
return self.event_type or 'unclassified'

View File

@ -1,25 +1,67 @@
{% load static %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>{% block title %}Knowledge Base{% endblock %}</title> <meta name="viewport" content="width=device-width, initial-scale=1">
{% if project_description %} <title>{% block title %}{{ project_name|default:"ReliefSignal" }}{% endblock %}</title>
<meta name="description" content="{{ project_description }}"> <meta name="description" content="{% block meta_description %}{{ meta_description|default:project_description|default:'Real-time tax relief call intake pilot for transcription, structured extraction, completion scoring, and CRM readiness.' }}{% endblock %}">
<meta property="og:description" content="{{ project_description }}">
<meta property="twitter:description" content="{{ project_description }}">
{% endif %}
{% if project_image_url %} {% if project_image_url %}
<meta property="og:image" content="{{ project_image_url }}"> <meta property="og:image" content="{{ project_image_url }}">
<meta property="twitter:image" content="{{ project_image_url }}"> <meta property="twitter:image" content="{{ project_image_url }}">
{% endif %} {% endif %}
{% load static %} <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Fraunces:opsz,wght@9..144,700&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}"> <link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}">
{% block head %}{% endblock %} {% block head %}{% endblock %}
</head> </head>
<body> <body>
<a class="skip-link" href="#main-content">Skip to content</a>
<nav class="navbar navbar-expand-lg app-nav sticky-top" aria-label="Primary navigation">
<div class="container py-2">
<a class="navbar-brand brand-mark" href="{% url 'home' %}">
<span class="brand-icon">RS</span>
<span>ReliefSignal</span>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNav" aria-controls="mainNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="mainNav">
<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>
</ul>
</div>
</div>
</nav>
{% if messages %}
<div class="container message-stack" aria-live="polite">
{% for message in messages %}
<div class="alert alert-{{ message.tags|default:'info' }} shadow-sm mb-2" role="alert">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
<main id="main-content">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main>
<footer class="site-footer">
<div class="container d-flex flex-column flex-md-row justify-content-between gap-3">
<span>ReliefSignal pilot console for tax relief call intake.</span>
<span>Real-time audio first · CRM sync ready · Completion scored</span>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body> </body>
</html> </html>

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

@ -0,0 +1,83 @@
{% extends "base.html" %}
{% block title %}{{ call.client_name }} — Pilot Call Detail{% endblock %}
{% block meta_description %}Detailed tax relief pilot call record with transcript, extracted intake fields, urgency rules, completion score, and integration readiness.{% endblock %}
{% block content %}
<section class="page-hero compact detail-hero">
<div class="container">
<div class="d-flex flex-column flex-lg-row justify-content-between gap-4 align-items-lg-end">
<div>
<span class="eyebrow">Pilot call review</span>
<h1 class="page-title">{{ call.client_name }}</h1>
<p class="hero-copy mb-0">{{ call.phone }}{% if call.email %} · {{ call.email }}{% endif %}</p>
</div>
<div class="score-circle" aria-label="Completion score {{ call.completion_score }} percent"><strong>{{ call.completion_score }}%</strong><span>complete</span></div>
</div>
</div>
</section>
<section class="section-pad pt-4">
<div class="container">
<div class="row g-4">
<div class="col-lg-8">
<div class="detail-panel mb-4">
<div class="panel-heading"><span>Transcript</span><h2>Conversation feed</h2></div>
<div class="transcript-review">{{ call.transcript|linebreaks }}</div>
</div>
<div class="detail-panel">
<div class="panel-heading"><span>Schema</span><h2>Extracted intake fields</h2></div>
<div class="row g-3">
{% for label, value in intake_fields %}
<div class="col-md-6 col-xl-4">
<div class="field-tile {% if not value %}missing{% endif %}">
<span>{{ label }}</span>
<strong>{% if value %}{{ value }}{% else %}Missing{% endif %}</strong>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
<aside class="col-lg-4">
<div class="detail-panel mb-4">
<div class="panel-heading"><span>Readiness</span><h2>Integrations</h2></div>
<ul class="readiness-list">
<li><span>Call source</span><strong>{{ call.get_call_source_display }}</strong></li>
<li><span>Ytel live audio</span><strong>{{ call.get_ytel_audio_status_display }}</strong></li>
<li><span>Logics API</span><strong>{{ call.get_logics_api_status_display }}</strong></li>
</ul>
</div>
<div class="detail-panel mb-4">
<div class="panel-heading"><span>Rules</span><h2>Coach alerts</h2></div>
{% if call.urgent_flags %}
<div class="flag-stack">
{% for flag in call.urgent_flags %}<span class="alert-flag">{{ flag }}</span>{% endfor %}
</div>
{% else %}
<p class="text-muted-custom mb-0">No urgent rules triggered yet.</p>
{% endif %}
</div>
<div class="detail-panel mb-4">
<div class="panel-heading"><span>Missing</span><h2>Required fields</h2></div>
{% if call.missing_required_labels %}
<ul class="missing-list">
{% for label in call.missing_required_labels %}<li>{{ label }}</li>{% endfor %}
</ul>
{% else %}
<p class="text-muted-custom mb-0">All required fields are captured.</p>
{% endif %}
</div>
<div class="detail-panel">
<div class="panel-heading"><span>Notes</span><h2>Next steps</h2></div>
<p>{{ call.agent_notes|default:'No notes yet.'|linebreaksbr }}</p>
<div class="d-grid gap-2 mt-3">
<a class="btn btn-signal" href="{% url 'call_create' %}">Create another call</a>
<a class="btn btn-ghost" href="{% url 'call_list' %}">Back to history</a>
</div>
</div>
</aside>
</div>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,94 @@
{% extends "base.html" %}
{% block title %}New Pilot Call — ReliefSignal{% endblock %}
{% block meta_description %}Create a tax relief pilot call intake with transcript, extraction fields, integration readiness, and completion scoring.{% endblock %}
{% block content %}
<section class="page-hero compact">
<div class="container">
<span class="eyebrow">Create pilot intake</span>
<h1 class="page-title">Start a real-time call proof point.</h1>
<p class="hero-copy mb-0">Paste a transcript segment, capture the tax relief fields your AI should extract, and record whether Ytel audio + Logics updates are ready.</p>
</div>
</section>
<section class="section-pad pt-4">
<div class="container">
<form method="post" class="intake-form" novalidate>
{% csrf_token %}
{% if form.non_field_errors %}
<div class="alert alert-danger">{{ form.non_field_errors }}</div>
{% endif %}
<div class="form-panel mb-4">
<div class="panel-heading"><span>01</span><h2>Call identity</h2></div>
<div class="row g-4">
{% for field in form %}
{% if field.name in 'client_name phone email call_source ytel_audio_status logics_api_status' %}
<div class="col-md-6 col-lg-4">
<label class="form-label" for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% for error in field.errors %}<div class="invalid-note">{{ error }}</div>{% endfor %}
</div>
{% endif %}
{% endfor %}
</div>
</div>
<div class="form-panel mb-4">
<div class="panel-heading"><span>02</span><h2>Transcript</h2></div>
<label class="form-label" for="{{ form.transcript.id_for_label }}">{{ form.transcript.label }}</label>
{{ form.transcript }}
<p class="form-hint">For the live MVP, this represents the text coming from speech-to-text every few seconds.</p>
{% for error in form.transcript.errors %}<div class="invalid-note">{{ error }}</div>{% endfor %}
</div>
<div class="form-panel mb-4">
<div class="panel-heading"><span>03</span><h2>Extracted intake schema</h2></div>
<div class="row g-4">
{% for field in form %}
{% if field.name not in 'client_name phone email call_source ytel_audio_status logics_api_status transcript irs_payment_plan levy garnishment assets agent_notes' %}
<div class="col-md-6 col-lg-4">
<label class="form-label" for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% for error in field.errors %}<div class="invalid-note">{{ error }}</div>{% endfor %}
</div>
{% endif %}
{% endfor %}
<div class="col-12">
<label class="form-label" for="{{ form.assets.id_for_label }}">{{ form.assets.label }}</label>
{{ form.assets }}
</div>
</div>
</div>
<div class="form-panel mb-4">
<div class="panel-heading"><span>04</span><h2>Rules engine flags</h2></div>
<div class="row g-4 align-items-start">
<div class="col-lg-5">
<div class="checks-grid">
{% for field in form %}
{% if field.name in 'irs_payment_plan levy garnishment' %}
<label class="form-check signal-check">
{{ field }}
<span>{{ field.label }}</span>
</label>
{% endif %}
{% endfor %}
</div>
</div>
<div class="col-lg-7">
<label class="form-label" for="{{ form.agent_notes.id_for_label }}">{{ form.agent_notes.label }}</label>
{{ form.agent_notes }}
</div>
</div>
</div>
<div class="d-flex flex-column flex-sm-row gap-3 justify-content-end">
<a class="btn btn-ghost" href="{% url 'call_list' %}">Cancel</a>
<button class="btn btn-signal" type="submit">Save and review score</button>
</div>
</form>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,64 @@
{% extends "base.html" %}
{% block title %}Pilot Call History — ReliefSignal{% endblock %}
{% block meta_description %}Review saved tax relief pilot calls with source, completion score, CRM readiness, and urgency flags.{% endblock %}
{% block content %}
<section class="page-hero compact">
<div class="container d-flex flex-column flex-lg-row justify-content-between gap-4 align-items-lg-end">
<div>
<span class="eyebrow">History</span>
<h1 class="page-title">Pilot call records</h1>
<p class="hero-copy mb-0">Every saved call becomes evidence for what the MVP captures and what integrations still need validation.</p>
</div>
<a class="btn btn-signal" href="{% url 'call_create' %}">New pilot call</a>
</div>
</section>
<section class="section-pad pt-4">
<div class="container">
{% if calls %}
<div class="history-shell">
<div class="table-responsive">
<table class="table align-middle history-table">
<thead>
<tr>
<th>Client</th>
<th>Source</th>
<th>Completion</th>
<th>Readiness</th>
<th>Flags</th>
<th class="text-end">Open</th>
</tr>
</thead>
<tbody>
{% for call in calls %}
<tr>
<td><strong>{{ call.client_name }}</strong><br><span>{{ call.phone }}</span></td>
<td>{{ call.get_call_source_display }}<br><span>{{ call.created_at|date:"M j, Y g:i A" }}</span></td>
<td><span class="score-badge">{{ call.completion_score }}%</span></td>
<td><span>Ytel: {{ call.get_ytel_audio_status_display }}</span><br><span>Logics: {{ call.get_logics_api_status_display }}</span></td>
<td>
{% if call.urgent_flags %}
{% for flag in call.urgent_flags %}<span class="mini-flag">{{ flag }}</span>{% endfor %}
{% else %}
<span class="text-muted-custom">None</span>
{% endif %}
</td>
<td class="text-end"><a class="btn btn-outline-signal btn-sm" href="{% url 'call_detail' call.pk %}">Review</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<div class="empty-state">
<h2>No call history yet</h2>
<p>Save your first pilot call to confirm the workflow from transcript capture to scored intake review.</p>
<a class="btn btn-signal" href="{% url 'call_create' %}">Create first call</a>
</div>
{% endif %}
</div>
</section>
{% endblock %}

View File

@ -1,145 +1,104 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ project_name }}{% endblock %} {% block title %}ReliefSignal — Real-Time Tax Relief Intake Pilot{% endblock %}
{% block meta_description %}A polished MVP console for validating real-time Ytel audio readiness, Logics CRM sync, and AI-assisted tax relief intake completion.{% endblock %}
{% block head %}
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-color-start: #6a11cb;
--bg-color-end: #2575fc;
--text-color: #ffffff;
--card-bg-color: rgba(255, 255, 255, 0.01);
--card-border-color: rgba(255, 255, 255, 0.1);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: 'Inter', sans-serif;
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
color: var(--text-color);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
text-align: center;
overflow: hidden;
position: relative;
}
body::before {
content: '';
position: absolute;
inset: 0;
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'><path d='M-10 10L110 10M10 -10L10 110' stroke-width='1' stroke='rgba(255,255,255,0.05)'/></svg>");
animation: bg-pan 20s linear infinite;
z-index: -1;
}
@keyframes bg-pan {
0% {
background-position: 0% 0%;
}
100% {
background-position: 100% 100%;
}
}
main {
padding: 2rem;
}
.card {
background: var(--card-bg-color);
border: 1px solid var(--card-border-color);
border-radius: 16px;
padding: 2.5rem 2rem;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25);
}
h1 {
font-size: clamp(2.2rem, 3vw + 1.2rem, 3.2rem);
font-weight: 700;
margin: 0 0 1.2rem;
letter-spacing: -0.02em;
}
p {
margin: 0.5rem 0;
font-size: 1.1rem;
opacity: 0.92;
}
.loader {
margin: 1.5rem auto;
width: 56px;
height: 56px;
border: 4px solid rgba(255, 255, 255, 0.25);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.runtime code {
background: rgba(0, 0, 0, 0.25);
padding: 0.15rem 0.45rem;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
footer {
position: absolute;
bottom: 1rem;
width: 100%;
text-align: center;
font-size: 0.85rem;
opacity: 0.75;
}
</style>
{% endblock %}
{% block content %} {% block content %}
<main> <section class="hero-section position-relative overflow-hidden">
<div class="card"> <div class="orb orb-one"></div>
<h1>Analyzing your requirements and generating your app…</h1> <div class="orb orb-two"></div>
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes"> <div class="container position-relative py-5 py-lg-6">
<span class="sr-only">Loading…</span> <div class="row align-items-center g-5">
<div class="col-lg-7">
<span class="eyebrow">Real-time tax relief intake MVP</span>
<h1 class="display-title mt-3">Prove live call extraction before building the whole platform.</h1>
<p class="hero-copy mt-4">ReliefSignal turns each pilot call into a structured intake: transcript, IRS debt fields, urgency flags, completion score, and Ytel/Logics readiness notes in one workflow.</p>
<div class="d-flex flex-column flex-sm-row gap-3 mt-4">
<a class="btn btn-signal btn-lg" href="{% url 'call_create' %}">Start a pilot call</a>
<a class="btn btn-ghost btn-lg" href="{% url 'call_list' %}">View history</a>
</div> </div>
<p class="hint">AppWizzy AI is collecting your requirements and applying the first changes.</p> <div class="hero-proof mt-5" aria-label="Pilot metrics">
<p class="hint">This page will refresh automatically as the plan is implemented.</p> <div><strong>{{ total_calls }}</strong><span>Pilot calls</span></div>
<p class="runtime"> <div><strong>{{ avg_completion }}%</strong><span>Avg completion</span></div>
Runtime: Django <code>{{ django_version }}</code> · Python <code>{{ python_version }}</code> <div><strong>{{ urgent_count }}</strong><span>Urgent flags</span></div>
— UTC <code>{{ current_time|date:"Y-m-d H:i:s" }}</code>
</p>
</div> </div>
</main> </div>
<footer> <div class="col-lg-5">
Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC) <div class="live-card glass-card">
</footer> <div class="d-flex justify-content-between align-items-center mb-4">
<div>
<span class="pulse-dot"></span>
<span class="small-label">Live Call Screen</span>
</div>
<span class="status-pill">Ytel audio: validate</span>
</div>
<div class="transcript-box mb-4">
<p><strong>Client:</strong> I owe around $24,000 to the IRS and Im worried about a levy.</p>
<p><strong>Agent:</strong> Which years are included, and what is your monthly income?</p>
</div>
<div class="row g-3">
<div class="col-6"><div class="metric-tile"><span>IRS Debt</span><strong>$24,000</strong></div></div>
<div class="col-6"><div class="metric-tile"><span>Employment</span><strong>1099</strong></div></div>
<div class="col-6"><div class="metric-tile urgent"><span>Alert</span><strong>Levy</strong></div></div>
<div class="col-6"><div class="metric-tile"><span>Completion</span><strong>80%</strong></div></div>
</div>
</div>
</div>
</div>
</div>
</section>
<section id="workflow" class="section-pad">
<div class="container">
<div class="row g-4 align-items-stretch">
<div class="col-lg-4">
<div class="section-kicker">First workflow</div>
<h2 class="section-title">Capture → score → review.</h2>
<p class="text-muted-custom">This first iteration intentionally keeps the media integration mocked, but the workflow stores real records so a tax office can test the intake logic immediately.</p>
</div>
<div class="col-lg-8">
<div class="row g-3">
<div class="col-md-4"><article class="step-card"><span>01</span><h3>Create</h3><p>Paste live transcript text and extracted intake values.</p></article></div>
<div class="col-md-4"><article class="step-card"><span>02</span><h3>Validate</h3><p>Track Ytel audio and Logics API readiness per pilot call.</p></article></div>
<div class="col-md-4"><article class="step-card"><span>03</span><h3>Coach</h3><p>Surface missing fields and urgent tax relief flags.</p></article></div>
</div>
</div>
</div>
</div>
</section>
<section class="section-pad pt-0">
<div class="container">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-end gap-3 mb-4">
<div>
<div class="section-kicker">Recent pilot calls</div>
<h2 class="section-title mb-0">History starts here.</h2>
</div>
<a class="btn btn-outline-signal" href="{% url 'call_list' %}">Open history</a>
</div>
{% if recent_calls %}
<div class="row g-3">
{% for call in recent_calls %}
<div class="col-md-4">
<a class="call-card-link" href="{% url 'call_detail' call.pk %}">
<article class="call-card h-100">
<div class="d-flex justify-content-between align-items-start gap-3">
<div><h3>{{ call.client_name }}</h3><p>{{ call.phone }}</p></div>
<span class="score-badge">{{ call.completion_score }}%</span>
</div>
<p class="mb-0 small-label">{{ call.get_call_source_display }} · {{ call.created_at|date:"M j, Y" }}</p>
</article>
</a>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<h3>No pilot calls yet</h3>
<p>Create the first call record to test transcript capture, intake fields, completion scoring, and urgent flags.</p>
<a class="btn btn-signal" href="{% url 'call_create' %}">Create first call</a>
</div>
{% endif %}
</div>
</section>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,94 @@
{% extends "base.html" %}
{% block title %}Webhook Receiver — ReliefSignal{% endblock %}
{% block meta_description %}Provider-neutral webhook receiver for testing external call-event payloads before adapting the integration to Ytel.{% endblock %}
{% block content %}
<section class="page-hero compact">
<div class="container">
<span class="eyebrow">Generic webhook inbox</span>
<div class="row align-items-end g-4 mt-2">
<div class="col-lg-8">
<h1 class="display-title">Capture provider events before we commit to Ytel-specific logic.</h1>
<p class="hero-copy mt-3">Use this receiver to collect real webhook payloads, inspect headers safely, and map the fields we need for call intake automation.</p>
</div>
<div class="col-lg-4 text-lg-end">
<a class="btn btn-signal" href="{% url 'call_list' %}">Back to call history</a>
</div>
</div>
</div>
</section>
<section class="section-pad">
<div class="container">
<div class="row g-4">
<div class="col-lg-5">
<div class="glass-card form-panel h-100">
<div class="panel-heading">
<span>01</span>
<h2>Receiver endpoint</h2>
</div>
<p class="text-muted-custom">Point test providers here. Add <strong>?provider=ytel</strong> when you start sending Ytel samples.</p>
<div class="endpoint-box mt-3">
<span>POST endpoint</span>
<code>{{ endpoint_url }}</code>
</div>
<pre class="endpoint-code mt-3"><code>curl -X POST "{{ endpoint_url }}?provider=test" \
-H "Content-Type: application/json" \
-d '{"event_type":"call.ended","external_id":"demo-call-001","status":"completed"}'</code></pre>
<ul class="readiness-list mt-4">
<li><span>JSON bodies</span><strong>Accepted</strong></li>
<li><span>Form posts</span><strong>Accepted</strong></li>
<li><span>Auth/signature headers</span><strong>Redacted</strong></li>
<li><span>Max body size</span><strong>512 KB</strong></li>
</ul>
</div>
</div>
<div class="col-lg-7">
<div class="glass-card history-shell h-100">
<div class="d-flex flex-column flex-md-row justify-content-between gap-3 align-items-md-center mb-4">
<div>
<span class="eyebrow">{{ total_events }} total received</span>
<h2 class="h3 fw-bold mb-0">Recent webhook events</h2>
</div>
<a class="btn btn-outline-signal btn-sm" href="/admin/core/webhookevent/">Open in admin</a>
</div>
{% if events %}
<div class="table-responsive">
<table class="table history-table align-middle">
<thead>
<tr>
<th>Received</th>
<th>Provider</th>
<th>Event</th>
<th>External ID</th>
<th>Preview</th>
</tr>
</thead>
<tbody>
{% for event in events %}
<tr>
<td><strong>#{{ event.pk }}</strong><br><span>{{ event.received_at|date:"M j, Y g:i A" }}</span></td>
<td><span class="mini-flag">{{ event.provider }}</span></td>
<td>{{ event.display_event_type }}</td>
<td>{% if event.external_id %}<code>{{ event.external_id }}</code>{% else %}<span class="text-muted-custom">Not supplied</span>{% endif %}</td>
<td><code class="webhook-preview">{{ event.raw_body|default:"No body"|truncatechars:110 }}</code></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state">
<h2>No webhooks received yet</h2>
<p>Send a test POST to the endpoint to verify connectivity, then we can map the real Ytel fields into pilot call records.</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</section>
{% endblock %}

View File

@ -1,7 +1,13 @@
from django.urls import path from django.urls import path
from .views import home from .views import audio_capture, call_create, call_detail, call_list, home, webhook_generic, webhook_list
urlpatterns = [ urlpatterns = [
path("", home, name="home"), 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

@ -1,25 +1,285 @@
import os import ipaddress
import platform import json
import logging
from django import get_version as django_version from django.contrib import messages
from django.shortcuts import render from django.db.models import Count
from django.utils import timezone from django.http import JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from .forms import PilotCallForm
from .models import PilotCall, WebhookEvent
logger = logging.getLogger(__name__)
MAX_WEBHOOK_BODY_BYTES = 512 * 1024
def home(request): def home(request):
"""Render the landing screen with loader and environment details.""" """Render the branded landing dashboard for the real-time intake pilot."""
host_name = request.get_host().lower() recent_calls = PilotCall.objects.all()[:3]
agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic" total_calls = PilotCall.objects.count()
now = timezone.now() urgent_count = sum(1 for call in PilotCall.objects.all() if call.urgent_flags)
avg_completion = 0
if total_calls:
avg_completion = round(sum(call.completion_score for call in PilotCall.objects.all()) / total_calls)
context = { context = {
"project_name": "New Style", 'project_name': 'ReliefSignal',
"agent_brand": agent_brand, 'meta_description': 'Real-time tax relief call intake pilot for live transcription, structured extraction, completion scoring, and CRM readiness.',
"django_version": django_version(), 'recent_calls': recent_calls,
"python_version": platform.python_version(), 'total_calls': total_calls,
"current_time": now, 'urgent_count': urgent_count,
"host_name": host_name, 'avg_completion': avg_completion,
"project_description": os.getenv("PROJECT_DESCRIPTION", ""),
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
} }
return render(request, "core/index.html", context) return render(request, 'core/index.html', context)
def call_list(request):
calls = PilotCall.objects.all()
summary = calls.aggregate(total=Count('id'))
context = {
'project_name': 'ReliefSignal',
'meta_description': 'Review tax relief pilot calls, completion scores, and urgency flags.',
'calls': calls,
'total_calls': summary['total'] or 0,
}
return render(request, 'core/call_list.html', context)
def call_create(request):
if request.method == 'POST':
form = PilotCallForm(request.POST)
if form.is_valid():
call = form.save()
messages.success(request, 'Pilot call saved. Review the completion score and missing intake fields below.')
return redirect('call_detail', pk=call.pk)
else:
form = PilotCallForm()
context = {
'project_name': 'ReliefSignal',
'meta_description': 'Create a tax relief pilot call intake from a live transcript segment.',
'form': form,
}
return render(request, 'core/call_form.html', context)
def call_detail(request, pk):
call = get_object_or_404(PilotCall, pk=pk)
intake_fields = [
('IRS Debt', call.irs_debt),
('State Debt', call.state_debt),
('Tax Years', call.tax_years),
('Filing Status', call.filing_status),
('Employment', call.employment_type),
('Occupation', call.occupation),
('Monthly Gross Income', call.monthly_gross_income),
('Spouse Income', call.spouse_income),
('Mortgage', call.mortgage),
('Rent', call.rent),
('Utilities', call.utilities),
('Car Payment', call.car_payment),
('Insurance', call.insurance),
('Student Loans', call.student_loans),
('Credit Cards', call.credit_cards),
('Business Expenses', call.business_expenses),
('Best Callback Time', call.best_callback_time),
]
context = {
'project_name': 'ReliefSignal',
'meta_description': f'Pilot call detail for {call.client_name}: transcript, extracted tax relief fields, urgency flags, and completion score.',
'call': call,
'intake_fields': intake_fields,
}
return render(request, 'core/call_detail.html', context)
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 = {
'project_name': 'ReliefSignal',
'meta_description': 'Provider-neutral webhook inbox for testing Ytel-style call events before a dedicated integration is finalized.',
'events': events,
'total_events': WebhookEvent.objects.count(),
'endpoint_url': _public_absolute_uri(request, reverse('webhook_generic')),
}
return render(request, 'core/webhook_list.html', context)
@csrf_exempt
@require_http_methods(['GET', 'POST'])
def webhook_generic(request):
if request.method == 'GET':
return JsonResponse({
'ok': True,
'name': 'ReliefSignal generic webhook receiver',
'usage': 'POST JSON or form payloads to this endpoint. Add ?provider=ytel later when testing Ytel deliveries.',
'accepted_methods': ['POST'],
})
body = request.body or b''
if len(body) > MAX_WEBHOOK_BODY_BYTES:
logger.warning('Rejected oversized webhook body: %s bytes', len(body))
return JsonResponse({'ok': False, 'error': 'Payload is too large.'}, status=413)
content_type = request.META.get('CONTENT_TYPE', '')[:180]
raw_body = body.decode('utf-8', errors='replace')
payload, parsed = _parse_webhook_payload(request, raw_body, content_type)
provider = _normalise_label(
request.GET.get('provider')
or request.headers.get('X-Webhook-Provider')
or _payload_lookup(payload, 'provider', 'source'),
fallback='generic',
max_length=60,
)
event_type = _normalise_label(
request.headers.get('X-Event-Type')
or request.headers.get('X-Webhook-Event')
or request.headers.get('X-Hook-Event')
or request.headers.get('X-Ytel-Event-Type')
or _payload_lookup(payload, 'event_type', 'eventType', 'event', 'type', 'action', 'status'),
fallback='',
max_length=120,
)
delivery_id = _normalise_label(
request.headers.get('X-Delivery-Id')
or request.headers.get('X-Webhook-Id')
or request.headers.get('X-Request-Id')
or request.headers.get('X-Event-Id')
or request.headers.get('X-Ytel-Delivery-Id')
or request.headers.get('X-Ytel-Event-Id')
or request.headers.get('Cf-Ray'),
fallback='',
max_length=160,
)
external_id = _normalise_label(
_payload_lookup(payload, 'external_id', 'externalId', 'event_id', 'eventId', 'id', 'call_id', 'callId', 'uuid', 'sid'),
fallback='',
max_length=160,
)
event = WebhookEvent.objects.create(
provider=provider,
event_type=event_type,
external_id=external_id,
delivery_id=delivery_id,
source_ip=_source_ip(request),
content_type=content_type,
headers=_safe_request_headers(request),
query_params=_query_params(request),
payload=payload,
raw_body=raw_body,
)
return JsonResponse({
'ok': True,
'accepted': True,
'parsed': parsed,
'id': event.pk,
'provider': event.provider,
'event_type': event.display_event_type,
'received_at': event.received_at.isoformat(),
}, status=202)
def _public_absolute_uri(request, path):
url = request.build_absolute_uri(path)
host = request.get_host().split(':')[0]
if host not in ('127.0.0.1', 'localhost') and url.startswith('http://'):
return 'https://' + url[len('http://'):]
return url
def _parse_webhook_payload(request, raw_body, content_type):
if raw_body and ('application/json' in content_type or raw_body.lstrip().startswith(('{', '['))):
try:
return json.loads(raw_body), True
except json.JSONDecodeError:
return {'_unparsed_preview': raw_body[:2000]}, False
if request.POST:
return _multi_value_dict(request.POST), True
if raw_body:
return {'_raw_preview': raw_body[:2000]}, False
return {}, True
def _safe_request_headers(request):
sensitive_markers = ('authorization', 'cookie', 'token', 'secret', 'key', 'signature')
safe_headers = {}
for name, value in request.headers.items():
if any(marker in name.lower() for marker in sensitive_markers):
safe_headers[name] = '[redacted]'
else:
safe_headers[name] = str(value)[:500]
return safe_headers
def _query_params(request):
return _multi_value_dict(request.GET)
def _multi_value_dict(query_dict):
return {
key: values if len(values := query_dict.getlist(key)) > 1 else query_dict.get(key)
for key in query_dict
}
def _payload_lookup(payload, *keys):
if not isinstance(payload, dict):
return None
for key in keys:
value = payload.get(key)
if value not in (None, ''):
return value
for nested_key in ('data', 'event', 'call', 'payload'):
nested = payload.get(nested_key)
if isinstance(nested, dict):
value = _payload_lookup(nested, *keys)
if value not in (None, ''):
return value
return None
def _normalise_label(value, fallback='', max_length=120):
if value in (None, ''):
return fallback
if isinstance(value, (dict, list)):
value = json.dumps(value, sort_keys=True)
return str(value).strip()[:max_length] or fallback
def _source_ip(request):
raw_ip = (
request.headers.get('Cf-Connecting-Ip')
or request.headers.get('X-Real-Ip')
or request.headers.get('X-Forwarded-For')
or request.META.get('REMOTE_ADDR')
or ''
)
candidate = raw_ip.split(',')[0].strip()
try:
ipaddress.ip_address(candidate)
except ValueError:
return None
return candidate

View File

@ -1,4 +1,431 @@
/* Custom styles for the application */ /* ReliefSignal custom brand layer — intentionally overrides default Bootstrap appearance. */
body { :root {
font-family: system-ui, -apple-system, sans-serif; --rs-ink: #10221f;
--rs-muted: #5f716d;
--rs-primary: #0f766e;
--rs-secondary: #f97316;
--rs-accent: #14b8a6;
--rs-cream: #fff8ed;
--rs-mint: #e7fbf5;
--rs-surface: #ffffff;
--rs-line: rgba(16, 34, 31, 0.12);
--rs-shadow: 0 24px 80px rgba(15, 118, 110, 0.16);
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: linear-gradient(180deg, #fffdf7 0%, #f5fbf7 54%, #fff8ed 100%);
color: var(--rs-ink);
min-height: 100vh;
}
.skip-link {
position: absolute;
left: 1rem;
top: -4rem;
z-index: 2000;
padding: .75rem 1rem;
background: var(--rs-ink);
color: #fff;
border-radius: 999px;
transition: top .2s ease;
}
.skip-link:focus { top: 1rem; }
.app-nav {
background: rgba(255, 253, 247, 0.82);
backdrop-filter: blur(18px);
border-bottom: 1px solid rgba(16, 34, 31, 0.08);
}
.brand-mark {
display: inline-flex;
align-items: center;
gap: .65rem;
font-weight: 800;
letter-spacing: -0.03em;
color: var(--rs-ink) !important;
}
.brand-icon {
display: inline-grid;
place-items: center;
width: 2.35rem;
height: 2.35rem;
border-radius: 1rem;
color: #fff;
background: linear-gradient(135deg, var(--rs-primary), var(--rs-secondary));
box-shadow: 0 12px 30px rgba(15, 118, 110, 0.28);
font-size: .82rem;
}
.nav-link {
color: rgba(16, 34, 31, 0.72) !important;
font-weight: 700;
}
.nav-link:hover, .nav-link:focus { color: var(--rs-primary) !important; }
.btn {
border-radius: 999px;
font-weight: 800;
padding: .85rem 1.3rem;
}
.btn-sm { padding: .45rem .85rem; }
.btn-signal {
background: linear-gradient(135deg, var(--rs-primary), #0d9488);
color: #fff;
border: 0;
box-shadow: 0 14px 34px rgba(15, 118, 110, 0.24);
}
.btn-signal:hover, .btn-signal:focus {
color: #fff;
transform: translateY(-1px);
box-shadow: 0 18px 42px rgba(15, 118, 110, 0.30);
}
.btn-ghost, .btn-outline-signal {
border: 1px solid rgba(15, 118, 110, .22);
color: var(--rs-primary);
background: rgba(255,255,255,.68);
}
.btn-ghost:hover, .btn-outline-signal:hover { background: var(--rs-mint); color: var(--rs-ink); }
.message-stack { position: fixed; top: 5.5rem; right: 0; left: 0; z-index: 1080; pointer-events: none; }
.message-stack .alert { max-width: 760px; margin-left: auto; pointer-events: auto; border-radius: 1rem; }
.hero-section {
background:
radial-gradient(circle at 16% 10%, rgba(249, 115, 22, .18), transparent 28%),
radial-gradient(circle at 88% 14%, rgba(20, 184, 166, .22), transparent 30%),
linear-gradient(135deg, #fff8ed 0%, #ecfff9 100%);
}
.py-lg-6 { padding-top: 6rem !important; padding-bottom: 6rem !important; }
.orb {
position: absolute;
border-radius: 999px;
filter: blur(2px);
opacity: .75;
}
.orb-one { width: 18rem; height: 18rem; background: rgba(249,115,22,.16); left: -6rem; top: 6rem; }
.orb-two { width: 22rem; height: 22rem; background: rgba(20,184,166,.18); right: -8rem; bottom: -5rem; }
.eyebrow, .section-kicker, .small-label {
display: inline-flex;
align-items: center;
gap: .45rem;
text-transform: uppercase;
letter-spacing: .14em;
font-size: .77rem;
font-weight: 900;
color: var(--rs-primary);
}
.display-title, .page-title, .section-title {
font-family: "Fraunces", Georgia, serif;
color: var(--rs-ink);
letter-spacing: -0.045em;
line-height: .98;
}
.display-title { font-size: clamp(3rem, 7vw, 6.6rem); max-width: 960px; }
.page-title { font-size: clamp(2.5rem, 5vw, 4.8rem); max-width: 980px; }
.section-title { font-size: clamp(2rem, 4vw, 3.4rem); }
.hero-copy {
max-width: 720px;
color: var(--rs-muted);
font-size: clamp(1.05rem, 1.4vw, 1.28rem);
line-height: 1.7;
}
.text-muted-custom { color: var(--rs-muted); }
.hero-proof {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.hero-proof div {
min-width: 135px;
padding: 1rem 1.15rem;
border: 1px solid var(--rs-line);
border-radius: 1.25rem;
background: rgba(255,255,255,.64);
}
.hero-proof strong { display: block; font-size: 1.7rem; color: var(--rs-primary); }
.hero-proof span { color: var(--rs-muted); font-size: .88rem; font-weight: 700; }
.glass-card, .form-panel, .detail-panel, .history-shell, .empty-state, .call-card, .step-card {
background: rgba(255, 255, 255, 0.78);
border: 1px solid rgba(16, 34, 31, 0.10);
box-shadow: var(--rs-shadow);
backdrop-filter: blur(16px);
}
.live-card { border-radius: 2rem; padding: 1.3rem; transform: rotate(1deg); }
.pulse-dot {
display: inline-block;
width: .78rem;
height: .78rem;
margin-right: .45rem;
border-radius: 999px;
background: var(--rs-secondary);
box-shadow: 0 0 0 .42rem rgba(249,115,22,.16);
}
.status-pill, .score-badge, .mini-flag, .alert-flag {
display: inline-flex;
align-items: center;
border-radius: 999px;
font-size: .78rem;
font-weight: 900;
}
.status-pill { padding: .5rem .75rem; color: #8a3d00; background: #fff0d8; }
.score-badge { padding: .55rem .7rem; color: #07564f; background: var(--rs-mint); }
.transcript-box, .transcript-review {
border-radius: 1.3rem;
background: #10221f;
color: #eaffe9;
padding: 1rem;
line-height: 1.65;
}
.transcript-box p:last-child, .transcript-review p:last-child { margin-bottom: 0; }
.metric-tile, .field-tile {
border-radius: 1.2rem;
padding: 1rem;
background: #fffdf8;
border: 1px solid var(--rs-line);
}
.metric-tile span, .field-tile span { display: block; color: var(--rs-muted); font-size: .82rem; font-weight: 800; }
.metric-tile strong, .field-tile strong { display: block; color: var(--rs-ink); font-size: 1.25rem; margin-top: .25rem; }
.metric-tile.urgent { background: #fff0d8; border-color: rgba(249,115,22,.35); }
.section-pad { padding: 5rem 0; }
.step-card, .call-card, .empty-state { border-radius: 1.5rem; padding: 1.35rem; height: 100%; }
.step-card span { color: var(--rs-secondary); font-weight: 900; }
.step-card h3, .call-card h3 { margin: .7rem 0 .45rem; font-weight: 900; letter-spacing: -.03em; }
.step-card p, .call-card p { color: var(--rs-muted); }
.call-card-link { color: inherit; text-decoration: none; display: block; height: 100%; }
.call-card-link:hover .call-card { transform: translateY(-3px); border-color: rgba(15,118,110,.28); }
.call-card, .btn, .step-card { transition: .18s ease; }
.empty-state { text-align: center; max-width: 760px; margin: 0 auto; padding: 3rem; }
.page-hero {
background: linear-gradient(135deg, #fff8ed 0%, #ecfff9 100%);
padding: 5rem 0 3rem;
border-bottom: 1px solid rgba(16,34,31,.08);
}
.page-hero.compact { padding-top: 4rem; }
.detail-hero { background: radial-gradient(circle at 78% 10%, rgba(249,115,22,.15), transparent 25%), linear-gradient(135deg, #ecfff9, #fff8ed); }
.form-panel, .detail-panel, .history-shell { border-radius: 1.6rem; padding: 1.4rem; }
.panel-heading {
display: flex;
align-items: baseline;
gap: .8rem;
margin-bottom: 1.2rem;
}
.panel-heading span { color: var(--rs-secondary); font-weight: 900; letter-spacing: .08em; text-transform: uppercase; font-size: .78rem; }
.panel-heading h2 { margin: 0; font-size: 1.25rem; font-weight: 900; letter-spacing: -.03em; }
.form-label { font-weight: 850; color: var(--rs-ink); }
.form-control, .form-select {
border-radius: 1rem;
border: 1px solid rgba(16,34,31,.14);
padding: .85rem 1rem;
background-color: rgba(255,255,255,.82);
}
.form-control:focus, .form-select:focus {
border-color: var(--rs-accent);
box-shadow: 0 0 0 .22rem rgba(20,184,166,.18);
}
.form-hint, .invalid-note { color: var(--rs-muted); font-size: .9rem; margin-top: .5rem; }
.invalid-note { color: #b42318; font-weight: 800; }
.checks-grid { display: grid; gap: .9rem; }
.signal-check {
display: flex;
align-items: center;
gap: .75rem;
padding: .85rem 1rem;
border-radius: 1rem;
background: #fffdf8;
border: 1px solid var(--rs-line);
font-weight: 850;
}
.form-check-input:checked { background-color: var(--rs-primary); border-color: var(--rs-primary); }
.history-table { margin: 0; }
.history-table thead th {
color: var(--rs-muted);
text-transform: uppercase;
letter-spacing: .08em;
font-size: .75rem;
border-bottom-color: var(--rs-line);
}
.history-table td { padding: 1rem .75rem; border-color: rgba(16,34,31,.08); }
.history-table td span { color: var(--rs-muted); font-size: .9rem; }
.mini-flag { padding: .35rem .55rem; margin: .1rem; color: #8a3d00; background: #fff0d8; }
.alert-flag { padding: .65rem .8rem; color: #8a3d00; background: #fff0d8; }
.flag-stack { display: flex; flex-wrap: wrap; gap: .5rem; }
.score-circle {
width: 8.25rem;
height: 8.25rem;
border-radius: 999px;
display: grid;
place-items: center;
text-align: center;
background: conic-gradient(var(--rs-primary), var(--rs-accent), var(--rs-secondary), var(--rs-primary));
color: #fff;
box-shadow: var(--rs-shadow);
}
.score-circle strong { display: block; font-size: 2rem; line-height: 1; }
.score-circle span { display: block; font-size: .82rem; font-weight: 900; text-transform: uppercase; letter-spacing: .08em; }
.field-tile.missing { background: #fff7ed; border-color: rgba(249,115,22,.28); }
.field-tile.missing strong { color: #9a3412; }
.readiness-list, .missing-list { list-style: none; padding: 0; margin: 0; display: grid; gap: .8rem; }
.readiness-list li, .missing-list li {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: .85rem 0;
border-bottom: 1px solid rgba(16,34,31,.08);
}
.readiness-list span { color: var(--rs-muted); font-weight: 800; }
.readiness-list strong { text-align: right; }
.missing-list li { display: block; color: #9a3412; font-weight: 900; }
.site-footer {
padding: 2rem 0;
color: var(--rs-muted);
border-top: 1px solid rgba(16,34,31,.08);
background: rgba(255,253,247,.74);
font-weight: 700;
}
@media (max-width: 767px) {
.section-pad { padding: 3.25rem 0; }
.live-card { transform: none; }
.hero-proof div { flex: 1 1 100%; }
.empty-state { padding: 2rem 1.2rem; }
.score-circle { width: 7rem; height: 7rem; }
}
.endpoint-box {
display: grid;
gap: .45rem;
border-radius: 1.1rem;
padding: 1rem;
background: #fffdf8;
border: 1px solid var(--rs-line);
}
.endpoint-box span { color: var(--rs-muted); font-size: .78rem; font-weight: 900; letter-spacing: .08em; text-transform: uppercase; }
.endpoint-box code, .endpoint-code code, .webhook-preview { color: var(--rs-ink); white-space: pre-wrap; word-break: break-word; }
.endpoint-code {
border-radius: 1.15rem;
padding: 1rem;
background: #10221f;
color: #ecfff9;
border: 1px solid rgba(255,255,255,.08);
overflow-x: auto;
}
.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

@ -1,21 +1,431 @@
/* ReliefSignal custom brand layer — intentionally overrides default Bootstrap appearance. */
:root { :root {
--bg-color-start: #6a11cb; --rs-ink: #10221f;
--bg-color-end: #2575fc; --rs-muted: #5f716d;
--text-color: #ffffff; --rs-primary: #0f766e;
--card-bg-color: rgba(255, 255, 255, 0.01); --rs-secondary: #f97316;
--card-border-color: rgba(255, 255, 255, 0.1); --rs-accent: #14b8a6;
--rs-cream: #fff8ed;
--rs-mint: #e7fbf5;
--rs-surface: #ffffff;
--rs-line: rgba(16, 34, 31, 0.12);
--rs-shadow: 0 24px 80px rgba(15, 118, 110, 0.16);
} }
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body { body {
margin: 0; margin: 0;
font-family: 'Inter', sans-serif; font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end)); background: linear-gradient(180deg, #fffdf7 0%, #f5fbf7 54%, #fff8ed 100%);
color: var(--text-color); color: var(--rs-ink);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh; min-height: 100vh;
text-align: center; }
overflow: hidden;
position: relative; .skip-link {
position: absolute;
left: 1rem;
top: -4rem;
z-index: 2000;
padding: .75rem 1rem;
background: var(--rs-ink);
color: #fff;
border-radius: 999px;
transition: top .2s ease;
}
.skip-link:focus { top: 1rem; }
.app-nav {
background: rgba(255, 253, 247, 0.82);
backdrop-filter: blur(18px);
border-bottom: 1px solid rgba(16, 34, 31, 0.08);
}
.brand-mark {
display: inline-flex;
align-items: center;
gap: .65rem;
font-weight: 800;
letter-spacing: -0.03em;
color: var(--rs-ink) !important;
}
.brand-icon {
display: inline-grid;
place-items: center;
width: 2.35rem;
height: 2.35rem;
border-radius: 1rem;
color: #fff;
background: linear-gradient(135deg, var(--rs-primary), var(--rs-secondary));
box-shadow: 0 12px 30px rgba(15, 118, 110, 0.28);
font-size: .82rem;
}
.nav-link {
color: rgba(16, 34, 31, 0.72) !important;
font-weight: 700;
}
.nav-link:hover, .nav-link:focus { color: var(--rs-primary) !important; }
.btn {
border-radius: 999px;
font-weight: 800;
padding: .85rem 1.3rem;
}
.btn-sm { padding: .45rem .85rem; }
.btn-signal {
background: linear-gradient(135deg, var(--rs-primary), #0d9488);
color: #fff;
border: 0;
box-shadow: 0 14px 34px rgba(15, 118, 110, 0.24);
}
.btn-signal:hover, .btn-signal:focus {
color: #fff;
transform: translateY(-1px);
box-shadow: 0 18px 42px rgba(15, 118, 110, 0.30);
}
.btn-ghost, .btn-outline-signal {
border: 1px solid rgba(15, 118, 110, .22);
color: var(--rs-primary);
background: rgba(255,255,255,.68);
}
.btn-ghost:hover, .btn-outline-signal:hover { background: var(--rs-mint); color: var(--rs-ink); }
.message-stack { position: fixed; top: 5.5rem; right: 0; left: 0; z-index: 1080; pointer-events: none; }
.message-stack .alert { max-width: 760px; margin-left: auto; pointer-events: auto; border-radius: 1rem; }
.hero-section {
background:
radial-gradient(circle at 16% 10%, rgba(249, 115, 22, .18), transparent 28%),
radial-gradient(circle at 88% 14%, rgba(20, 184, 166, .22), transparent 30%),
linear-gradient(135deg, #fff8ed 0%, #ecfff9 100%);
}
.py-lg-6 { padding-top: 6rem !important; padding-bottom: 6rem !important; }
.orb {
position: absolute;
border-radius: 999px;
filter: blur(2px);
opacity: .75;
}
.orb-one { width: 18rem; height: 18rem; background: rgba(249,115,22,.16); left: -6rem; top: 6rem; }
.orb-two { width: 22rem; height: 22rem; background: rgba(20,184,166,.18); right: -8rem; bottom: -5rem; }
.eyebrow, .section-kicker, .small-label {
display: inline-flex;
align-items: center;
gap: .45rem;
text-transform: uppercase;
letter-spacing: .14em;
font-size: .77rem;
font-weight: 900;
color: var(--rs-primary);
}
.display-title, .page-title, .section-title {
font-family: "Fraunces", Georgia, serif;
color: var(--rs-ink);
letter-spacing: -0.045em;
line-height: .98;
}
.display-title { font-size: clamp(3rem, 7vw, 6.6rem); max-width: 960px; }
.page-title { font-size: clamp(2.5rem, 5vw, 4.8rem); max-width: 980px; }
.section-title { font-size: clamp(2rem, 4vw, 3.4rem); }
.hero-copy {
max-width: 720px;
color: var(--rs-muted);
font-size: clamp(1.05rem, 1.4vw, 1.28rem);
line-height: 1.7;
}
.text-muted-custom { color: var(--rs-muted); }
.hero-proof {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.hero-proof div {
min-width: 135px;
padding: 1rem 1.15rem;
border: 1px solid var(--rs-line);
border-radius: 1.25rem;
background: rgba(255,255,255,.64);
}
.hero-proof strong { display: block; font-size: 1.7rem; color: var(--rs-primary); }
.hero-proof span { color: var(--rs-muted); font-size: .88rem; font-weight: 700; }
.glass-card, .form-panel, .detail-panel, .history-shell, .empty-state, .call-card, .step-card {
background: rgba(255, 255, 255, 0.78);
border: 1px solid rgba(16, 34, 31, 0.10);
box-shadow: var(--rs-shadow);
backdrop-filter: blur(16px);
}
.live-card { border-radius: 2rem; padding: 1.3rem; transform: rotate(1deg); }
.pulse-dot {
display: inline-block;
width: .78rem;
height: .78rem;
margin-right: .45rem;
border-radius: 999px;
background: var(--rs-secondary);
box-shadow: 0 0 0 .42rem rgba(249,115,22,.16);
}
.status-pill, .score-badge, .mini-flag, .alert-flag {
display: inline-flex;
align-items: center;
border-radius: 999px;
font-size: .78rem;
font-weight: 900;
}
.status-pill { padding: .5rem .75rem; color: #8a3d00; background: #fff0d8; }
.score-badge { padding: .55rem .7rem; color: #07564f; background: var(--rs-mint); }
.transcript-box, .transcript-review {
border-radius: 1.3rem;
background: #10221f;
color: #eaffe9;
padding: 1rem;
line-height: 1.65;
}
.transcript-box p:last-child, .transcript-review p:last-child { margin-bottom: 0; }
.metric-tile, .field-tile {
border-radius: 1.2rem;
padding: 1rem;
background: #fffdf8;
border: 1px solid var(--rs-line);
}
.metric-tile span, .field-tile span { display: block; color: var(--rs-muted); font-size: .82rem; font-weight: 800; }
.metric-tile strong, .field-tile strong { display: block; color: var(--rs-ink); font-size: 1.25rem; margin-top: .25rem; }
.metric-tile.urgent { background: #fff0d8; border-color: rgba(249,115,22,.35); }
.section-pad { padding: 5rem 0; }
.step-card, .call-card, .empty-state { border-radius: 1.5rem; padding: 1.35rem; height: 100%; }
.step-card span { color: var(--rs-secondary); font-weight: 900; }
.step-card h3, .call-card h3 { margin: .7rem 0 .45rem; font-weight: 900; letter-spacing: -.03em; }
.step-card p, .call-card p { color: var(--rs-muted); }
.call-card-link { color: inherit; text-decoration: none; display: block; height: 100%; }
.call-card-link:hover .call-card { transform: translateY(-3px); border-color: rgba(15,118,110,.28); }
.call-card, .btn, .step-card { transition: .18s ease; }
.empty-state { text-align: center; max-width: 760px; margin: 0 auto; padding: 3rem; }
.page-hero {
background: linear-gradient(135deg, #fff8ed 0%, #ecfff9 100%);
padding: 5rem 0 3rem;
border-bottom: 1px solid rgba(16,34,31,.08);
}
.page-hero.compact { padding-top: 4rem; }
.detail-hero { background: radial-gradient(circle at 78% 10%, rgba(249,115,22,.15), transparent 25%), linear-gradient(135deg, #ecfff9, #fff8ed); }
.form-panel, .detail-panel, .history-shell { border-radius: 1.6rem; padding: 1.4rem; }
.panel-heading {
display: flex;
align-items: baseline;
gap: .8rem;
margin-bottom: 1.2rem;
}
.panel-heading span { color: var(--rs-secondary); font-weight: 900; letter-spacing: .08em; text-transform: uppercase; font-size: .78rem; }
.panel-heading h2 { margin: 0; font-size: 1.25rem; font-weight: 900; letter-spacing: -.03em; }
.form-label { font-weight: 850; color: var(--rs-ink); }
.form-control, .form-select {
border-radius: 1rem;
border: 1px solid rgba(16,34,31,.14);
padding: .85rem 1rem;
background-color: rgba(255,255,255,.82);
}
.form-control:focus, .form-select:focus {
border-color: var(--rs-accent);
box-shadow: 0 0 0 .22rem rgba(20,184,166,.18);
}
.form-hint, .invalid-note { color: var(--rs-muted); font-size: .9rem; margin-top: .5rem; }
.invalid-note { color: #b42318; font-weight: 800; }
.checks-grid { display: grid; gap: .9rem; }
.signal-check {
display: flex;
align-items: center;
gap: .75rem;
padding: .85rem 1rem;
border-radius: 1rem;
background: #fffdf8;
border: 1px solid var(--rs-line);
font-weight: 850;
}
.form-check-input:checked { background-color: var(--rs-primary); border-color: var(--rs-primary); }
.history-table { margin: 0; }
.history-table thead th {
color: var(--rs-muted);
text-transform: uppercase;
letter-spacing: .08em;
font-size: .75rem;
border-bottom-color: var(--rs-line);
}
.history-table td { padding: 1rem .75rem; border-color: rgba(16,34,31,.08); }
.history-table td span { color: var(--rs-muted); font-size: .9rem; }
.mini-flag { padding: .35rem .55rem; margin: .1rem; color: #8a3d00; background: #fff0d8; }
.alert-flag { padding: .65rem .8rem; color: #8a3d00; background: #fff0d8; }
.flag-stack { display: flex; flex-wrap: wrap; gap: .5rem; }
.score-circle {
width: 8.25rem;
height: 8.25rem;
border-radius: 999px;
display: grid;
place-items: center;
text-align: center;
background: conic-gradient(var(--rs-primary), var(--rs-accent), var(--rs-secondary), var(--rs-primary));
color: #fff;
box-shadow: var(--rs-shadow);
}
.score-circle strong { display: block; font-size: 2rem; line-height: 1; }
.score-circle span { display: block; font-size: .82rem; font-weight: 900; text-transform: uppercase; letter-spacing: .08em; }
.field-tile.missing { background: #fff7ed; border-color: rgba(249,115,22,.28); }
.field-tile.missing strong { color: #9a3412; }
.readiness-list, .missing-list { list-style: none; padding: 0; margin: 0; display: grid; gap: .8rem; }
.readiness-list li, .missing-list li {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: .85rem 0;
border-bottom: 1px solid rgba(16,34,31,.08);
}
.readiness-list span { color: var(--rs-muted); font-weight: 800; }
.readiness-list strong { text-align: right; }
.missing-list li { display: block; color: #9a3412; font-weight: 900; }
.site-footer {
padding: 2rem 0;
color: var(--rs-muted);
border-top: 1px solid rgba(16,34,31,.08);
background: rgba(255,253,247,.74);
font-weight: 700;
}
@media (max-width: 767px) {
.section-pad { padding: 3.25rem 0; }
.live-card { transform: none; }
.hero-proof div { flex: 1 1 100%; }
.empty-state { padding: 2rem 1.2rem; }
.score-circle { width: 7rem; height: 7rem; }
}
.endpoint-box {
display: grid;
gap: .45rem;
border-radius: 1.1rem;
padding: 1rem;
background: #fffdf8;
border: 1px solid var(--rs-line);
}
.endpoint-box span { color: var(--rs-muted); font-size: .78rem; font-weight: 900; letter-spacing: .08em; text-transform: uppercase; }
.endpoint-box code, .endpoint-code code, .webhook-preview { color: var(--rs-ink); white-space: pre-wrap; word-break: break-word; }
.endpoint-code {
border-radius: 1.15rem;
padding: 1rem;
background: #10221f;
color: #ecfff9;
border: 1px solid rgba(255,255,255,.08);
overflow-x: auto;
}
.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]}`;
}
})();