feat(conversation): red/blue mic button with aria-pressed state
Red while the mic is hot (live or recording), blue when off; label toggles Stop/Start microphone (DE+EN); pulse only without reduced motion; reset to blue when microphone access fails. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
0a30b00d7f
commit
a3ba3581fe
@ -675,6 +675,10 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
|
||||
function resetConversationInput(statusText = 'Ready') {
|
||||
liveInterimText = '';
|
||||
previewTranscribing = false;
|
||||
// Red = hot (live or recording); the label/pressed state carries it for non-colour users.
|
||||
const hot = liveMicEnabled || micBtn.classList.contains('recording');
|
||||
micBtn.setAttribute('aria-pressed', hot ? 'true' : 'false');
|
||||
micBtn.setAttribute('aria-label', tr(hot ? 'Stop microphone' : 'Start microphone'));
|
||||
isProcessing = false;
|
||||
micBtn.classList.remove('processing', 'recording');
|
||||
micIcon.className = 'mdi mdi-microphone';
|
||||
@ -846,12 +850,19 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
|
||||
toast('Microphone unavailable — browser requires a secure context (HTTPS or localhost). ' +
|
||||
'Access the app via http://localhost:7890 or enable it in chrome://flags/#unsafely-treat-insecure-origin-as-secure', 'error', 8000);
|
||||
return;
|
||||
function micOffAfterFailure() { // never leave the button red when no mic is open
|
||||
liveMicEnabled = false;
|
||||
micBtn.classList.remove('recording');
|
||||
setLiveMicUi('Mic off');
|
||||
}
|
||||
|
||||
}
|
||||
let stream;
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
} catch(e) {
|
||||
toast('Microphone access denied: ' + e.message, 'error');
|
||||
micOffAfterFailure();
|
||||
return;
|
||||
}
|
||||
|
||||
@ -859,6 +870,7 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
|
||||
previewTranscribing = false;
|
||||
vadHadSpeech = false;
|
||||
vadLastVoiceMs = 0;
|
||||
micOffAfterFailure();
|
||||
vadSpeechStartMs = 0;
|
||||
cancelNextBlob = false;
|
||||
recChunks = [];
|
||||
@ -1020,6 +1032,7 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
|
||||
|
||||
// ── Send turn via SSE ─────────────────────────────────────────────────────
|
||||
// Decode a recorded blob (webm/opus, ogg, …) in the browser and re-encode it
|
||||
setLiveMicUi();
|
||||
// as a 16 kHz mono WAV. This sidesteps server-side ffmpeg, whose webm/EBML
|
||||
// parser fails on some platforms (ARM64): "EBML header parsing failed".
|
||||
// Returns a WAV Blob, or null if decoding isn't possible (caller falls back).
|
||||
|
||||
@ -94,6 +94,8 @@ const I18N_DICT = {
|
||||
'Thinking': 'Denken',
|
||||
'on': 'an', 'off': 'aus',
|
||||
'Off = faster answers. On = better reasoning but slower, and some models may return an empty reply.': 'Aus = schnellere Antworten. An = besseres Schlussfolgern, aber langsamer; manche Modelle liefern eine leere Antwort.',
|
||||
// Conversation mic button
|
||||
'Stop microphone': 'Mikrofon stoppen', 'Start microphone': 'Mikrofon starten',
|
||||
// Conversation metrics view
|
||||
'Token Usage': 'Token-Verbrauch', 'Time (ms)': 'Zeit (ms)',
|
||||
'Prompt': 'Prompt', 'Completion': 'Completion',
|
||||
|
||||
@ -4359,11 +4359,15 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
||||
justify-content: center; transition: background .15s, transform .1s, box-shadow .15s;
|
||||
}
|
||||
.conv-mic-btn:hover { background: #1d4ed8; }
|
||||
.conv-mic-btn.live { background: var(--accent); border-color: var(--accent); box-shadow: 0 0 0 4px rgba(37,99,235,.16); }
|
||||
.conv-mic-btn.live:not(.processing) { animation: convPulse 1.4s infinite; }
|
||||
.conv-mic-btn.recording:not(.live) { background: var(--red); border-color: var(--red); animation: convPulse 1s infinite; }
|
||||
.conv-mic-btn.live { background: var(--red); border-color: var(--red); box-shadow: 0 0 0 4px rgba(220,38,38,.16); }
|
||||
.conv-mic-btn.live:hover, .conv-mic-btn.recording:hover { filter: brightness(.9); }
|
||||
.conv-mic-btn.recording:not(.live) { background: var(--red); border-color: var(--red); }
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.conv-mic-btn.live:not(.processing) { animation: convPulse 1.4s infinite; }
|
||||
.conv-mic-btn.recording:not(.live) { animation: convPulse 1s infinite; }
|
||||
}
|
||||
.conv-mic-btn.processing { background: var(--yellow); border-color: var(--yellow); cursor: pointer; }
|
||||
.conv-mic-btn.processing.live { background: var(--accent); border-color: var(--accent); }
|
||||
.conv-mic-btn.processing.live { background: var(--red); border-color: var(--red); }
|
||||
@keyframes convPulse { 0%,100% { box-shadow: 0 0 0 0 rgba(220,38,38,.4); } 50% { box-shadow: 0 0 0 8px rgba(220,38,38,0); } }
|
||||
.conv-mic-timer { font-size: 13px; font-variant-numeric: tabular-nums; color: var(--red); min-width: 36px; text-align: right; }
|
||||
.conv-vad-label { display: flex; align-items: center; gap: 4px; font-size: 11px; color: var(--subtext); cursor: pointer; white-space: nowrap; user-select: none; }
|
||||
|
||||
54
tests/conversation-mic.spec.js
Normal file
54
tests/conversation-mic.spec.js
Normal file
@ -0,0 +1,54 @@
|
||||
// @ts-check
|
||||
// Conversation mic button: red while hot, blue when off, aria toggles (all /api routes mocked).
|
||||
const { test, expect } = require('./_guard');
|
||||
|
||||
async function boot(page, { denied = false } = {}) {
|
||||
await page.route('**/api/**', route => route.request().method() === 'GET' ? route.fulfill({ json: {} }) : route.fulfill({ json: { ok: true } }));
|
||||
await page.route('**/static/dist/main.min.js*', r => r.abort());
|
||||
await page.addInitScript(d => {
|
||||
const gum = d ? () => Promise.reject(new Error('denied')) : () => Promise.resolve({ getTracks: () => [{ stop() {} }] });
|
||||
Object.defineProperty(navigator, 'mediaDevices', { value: { getUserMedia: gum }, configurable: true });
|
||||
window.MediaRecorder = class { constructor() { this.state = 'inactive'; this.mimeType = 'audio/webm'; }
|
||||
start() { this.state = 'recording'; } stop() { this.state = 'inactive'; setTimeout(() => this.onstop && this.onstop(), 20); } };
|
||||
window.MediaRecorder.isTypeSupported = () => true;
|
||||
}, denied);
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 });
|
||||
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
|
||||
await page.evaluate(() => window.navTo('s-conversation'));
|
||||
}
|
||||
const bg = async (/** @type {import('@playwright/test').Page} */ page) => { await page.mouse.move(0, 0); return page.locator('#conv-mic-btn').evaluate(e => getComputedStyle(e).backgroundColor); };
|
||||
|
||||
test('mic is blue and unpressed when off, red and pressed when hot, back to blue after stop', async ({ page }) => {
|
||||
await boot(page);
|
||||
const btn = page.locator('#conv-mic-btn');
|
||||
await expect(btn).toHaveAttribute('aria-pressed', 'false');
|
||||
await expect(btn).toHaveAttribute('aria-label', 'Start microphone');
|
||||
const blue = await bg(page);
|
||||
await btn.click();
|
||||
await expect(btn).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(btn).toHaveAttribute('aria-label', 'Stop microphone');
|
||||
await expect(page.locator('#conv-mic-status')).toHaveText('Listening…');
|
||||
await expect.poll(() => bg(page)).not.toBe(blue);
|
||||
await expect.poll(async () => { const [r, g, b] = (await bg(page)).match(/\d+/g).map(Number); return r > 200 && g < 90 && b < 90; }).toBe(true); // --red
|
||||
await btn.click();
|
||||
await expect(btn).toHaveAttribute('aria-pressed', 'false');
|
||||
await expect(page.locator('#conv-mic-status')).toHaveText('Mic off');
|
||||
await expect.poll(() => bg(page)).toBe(blue);
|
||||
});
|
||||
|
||||
test('mic never stays red when microphone access is denied', async ({ page }) => {
|
||||
await boot(page, { denied: true });
|
||||
const btn = page.locator('#conv-mic-btn');
|
||||
const blue = await bg(page);
|
||||
await btn.click();
|
||||
await expect(btn).toHaveAttribute('aria-pressed', 'false');
|
||||
await expect(page.locator('#conv-mic-status')).toHaveText('Mic off');
|
||||
await expect.poll(() => bg(page)).toBe(blue);
|
||||
});
|
||||
|
||||
test('German mic labels', async ({ page }) => {
|
||||
await page.addInitScript(() => { try { localStorage.setItem('app-lang', 'de'); } catch (e) {} });
|
||||
await boot(page);
|
||||
await expect(page.locator('#conv-mic-btn')).toHaveAttribute('aria-label', 'Mikrofon starten');
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user