feat(voice): render the voice control from a presenter

createVoiceControl now returns a presenter instead of building the
control with innerHTML: the session, cost, input, card and panel owners
write its state by part and listen for interactions, and VoiceControl.vue
renders it in the command dock, between the location bar and the visual
presets as before. Focus moves wait for the render; the waveform's bars
publish apart from the control and only when they change; the minutes
field keeps a draft until its owner writes it. Owner tests use a real
presenter on their own store, and view tests render the control.
This commit is contained in:
Sameh Khamis committed 2026-10-31 19:22:03 -07:00
1 parent 2ffd7a4afd
commit 1de4f635ca
47 files changed
+1691 -1645

No files matched your search

+31 -36
View File
@@ -11,8 +11,8 @@ import {
resolveVoiceVisualizerSpeaker,
resolveVoiceControlHint,
gateVoiceVisualizerLevel,
resetVoiceVisualizerBars,
} from './realtimeInputPolicy.js';
import { VOICE_VISUALIZER_BARS } from './control.js';
import { shouldPauseRadioForVoice } from './realtimeProtocol.js';
/** Own physical push-to-talk gestures, microphone controls and audio meters. */
@@ -116,7 +116,7 @@ export class RealtimeInput {
// off if it is talking. Short taps never reach this point.
if (mayInterrupt) this.bargeIn?.();
this.beginPointerTurn?.('keydown');
this.ui.root.dataset.pushToTalk = 'held';
this.ui.update('root', { pushToTalk: 'held' });
this.setMicrophoneEnabled(true);
if (this.status === 'listening')
this.setStatus('listening', t('Release Space to send'));
@@ -124,7 +124,8 @@ export class RealtimeInput {
this.start({ pushToTalk: true });
// start() performs a controlled stop() before connecting. Restore the
// marker it clears so the UI reflects this still-held gesture.
if (this.pushToTalkKeyHeld) this.ui.root.dataset.pushToTalk = 'held';
if (this.pushToTalkKeyHeld)
this.ui.update('root', { pushToTalk: 'held' });
}
}, PUSH_TO_TALK_HOLD_DELAY_MS);
};
@@ -191,7 +192,7 @@ export class RealtimeInput {
this.releasePointerHold?.();
if (!this.pushToTalkKeyHeld) return;
this.pushToTalkKeyHeld = false;
delete this.ui.root.dataset.pushToTalk;
this.ui.update('root', { pushToTalk: null });
if (!this.pushToTalkMode) return;
this.setMicrophoneEnabled(false);
if (this.status === 'listening')
@@ -205,8 +206,7 @@ export class RealtimeInput {
* @returns {void}
*/
setMicrophoneEnabled(enabled) {
if (this.ui?.root)
this.ui.root.dataset.microphone = enabled ? 'active' : 'muted';
this.ui?.update('root', { microphone: enabled ? 'active' : 'muted' });
this.stream?.getAudioTracks?.().forEach((track) => {
track.enabled = Boolean(enabled);
});
@@ -220,10 +220,7 @@ export class RealtimeInput {
startVoiceVisualizer(stream) {
this.stopVoiceVisualizer();
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
const bars = Array.from(
this.ui.root.querySelectorAll('.gev-voice-visualizer span'),
);
if (!AudioContextClass || !stream || !bars.length) return;
if (!AudioContextClass || !stream || !this.ui) return;
try {
const context = new AudioContextClass();
this.visualizerAudioContext = context;
@@ -252,17 +249,18 @@ export class RealtimeInput {
},
);
if (!signal) {
resetVoiceVisualizerBars(bars);
this.restVoiceVisualizer();
this.visualizerFrame = requestAnimationFrame(render);
return;
}
signal.analyser.getByteFrequencyData(signal.data);
const binCount = signal.data.length;
bars.forEach((bar, index) => {
const start = Math.floor((index / bars.length) * binCount);
const count = VOICE_VISUALIZER_BARS;
const bars = Array.from({ length: count }, (_, index) => {
const start = Math.floor((index / count) * binCount);
const end = Math.max(
start + 1,
Math.floor(((index + 1) / bars.length) * binCount),
Math.floor(((index + 1) / count) * binCount),
);
let energy = 0;
for (let bin = start; bin < end; bin++) energy += signal.data[bin];
@@ -275,15 +273,12 @@ export class RealtimeInput {
gateVoiceVisualizerLevel(normalized, gate),
0.72,
);
bar.style.setProperty(
'--audio-level',
`${Math.round(5 + shaped * 29)}px`,
);
bar.style.setProperty(
'--audio-opacity',
`${(0.5 + shaped * 0.5).toFixed(2)}`,
);
return {
level: `${Math.round(5 + shaped * 29)}px`,
opacity: `${(0.5 + shaped * 0.5).toFixed(2)}`,
};
});
this.ui?.setBars(bars);
this.visualizerFrame = requestAnimationFrame(render);
};
render();
@@ -353,9 +348,12 @@ export class RealtimeInput {
this.visualizerAudioContext.close().catch(() => {});
this.visualizerAudioContext = null;
}
resetVoiceVisualizerBars(
this.ui?.root?.querySelectorAll('.gev-voice-visualizer span'),
);
this.restVoiceVisualizer();
}
/** Return the waveform to its CSS standby baseline. */
restVoiceVisualizer() {
this.ui?.setBars(null);
}
/**
@@ -363,15 +361,15 @@ export class RealtimeInput {
* @returns {void}
*/
updateVoiceButtonLabel() {
if (!this.ui.buttonLabel) return;
if (!this.ui) return;
// The mic button's caption; the first-run tip names it the same way.
this.ui.buttonLabel.textContent = t('MIC');
if (this.ui.helpDetail) {
this.ui.helpDetail.textContent = resolveVoiceControlHint(
this.ui.update('mic', { text: t('MIC') });
this.ui.update('help', {
text: resolveVoiceControlHint(
this.pushToTalkMode,
this.pushToTalkKeyHeld,
);
}
),
});
}
setVoiceSpeaker(speaker, { keepVisualizerSpeaker = false } = {}) {
@@ -382,7 +380,7 @@ export class RealtimeInput {
nextSpeaker,
keepVisualizerSpeaker,
);
this.ui.root.dataset.speaker = nextSpeaker;
this.ui.update('root', { speaker: nextSpeaker });
if (shouldPauseRadioForVoice({ speaker: nextSpeaker }))
this.pauseRadioForVoice();
}
@@ -392,10 +390,7 @@ export class RealtimeInput {
this.pushToTalkKeyHeld = false;
this.spaceKeyHeld = false;
this.resetPushToTalkGesture();
if (this.ui?.root) {
delete this.ui.root.dataset.pushToTalk;
delete this.ui.root.dataset.microphone;
}
this.ui?.update('root', { pushToTalk: null, microphone: null });
}
detachBindings() {