Update project
This commit is contained in:
+192
-90
@@ -217,7 +217,6 @@
|
|||||||
const NOTE_BODY_PLAIN_FIELD = 'body_plain';
|
const NOTE_BODY_PLAIN_FIELD = 'body_plain';
|
||||||
const NOTE_USERNAME_FIELD = 'Username';
|
const NOTE_USERNAME_FIELD = 'Username';
|
||||||
let mediaRecorder = null;
|
let mediaRecorder = null;
|
||||||
let recordedChunks = [];
|
|
||||||
let recordedBlob = null;
|
let recordedBlob = null;
|
||||||
let titleRecognition = null;
|
let titleRecognition = null;
|
||||||
let noteRecognition = null;
|
let noteRecognition = null;
|
||||||
@@ -647,105 +646,172 @@
|
|||||||
ensureUserLogged();
|
ensureUserLogged();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Audio recording setup with auto-stop on silence ---
|
// --- Audio recording setup with segmented silence handling ---
|
||||||
let audioContext = null;
|
let audioContext = null;
|
||||||
let analyser = null;
|
let analyser = null;
|
||||||
let silenceStart = null;
|
let recordingStream = null;
|
||||||
let silenceCheckInterval = null;
|
let silenceCheckInterval = null;
|
||||||
|
let lastSoundAt = 0;
|
||||||
|
let stoppingSegment = false;
|
||||||
const SILENCE_THRESHOLD = 0.01; // Volume threshold for silence
|
const SILENCE_THRESHOLD = 0.01; // Volume threshold for silence
|
||||||
const SILENCE_DURATION = 3000; // 3 seconds
|
const SEGMENT_SILENCE_MS = 2000; // pause a segment after 2s of silence
|
||||||
|
const SESSION_SILENCE_MS = 3500; // end entire session after 3.5s of silence
|
||||||
function checkSilence() {
|
let recordingSessionActive = false;
|
||||||
if (!analyser) return;
|
let segmentActive = false;
|
||||||
|
let currentSegmentChunks = [];
|
||||||
|
let recordedSegments = [];
|
||||||
|
recordedBlob = null; // reuse existing variable for merged preview
|
||||||
|
let hasHeardSpeech = false;
|
||||||
|
let sessionStartAt = 0;
|
||||||
|
|
||||||
|
function computeRms() {
|
||||||
|
if (!analyser) return 0;
|
||||||
const bufferLength = analyser.frequencyBinCount;
|
const bufferLength = analyser.frequencyBinCount;
|
||||||
const dataArray = new Uint8Array(bufferLength);
|
const dataArray = new Uint8Array(bufferLength);
|
||||||
analyser.getByteTimeDomainData(dataArray);
|
analyser.getByteTimeDomainData(dataArray);
|
||||||
|
|
||||||
// Calculate average volume
|
|
||||||
let sum = 0;
|
let sum = 0;
|
||||||
for (let i = 0; i < bufferLength; i++) {
|
for (let i = 0; i < bufferLength; i++) {
|
||||||
const normalized = (dataArray[i] - 128) / 128;
|
const normalized = (dataArray[i] - 128) / 128;
|
||||||
sum += normalized * normalized;
|
sum += normalized * normalized;
|
||||||
}
|
}
|
||||||
const rms = Math.sqrt(sum / bufferLength);
|
return Math.sqrt(sum / bufferLength);
|
||||||
|
|
||||||
if (rms < SILENCE_THRESHOLD) {
|
|
||||||
if (!silenceStart) {
|
|
||||||
silenceStart = Date.now();
|
|
||||||
} else if (Date.now() - silenceStart >= SILENCE_DURATION) {
|
|
||||||
stopRecording();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
silenceStart = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setupRecorder() {
|
async function ensureStream() {
|
||||||
let stream;
|
if (recordingStream) return recordingStream;
|
||||||
try {
|
try {
|
||||||
stream = await navigator.mediaDevices.getUserMedia({
|
recordingStream = await navigator.mediaDevices.getUserMedia({
|
||||||
audio: {
|
audio: {
|
||||||
autoGainControl: true
|
autoGainControl: true,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Microphone access failed:', err);
|
console.error('Microphone access failed:', err);
|
||||||
document.getElementById('recordStatus').textContent = 'Microphone access denied or unavailable.';
|
document.getElementById('recordStatus').textContent = 'Microphone access denied or unavailable.';
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
recordedChunks = [];
|
|
||||||
recordedBlob = null;
|
|
||||||
|
|
||||||
// Setup audio context for silence detection
|
|
||||||
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
analyser = audioContext.createAnalyser();
|
analyser = audioContext.createAnalyser();
|
||||||
const source = audioContext.createMediaStreamSource(stream);
|
const source = audioContext.createMediaStreamSource(recordingStream);
|
||||||
source.connect(analyser);
|
source.connect(analyser);
|
||||||
analyser.fftSize = 2048;
|
analyser.fftSize = 2048;
|
||||||
|
return recordingStream;
|
||||||
|
}
|
||||||
|
|
||||||
mediaRecorder = new MediaRecorder(stream);
|
function startSilenceMonitor() {
|
||||||
|
if (silenceCheckInterval) return;
|
||||||
|
silenceCheckInterval = setInterval(checkSilence, 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopSilenceMonitor() {
|
||||||
|
if (silenceCheckInterval) {
|
||||||
|
clearInterval(silenceCheckInterval);
|
||||||
|
silenceCheckInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupStream() {
|
||||||
|
stopSilenceMonitor();
|
||||||
|
if (audioContext) {
|
||||||
|
audioContext.close();
|
||||||
|
audioContext = null;
|
||||||
|
}
|
||||||
|
mediaRecorder = null;
|
||||||
|
if (recordingStream) {
|
||||||
|
recordingStream.getTracks().forEach((t) => t.stop());
|
||||||
|
recordingStream = null;
|
||||||
|
}
|
||||||
|
analyser = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCombinedPreview() {
|
||||||
|
const audioEl = document.getElementById('audioPreview');
|
||||||
|
if (!recordedSegments.length) {
|
||||||
|
recordedBlob = null;
|
||||||
|
audioEl.src = '';
|
||||||
|
audioEl.classList.add('hidden');
|
||||||
|
document.getElementById('audioPlayer').classList.add('hidden');
|
||||||
|
document.getElementById('recordStatus').textContent = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = recordedSegments[0]?.blob?.type || 'audio/webm';
|
||||||
|
recordedBlob = new Blob(recordedSegments.map((s) => s.blob), { type });
|
||||||
|
audioEl.src = URL.createObjectURL(recordedBlob);
|
||||||
|
audioEl.volume = 1.0;
|
||||||
|
audioEl.classList.remove('hidden');
|
||||||
|
document.getElementById('audioPlayer').classList.remove('hidden');
|
||||||
|
document.getElementById('recordStatus').textContent = `Segments recorded: ${recordedSegments.length}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSegment() {
|
||||||
|
if (!recordingStream) return;
|
||||||
|
currentSegmentChunks = [];
|
||||||
|
mediaRecorder = new MediaRecorder(recordingStream);
|
||||||
|
const recorderRef = mediaRecorder;
|
||||||
|
segmentActive = true;
|
||||||
|
stoppingSegment = false;
|
||||||
|
lastSoundAt = Date.now();
|
||||||
mediaRecorder.ondataavailable = (e) => {
|
mediaRecorder.ondataavailable = (e) => {
|
||||||
if (e.data && e.data.size > 0) recordedChunks.push(e.data);
|
if (e.data && e.data.size > 0) currentSegmentChunks.push(e.data);
|
||||||
};
|
};
|
||||||
mediaRecorder.onstop = () => {
|
mediaRecorder.onstop = () => {
|
||||||
if (silenceCheckInterval) {
|
if (currentSegmentChunks.length) {
|
||||||
clearInterval(silenceCheckInterval);
|
const blob = new Blob(currentSegmentChunks, { type: recorderRef.mimeType });
|
||||||
silenceCheckInterval = null;
|
recordedSegments.push({ blob, name: `segment-${recordedSegments.length + 1}.webm` });
|
||||||
|
updateCombinedPreview();
|
||||||
}
|
}
|
||||||
silenceStart = null;
|
segmentActive = false;
|
||||||
if (audioContext) {
|
if (mediaRecorder === recorderRef) {
|
||||||
audioContext.close();
|
mediaRecorder = null;
|
||||||
audioContext = null;
|
|
||||||
}
|
}
|
||||||
stream.getTracks().forEach(track => track.stop());
|
stoppingSegment = false;
|
||||||
recordedBlob = new Blob(recordedChunks, { type: mediaRecorder.mimeType });
|
|
||||||
const audioEl = document.getElementById('audioPreview');
|
|
||||||
audioEl.src = URL.createObjectURL(recordedBlob);
|
|
||||||
audioEl.volume = 1.0;
|
|
||||||
audioEl.classList.remove('hidden');
|
|
||||||
document.getElementById('audioPlayer').classList.remove('hidden');
|
|
||||||
document.getElementById('recordStatus').textContent = '';
|
|
||||||
mediaRecorder = null; // allow fresh recorder next start
|
|
||||||
};
|
};
|
||||||
|
mediaRecorder.start();
|
||||||
|
document.getElementById('recordStatus').textContent = `Recording segment ${recordedSegments.length + 1}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopSegment() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (stoppingSegment) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
||||||
|
stoppingSegment = true;
|
||||||
|
const onStop = () => {
|
||||||
|
mediaRecorder.removeEventListener('stop', onStop);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
mediaRecorder.addEventListener('stop', onStop);
|
||||||
|
mediaRecorder.stop();
|
||||||
|
} else {
|
||||||
|
segmentActive = false;
|
||||||
|
stoppingSegment = false;
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startRecording() {
|
async function startRecording() {
|
||||||
const recordBtn = document.getElementById('recordBtn');
|
const recordBtn = document.getElementById('recordBtn');
|
||||||
const stopBtn = document.getElementById('stopBtn');
|
const stopBtn = document.getElementById('stopBtn');
|
||||||
try {
|
try {
|
||||||
if (!mediaRecorder) await setupRecorder();
|
if (recordingSessionActive) return;
|
||||||
silenceStart = null;
|
await ensureStream();
|
||||||
recordedChunks = [];
|
recordedSegments = [];
|
||||||
recordedBlob = null;
|
recordedBlob = null;
|
||||||
mediaRecorder.start();
|
segmentActive = false;
|
||||||
document.getElementById('recordStatus').textContent = 'Recording…';
|
hasHeardSpeech = false;
|
||||||
|
sessionStartAt = Date.now();
|
||||||
|
recordingSessionActive = true;
|
||||||
|
lastSoundAt = sessionStartAt;
|
||||||
|
startSegment();
|
||||||
|
startSilenceMonitor();
|
||||||
recordBtn.disabled = true;
|
recordBtn.disabled = true;
|
||||||
stopBtn.disabled = false;
|
stopBtn.disabled = false;
|
||||||
stopBtn.classList.remove('hidden');
|
stopBtn.classList.remove('hidden');
|
||||||
|
|
||||||
// Start silence detection
|
|
||||||
silenceCheckInterval = setInterval(checkSilence, 100);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to start recording:', err);
|
console.error('Failed to start recording:', err);
|
||||||
document.getElementById('recordStatus').textContent = 'Recording not available.';
|
document.getElementById('recordStatus').textContent = 'Recording not available.';
|
||||||
@@ -755,29 +821,60 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopRecording() {
|
async function endRecordingSession(reason) {
|
||||||
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
recordingSessionActive = false;
|
||||||
mediaRecorder.stop();
|
stopSilenceMonitor();
|
||||||
if (silenceCheckInterval) {
|
await stopSegment();
|
||||||
clearInterval(silenceCheckInterval);
|
cleanupStream();
|
||||||
silenceCheckInterval = null;
|
const recordBtn = document.getElementById('recordBtn');
|
||||||
|
const stopBtn = document.getElementById('stopBtn');
|
||||||
|
recordBtn.disabled = false;
|
||||||
|
stopBtn.disabled = true;
|
||||||
|
stopBtn.classList.add('hidden');
|
||||||
|
if (reason) {
|
||||||
|
document.getElementById('recordStatus').textContent = reason;
|
||||||
|
}
|
||||||
|
hasHeardSpeech = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopRecording() {
|
||||||
|
await endRecordingSession('Recording stopped.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkSilence() {
|
||||||
|
if (!recordingSessionActive || !analyser) return;
|
||||||
|
const now = Date.now();
|
||||||
|
const rms = computeRms();
|
||||||
|
|
||||||
|
if (rms >= SILENCE_THRESHOLD) {
|
||||||
|
hasHeardSpeech = true;
|
||||||
|
lastSoundAt = now;
|
||||||
|
if (!segmentActive) {
|
||||||
|
startSegment();
|
||||||
}
|
}
|
||||||
document.getElementById('recordBtn').disabled = false;
|
return;
|
||||||
const stopBtn = document.getElementById('stopBtn');
|
}
|
||||||
stopBtn.disabled = true;
|
|
||||||
stopBtn.classList.add('hidden');
|
// If we have not yet heard speech, allow up to SESSION_SILENCE_MS before ending
|
||||||
|
if (!hasHeardSpeech) {
|
||||||
|
if (now - sessionStartAt >= SESSION_SILENCE_MS) {
|
||||||
|
endRecordingSession('Stopped after silence.');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segmentActive && !stoppingSegment && now - lastSoundAt >= SEGMENT_SILENCE_MS) {
|
||||||
|
stopSegment();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!segmentActive && now - lastSoundAt >= SESSION_SILENCE_MS) {
|
||||||
|
endRecordingSession('Stopped after silence.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearAudio() {
|
async function clearAudio() {
|
||||||
if (silenceCheckInterval) {
|
await endRecordingSession('Audio cleared.');
|
||||||
clearInterval(silenceCheckInterval);
|
recordedSegments = [];
|
||||||
silenceCheckInterval = null;
|
|
||||||
}
|
|
||||||
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
|
||||||
mediaRecorder.stop();
|
|
||||||
}
|
|
||||||
recordedChunks = [];
|
|
||||||
recordedBlob = null;
|
recordedBlob = null;
|
||||||
const audioEl = document.getElementById('audioPreview');
|
const audioEl = document.getElementById('audioPreview');
|
||||||
audioEl.src = '';
|
audioEl.src = '';
|
||||||
@@ -788,15 +885,13 @@
|
|||||||
document.getElementById('currentTime').textContent = '0:00';
|
document.getElementById('currentTime').textContent = '0:00';
|
||||||
document.getElementById('durationTime').textContent = '0:00';
|
document.getElementById('durationTime').textContent = '0:00';
|
||||||
document.getElementById('playPauseBtn').textContent = '▶';
|
document.getElementById('playPauseBtn').textContent = '▶';
|
||||||
document.getElementById('recordBtn').disabled = false;
|
|
||||||
const stopBtn = document.getElementById('stopBtn');
|
|
||||||
stopBtn.disabled = true;
|
|
||||||
stopBtn.classList.add('hidden');
|
|
||||||
document.getElementById('recordStatus').textContent = '';
|
document.getElementById('recordStatus').textContent = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('recordBtn').addEventListener('click', startRecording);
|
document.getElementById('recordBtn').addEventListener('click', startRecording);
|
||||||
document.getElementById('stopBtn').addEventListener('click', stopRecording);
|
document.getElementById('stopBtn').addEventListener('click', () => {
|
||||||
|
stopRecording();
|
||||||
|
});
|
||||||
// Audio options menu
|
// Audio options menu
|
||||||
const audioMenuContainer = document.getElementById('audioMenuContainer');
|
const audioMenuContainer = document.getElementById('audioMenuContainer');
|
||||||
const audioMenuBtn = document.getElementById('audioMenuBtn');
|
const audioMenuBtn = document.getElementById('audioMenuBtn');
|
||||||
@@ -853,10 +948,10 @@
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
audioMenu.classList.toggle('hidden');
|
audioMenu.classList.toggle('hidden');
|
||||||
});
|
});
|
||||||
removeAudioBtn.addEventListener('click', (e) => {
|
removeAudioBtn.addEventListener('click', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
audioMenu.classList.add('hidden');
|
audioMenu.classList.add('hidden');
|
||||||
clearAudio();
|
await clearAudio();
|
||||||
});
|
});
|
||||||
document.addEventListener('click', (e) => {
|
document.addEventListener('click', (e) => {
|
||||||
if (!audioMenuContainer.contains(e.target)) {
|
if (!audioMenuContainer.contains(e.target)) {
|
||||||
@@ -1217,16 +1312,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const noteRecord = await pb.collection(NOTES_COLLECTION).create(payload);
|
const noteRecord = await pb.collection(NOTES_COLLECTION).create(payload);
|
||||||
let attachmentRecord = null;
|
const attachmentRecords = [];
|
||||||
if (recordedBlob) {
|
if (recordedSegments && recordedSegments.length) {
|
||||||
const file = new File([recordedBlob], 'note-audio.webm', { type: recordedBlob.type || 'audio/webm' });
|
for (let i = 0; i < recordedSegments.length; i++) {
|
||||||
const fd = new FormData();
|
const seg = recordedSegments[i];
|
||||||
fd.append(ATTACHMENTS_FILE_FIELD, file);
|
const blobToUpload = seg.blob || seg;
|
||||||
fd.append(ATTACHMENTS_PBID_FIELD, noteRecord.id);
|
const fileName = seg.name || `note-audio-seg-${i + 1}.webm`;
|
||||||
attachmentRecord = await pb.collection(ATTACHMENTS_COLLECTION).create(fd);
|
const file = new File([blobToUpload], fileName, { type: blobToUpload.type || 'audio/webm' });
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append(ATTACHMENTS_FILE_FIELD, file);
|
||||||
|
fd.append(ATTACHMENTS_PBID_FIELD, noteRecord.id);
|
||||||
|
const createdAtt = await pb.collection(ATTACHMENTS_COLLECTION).create(fd);
|
||||||
|
attachmentRecords.push(createdAtt);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
statusEl.textContent = `Saved note ${noteRecord.id}` + (attachmentRecord ? ` with audio ${attachmentRecord.id}` : '');
|
const attachmentMsg = attachmentRecords.length ? ` with ${attachmentRecords.length} audio segment(s)` : '';
|
||||||
clearAudio();
|
statusEl.textContent = `Saved note ${noteRecord.id}${attachmentMsg}`;
|
||||||
|
await clearAudio();
|
||||||
document.getElementById('noteText').value = '';
|
document.getElementById('noteText').value = '';
|
||||||
document.getElementById('noteTitle').value = '';
|
document.getElementById('noteTitle').value = '';
|
||||||
document.getElementById('jobNote').value = 'no';
|
document.getElementById('jobNote').value = 'no';
|
||||||
|
|||||||
Reference in New Issue
Block a user