import telemetry from '/static/telemetry.js';
import { createMicOverlay } from '/static/mic-overlay.js';
export function createAttachAction({ send, onError } = {}) {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = '*/*';
fileInput.style.display = 'none';
document.body.appendChild(fileInput);
async function uploadFile(file) {
const form = new FormData();
form.append('file', file);
const res = await window.MobuxMesh.apiFetch('/api/upload', { method: 'POST', body: form });
if (!res.ok) throw new Error(await res.text());
const { path } = await res.json();
send(path);
}
fileInput.addEventListener('change', async () => {
const file = fileInput.files?.[0];
if (!file) return;
try {
await uploadFile(file);
} catch (err) {
console.error('Upload failed:', err);
onError?.('Attach failed: upload error');
}
fileInput.value = '';
});
return {
trigger() { fileInput.click(); },
};
}
const TARGET_RATE = 16000;
const MAX_SECONDS = 60;
export function createDictateAction({ send, button, onText } = {}) {
const mic = {
recording: false,
busy: false,
stream: null,
ctx: null,
source: null,
analyser: null,
processor: null,
chunks: [],
inputRate: 0,
timer: null,
deadline: null,
startedAt: 0,
paused: false,
pendingChunks: null,
pendingRate: 0,
pendingDurationMs: 0,
};
function micLabel(text) {
if (button) button.textContent = text;
}
const micOverlay = createMicOverlay({
onStop: () => { if (mic.recording) captureStop(); },
onPause: () => {
mic.paused = true;
telemetry.log('mic.pause');
},
onResume: () => {
mic.paused = false;
telemetry.log('mic.resume');
},
onCancel: () => { cancelRecording(); },
onDismiss: () => {
mic.recording = false;
mic.busy = false;
mic.paused = false;
mic.pendingChunks = null;
stopTracks();
button?.classList.remove('mic-recording');
micLabel('🎤');
},
onRetry: () => { retryFresh(); },
onSubmit: (text) => { submitText(text); },
retryTranscription: async () => {
if (!mic.pendingChunks || !mic.pendingChunks.length) return;
const chunks = mic.pendingChunks;
const inputRate = mic.pendingRate;
const durationMs = mic.pendingDurationMs;
mic.pendingChunks = null;
try {
const wav = encodeWav(chunks, inputRate);
const form = new FormData();
form.append('audio', wav, 'speech.wav');
micLabel('…');
micOverlay.showTranscribing();
const res = await fetch('/transcribe', { method: 'POST', body: form });
if (!res.ok) {
const bodyText = await res.text().catch(() => '');
if (res.status === 503) { micFault('model', '503 ' + bodyText.slice(0, 120)); }
else { micFault('http', res.status + ' ' + (bodyText.slice(0, 120) || res.statusText)); }
return;
}
const { text } = await res.json();
micOverlay.showReview(text && text.trim() ? text : '');
} catch (err) {
micFault('mic', err?.message || 'retry error');
} finally {
mic.busy = false;
}
},
});
function micFault(kind, extra) {
telemetry.log('mic.fault', extra ? { kind, extra } : { kind });
button?.classList.remove('mic-recording');
mic.recording = false;
mic.busy = false;
micLabel('🎤');
micOverlay.showFault(kind, extra);
}
function encodeWav(chunks, inputRate) {
let total = 0;
for (const c of chunks) total += c.length;
const merged = new Float32Array(total);
let off = 0;
for (const c of chunks) { merged.set(c, off); off += c.length; }
let samples = merged;
if (inputRate !== TARGET_RATE) {
const ratio = inputRate / TARGET_RATE;
const outLen = Math.floor(merged.length / ratio);
const out = new Float32Array(outLen);
for (let i = 0; i < outLen; i++) {
const pos = i * ratio;
const i0 = Math.floor(pos);
const i1 = Math.min(i0 + 1, merged.length - 1);
const frac = pos - i0;
out[i] = merged[i0] * (1 - frac) + merged[i1] * frac;
}
samples = out;
}
const buffer = new ArrayBuffer(44 + samples.length * 2);
const view = new DataView(buffer);
const writeStr = (o, s) => { for (let i = 0; i < s.length; i++) view.setUint8(o + i, s.charCodeAt(i)); };
const dataLen = samples.length * 2;
writeStr(0, 'RIFF');
view.setUint32(4, 36 + dataLen, true);
writeStr(8, 'WAVE');
writeStr(12, 'fmt ');
view.setUint32(16, 16, true); view.setUint16(20, 1, true); view.setUint16(22, 1, true); view.setUint32(24, TARGET_RATE, true);
view.setUint32(28, TARGET_RATE * 2, true); view.setUint16(32, 2, true); view.setUint16(34, 16, true); writeStr(36, 'data');
view.setUint32(40, dataLen, true);
let p = 44;
for (let i = 0; i < samples.length; i++) {
const s = Math.max(-1, Math.min(1, samples[i]));
view.setInt16(p, s < 0 ? s * 0x8000 : s * 0x7fff, true);
p += 2;
}
return new Blob([buffer], { type: 'audio/wav' });
}
function stopTracks() {
if (mic.processor) { try { mic.processor.disconnect(); } catch (_) {} mic.processor.onaudioprocess = null; }
if (mic.analyser) { try { mic.analyser.disconnect(); } catch (_) {} mic.analyser = null; }
if (mic.source) { try { mic.source.disconnect(); } catch (_) {} }
if (mic.ctx) { try { mic.ctx.close(); } catch (_) {} }
if (mic.stream) mic.stream.getTracks().forEach((t) => t.stop());
if (mic.timer) { clearInterval(mic.timer); mic.timer = null; }
mic.processor = mic.source = mic.ctx = mic.stream = null;
}
async function startRecording() {
if (mic.busy) return;
document.activeElement?.blur?.();
mic.paused = false;
const secure = window.isSecureContext !== false;
const hasGUM = !!navigator.mediaDevices?.getUserMedia;
telemetry.log('mic.secure.check', { secure, hasGetUserMedia: hasGUM });
if (!hasGUM) {
micFault('insecure');
return;
}
telemetry.log('mic.getusermedia.req');
try {
mic.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch (err) {
const name = err?.name || 'Error';
telemetry.log('mic.getusermedia.denied', { name, message: err?.message || '' });
if (name === 'NotFoundError' || name === 'DevicesNotFoundError') {
micFault('notfound', name);
} else if (name === 'NotAllowedError' || name === 'SecurityError' || name === 'PermissionDeniedError') {
micFault('denied', name);
} else {
micFault('mic', name + ': ' + (err?.message || ''));
}
return;
}
telemetry.log('mic.getusermedia.ok');
const AC = window.AudioContext || window.webkitAudioContext;
mic.ctx = new AC();
mic.inputRate = mic.ctx.sampleRate;
mic.source = mic.ctx.createMediaStreamSource(mic.stream);
mic.analyser = mic.ctx.createAnalyser();
mic.analyser.fftSize = 1024;
mic.source.connect(mic.analyser);
mic.processor = mic.ctx.createScriptProcessor(4096, 1, 1);
mic.analyser.connect(mic.processor);
mic.processor.connect(mic.ctx.destination);
mic.chunks = [];
mic.processor.onaudioprocess = (e) => {
if (!mic.paused) {
mic.chunks.push(new Float32Array(e.inputBuffer.getChannelData(0)));
}
};
mic.recording = true;
mic.busy = true;
mic.startedAt = Date.now();
button?.classList.add('mic-recording');
micOverlay.showRecording(mic.analyser);
telemetry.log('mic.recording.start', { inputRate: mic.inputRate });
mic.deadline = Date.now() + MAX_SECONDS * 1000;
const tick = () => {
const left = Math.max(0, Math.ceil((mic.deadline - Date.now()) / 1000));
micLabel('⏺' + left);
if (left <= 0) captureStop();
};
tick();
mic.timer = setInterval(tick, 250);
}
async function captureStop() {
if (!mic.recording) return;
mic.recording = false;
button?.classList.remove('mic-recording');
micLabel('…');
telemetry.log('mic.stop');
const chunks = mic.chunks;
const inputRate = mic.inputRate;
const durationMs = mic.startedAt ? Date.now() - mic.startedAt : 0;
mic.pendingChunks = chunks;
mic.pendingRate = inputRate;
mic.pendingDurationMs = durationMs;
stopTracks();
mic.chunks = [];
telemetry.log('mic.recording.stop', { durationMs, chunkCount: chunks.length });
micOverlay.showTranscribing();
try {
const wav = encodeWav(chunks, inputRate);
const form = new FormData();
form.append('audio', wav, 'speech.wav');
telemetry.log('mic.transcribe.req', { bytes: wav.size, durationMs });
let res;
try {
res = await fetch('/transcribe', { method: 'POST', body: form });
} catch (netErr) {
telemetry.log('mic.transcribe.err', { stage: 'network', message: netErr?.message || '' });
micFault('network', netErr?.message || 'network error');
return;
}
telemetry.log('mic.transcribe.resp', { status: res.status });
if (!res.ok) {
const bodyText = await res.text().catch(() => '');
telemetry.log('mic.transcribe.err', { stage: 'http', status: res.status, body: bodyText.slice(0, 200) });
if (res.status === 503) {
micFault('model', '503 ' + bodyText.slice(0, 120));
} else {
micFault('http', res.status + ' ' + (bodyText.slice(0, 120) || res.statusText));
}
return;
}
const { text } = await res.json();
telemetry.log('mic.transcribe.ok', { textLength: (text || '').trim().length });
micOverlay.showReview(text && text.trim() ? text : '');
} catch (err) {
console.error('Transcription failed:', err);
telemetry.log('mic.transcribe.err', { stage: 'exception', message: err?.message || String(err) });
micFault('mic', err?.message || 'encode/transcribe error');
}
}
function cancelRecording() {
mic.recording = false;
mic.busy = false;
mic.paused = false;
stopTracks();
mic.chunks = [];
mic.pendingChunks = null;
button?.classList.remove('mic-recording');
micLabel('🎤');
micOverlay.dismiss();
}
async function retryFresh() {
telemetry.log('mic.retry');
stopTracks();
mic.chunks = [];
mic.pendingChunks = null;
mic.recording = false;
mic.busy = false;
mic.paused = false;
micOverlay.dismiss();
await startRecording();
}
function submitText(text) {
telemetry.log('mic.submit');
send(text.trim());
send('\r');
onText?.();
mic.busy = false;
micLabel('🎤');
}
return {
trigger() {
if (mic.busy) return;
telemetry.log('mic.click', { action: 'start' });
startRecording();
},
toggle() {
if (mic.busy) return;
telemetry.log('mic.click', { action: mic.recording ? 'stop' : 'start' });
if (mic.recording) captureStop();
else startRecording();
},
isRecording() { return mic.recording; },
};
}