mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-07-16 16:46:26 +00:00
Some checks are pending
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Read metadata (push) Blocked by required conditions
Build and Release / Sync Flatpak repo (push) Blocked by required conditions
Build and Release / Collect Flatpak artifacts (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions
449 lines
17 KiB
JavaScript
449 lines
17 KiB
JavaScript
// Shared the audio context for sound effects (Web Audio API does not register with Media Session):
|
|
let soundEffectContext = null;
|
|
|
|
// Cache for decoded sound effect audio buffers:
|
|
const soundEffectCache = new Map();
|
|
|
|
// Track the preload state:
|
|
let soundEffectsPreloaded = false;
|
|
|
|
// Queue system: tracks when the next sound can start playing.
|
|
// This prevents sounds from overlapping and getting "swallowed" by the audio system:
|
|
let nextAvailablePlayTime = 0;
|
|
|
|
// Minimum gap between sounds in seconds (small buffer to ensure clean transitions):
|
|
const SOUND_GAP_SECONDS = 0.25;
|
|
|
|
// List of all sound effects used in the app:
|
|
const SOUND_EFFECT_PATHS = [
|
|
'/sounds/start_recording.ogg',
|
|
'/sounds/stop_recording.ogg',
|
|
'/sounds/transcription_done.ogg'
|
|
];
|
|
|
|
function createSoundEffectsInitResult(success, failedPaths = [], errorMessage = null) {
|
|
return {
|
|
success: success,
|
|
failedPaths: failedPaths,
|
|
errorMessage: errorMessage
|
|
};
|
|
}
|
|
|
|
// Initialize the audio context with low-latency settings.
|
|
// Should be called from a user interaction (click, keypress)
|
|
// to satisfy browser autoplay policies:
|
|
window.initSoundEffects = async function() {
|
|
|
|
try {
|
|
if (soundEffectContext && soundEffectContext.state !== 'closed') {
|
|
// Already initialized, just ensure it's running:
|
|
if (soundEffectContext.state === 'suspended') {
|
|
await soundEffectContext.resume();
|
|
}
|
|
} else {
|
|
// Create the context with the interactive latency hint for the lowest latency:
|
|
soundEffectContext = new (window.AudioContext || window.webkitAudioContext)({
|
|
latencyHint: 'interactive'
|
|
});
|
|
|
|
// Resume immediately (needed for Safari/macOS):
|
|
if (soundEffectContext.state === 'suspended') {
|
|
await soundEffectContext.resume();
|
|
}
|
|
|
|
// Reset the queue timing:
|
|
nextAvailablePlayTime = 0;
|
|
|
|
//
|
|
// Play a very short silent buffer to "warm up" the audio pipeline.
|
|
// This helps prevent the first real sound from being cut off:
|
|
//
|
|
const silentBuffer = soundEffectContext.createBuffer(1, 1, soundEffectContext.sampleRate);
|
|
const silentSource = soundEffectContext.createBufferSource();
|
|
silentSource.buffer = silentBuffer;
|
|
silentSource.connect(soundEffectContext.destination);
|
|
silentSource.start(0);
|
|
|
|
console.log('Sound effects - AudioContext initialized with latency:', soundEffectContext.baseLatency);
|
|
}
|
|
|
|
// Preload all sound effects in parallel:
|
|
if (!soundEffectsPreloaded) {
|
|
return await window.preloadSoundEffects();
|
|
}
|
|
|
|
return createSoundEffectsInitResult(true);
|
|
} catch (error) {
|
|
console.warn('Failed to initialize sound effects:', error);
|
|
return createSoundEffectsInitResult(false, [], error?.message || String(error));
|
|
}
|
|
};
|
|
|
|
// Preload all sound effect files into the cache:
|
|
window.preloadSoundEffects = async function() {
|
|
if (soundEffectsPreloaded) {
|
|
return createSoundEffectsInitResult(true);
|
|
}
|
|
|
|
// Ensure that the context exists:
|
|
if (!soundEffectContext || soundEffectContext.state === 'closed') {
|
|
soundEffectContext = new (window.AudioContext || window.webkitAudioContext)({
|
|
latencyHint: 'interactive'
|
|
});
|
|
}
|
|
|
|
console.log('Sound effects - preloading', SOUND_EFFECT_PATHS.length, 'sound files...');
|
|
const failedPaths = [];
|
|
|
|
const preloadPromises = SOUND_EFFECT_PATHS.map(async (soundPath) => {
|
|
try {
|
|
const response = await fetch(soundPath);
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}`);
|
|
}
|
|
|
|
const arrayBuffer = await response.arrayBuffer();
|
|
const audioBuffer = await soundEffectContext.decodeAudioData(arrayBuffer);
|
|
soundEffectCache.set(soundPath, audioBuffer);
|
|
|
|
console.log('Sound effects - preloaded:', soundPath, 'duration:', audioBuffer.duration.toFixed(2), 's');
|
|
} catch (error) {
|
|
console.warn('Sound effects - failed to preload:', soundPath, error);
|
|
failedPaths.push(soundPath);
|
|
}
|
|
});
|
|
|
|
await Promise.all(preloadPromises);
|
|
soundEffectsPreloaded = failedPaths.length === 0;
|
|
|
|
if (soundEffectsPreloaded) {
|
|
console.log('Sound effects - all files preloaded');
|
|
return createSoundEffectsInitResult(true);
|
|
}
|
|
|
|
console.warn('Sound effects - preload finished with failures:', failedPaths);
|
|
return createSoundEffectsInitResult(false, failedPaths, 'One or more sound effects could not be loaded.');
|
|
};
|
|
|
|
window.playSound = async function(soundPath) {
|
|
try {
|
|
// Initialize context if needed (fallback if initSoundEffects wasn't called):
|
|
if (!soundEffectContext || soundEffectContext.state === 'closed') {
|
|
soundEffectContext = new (window.AudioContext || window.webkitAudioContext)({
|
|
latencyHint: 'interactive'
|
|
});
|
|
|
|
nextAvailablePlayTime = 0;
|
|
}
|
|
|
|
// Resume if suspended (browser autoplay policy):
|
|
if (soundEffectContext.state === 'suspended') {
|
|
await soundEffectContext.resume();
|
|
}
|
|
|
|
// Check the cache for already decoded audio:
|
|
let audioBuffer = soundEffectCache.get(soundPath);
|
|
|
|
if (!audioBuffer) {
|
|
// Fetch and decode the audio file (fallback if not preloaded):
|
|
console.log('Sound effects - loading on demand:', soundPath);
|
|
const response = await fetch(soundPath);
|
|
const arrayBuffer = await response.arrayBuffer();
|
|
audioBuffer = await soundEffectContext.decodeAudioData(arrayBuffer);
|
|
soundEffectCache.set(soundPath, audioBuffer);
|
|
}
|
|
|
|
// Calculate when this sound should start:
|
|
const currentTime = soundEffectContext.currentTime;
|
|
let startTime;
|
|
|
|
if (currentTime >= nextAvailablePlayTime) {
|
|
// No sound is playing, or the previous sound has finished; start immediately:
|
|
startTime = 0; // 0 means "now" in Web Audio API
|
|
nextAvailablePlayTime = currentTime + audioBuffer.duration + SOUND_GAP_SECONDS;
|
|
} else {
|
|
// A sound is still playing; schedule this sound to start after it:
|
|
startTime = nextAvailablePlayTime;
|
|
nextAvailablePlayTime = startTime + audioBuffer.duration + SOUND_GAP_SECONDS;
|
|
console.log('Sound effects - queued:', soundPath, 'will play in', (startTime - currentTime).toFixed(2), 's');
|
|
}
|
|
|
|
// Create a new source node and schedule playback:
|
|
const source = soundEffectContext.createBufferSource();
|
|
source.buffer = audioBuffer;
|
|
source.connect(soundEffectContext.destination);
|
|
source.start(startTime);
|
|
console.log('Sound effects - playing:', soundPath);
|
|
|
|
} catch (error) {
|
|
console.warn('Failed to play sound effect:', error);
|
|
}
|
|
};
|
|
|
|
let pendingChunkUploads = 0;
|
|
let chunkUploadPromise = Promise.resolve();
|
|
let chunkUploadError = null;
|
|
let recordingError = null;
|
|
let captureAudioContext = null;
|
|
let captureSourceNode = null;
|
|
let captureWorkletNode = null;
|
|
let captureSilentGainNode = null;
|
|
let pcmFlushResolve = null;
|
|
let pcmSamplesReceived = 0;
|
|
|
|
// Store the media stream so we can close the microphone later:
|
|
let activeMediaStream = null;
|
|
|
|
// Delay in milliseconds to wait after getUserMedia() for Bluetooth profile switch (A2DP → HFP):
|
|
const BLUETOOTH_PROFILE_SWITCH_DELAY_MS = 1_600;
|
|
const PCM_SAMPLE_RATE = 48_000;
|
|
const PCM_CHUNK_DURATION_SECONDS = 3;
|
|
const PCM_FLUSH_TIMEOUT_MS = 5_000;
|
|
|
|
function queueAudioChunkUpload(upload) {
|
|
pendingChunkUploads++;
|
|
chunkUploadPromise = chunkUploadPromise
|
|
.then(upload)
|
|
.catch(error => {
|
|
chunkUploadError ??= error;
|
|
console.error('Error sending audio chunk to .NET:', error);
|
|
})
|
|
.finally(() => pendingChunkUploads--);
|
|
}
|
|
|
|
async function waitForAudioChunkUploads() {
|
|
let observedUploadPromise;
|
|
do {
|
|
observedUploadPromise = chunkUploadPromise;
|
|
await observedUploadPromise;
|
|
} while (pendingChunkUploads > 0 || observedUploadPromise !== chunkUploadPromise);
|
|
}
|
|
|
|
function createPcmWavHeader(sampleRate) {
|
|
const buffer = new ArrayBuffer(44);
|
|
const view = new DataView(buffer);
|
|
|
|
const writeAscii = (offset, value) => {
|
|
for (let index = 0; index < value.length; index++) {
|
|
view.setUint8(offset + index, value.charCodeAt(index));
|
|
}
|
|
};
|
|
|
|
writeAscii(0, 'RIFF');
|
|
view.setUint32(4, 0, true); // Finalized by .NET after all PCM data was written.
|
|
writeAscii(8, 'WAVE');
|
|
writeAscii(12, 'fmt ');
|
|
view.setUint32(16, 16, true);
|
|
view.setUint16(20, 1, true); // PCM
|
|
view.setUint16(22, 1, true); // Mono
|
|
view.setUint32(24, sampleRate, true);
|
|
view.setUint32(28, sampleRate * 2, true);
|
|
view.setUint16(32, 2, true);
|
|
view.setUint16(34, 16, true);
|
|
writeAscii(36, 'data');
|
|
view.setUint32(40, 0, true); // Finalized by .NET after all PCM data was written.
|
|
|
|
return new Uint8Array(buffer);
|
|
}
|
|
|
|
function observeAudioTrack(track) {
|
|
console.log('Audio recording - microphone track state:', {
|
|
label: track.label,
|
|
enabled: track.enabled,
|
|
muted: track.muted,
|
|
readyState: track.readyState,
|
|
settings: typeof track.getSettings === 'function' ? track.getSettings() : null,
|
|
});
|
|
|
|
track.addEventListener('mute', () => console.warn('Audio recording - microphone track was muted.'));
|
|
track.addEventListener('unmute', () => console.log('Audio recording - microphone track was unmuted.'));
|
|
track.addEventListener('ended', () => console.warn('Audio recording - microphone track ended.'));
|
|
}
|
|
|
|
async function startPcmRecording(stream, dotnetRef) {
|
|
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
|
|
if (!AudioContextClass || typeof AudioWorkletNode === 'undefined') {
|
|
throw new Error('PCM audio capture is unavailable because AudioWorklet is not supported.');
|
|
}
|
|
|
|
try {
|
|
captureAudioContext = new AudioContextClass({
|
|
latencyHint: 'interactive',
|
|
sampleRate: PCM_SAMPLE_RATE,
|
|
});
|
|
|
|
if (!captureAudioContext.audioWorklet) {
|
|
throw new Error('PCM audio capture is unavailable because AudioWorklet is not supported.');
|
|
}
|
|
|
|
await captureAudioContext.audioWorklet.addModule('/audio-recorder-worklet.js');
|
|
|
|
const actualSampleRate = captureAudioContext.sampleRate;
|
|
console.log(`Audio recording - starting PCM/WAV capture at ${actualSampleRate} Hz mono.`);
|
|
|
|
if (captureAudioContext.state === 'suspended') {
|
|
await captureAudioContext.resume();
|
|
}
|
|
|
|
captureSourceNode = captureAudioContext.createMediaStreamSource(stream);
|
|
captureWorkletNode = new AudioWorkletNode(captureAudioContext, 'pcm-recorder-processor', {
|
|
numberOfInputs: 1,
|
|
numberOfOutputs: 1,
|
|
outputChannelCount: [1],
|
|
processorOptions: {
|
|
chunkDurationSeconds: PCM_CHUNK_DURATION_SECONDS,
|
|
},
|
|
});
|
|
|
|
captureSilentGainNode = captureAudioContext.createGain();
|
|
captureSilentGainNode.gain.value = 0;
|
|
|
|
captureWorkletNode.port.onmessage = event => {
|
|
if (event.data?.type === 'chunk') {
|
|
const chunkBytes = new Uint8Array(event.data.buffer);
|
|
pcmSamplesReceived += event.data.sampleCount;
|
|
console.debug(`Audio recording - received ${event.data.sampleCount} PCM samples from AudioWorklet.`);
|
|
queueAudioChunkUpload(() => dotnetRef.invokeMethodAsync('OnAudioChunkReceived', chunkBytes));
|
|
} else if (event.data?.type === 'flushed') {
|
|
pcmFlushResolve?.();
|
|
pcmFlushResolve = null;
|
|
}
|
|
};
|
|
|
|
captureWorkletNode.onprocessorerror = event => {
|
|
recordingError ??= event.error || new Error('The PCM audio processor failed.');
|
|
console.error('Audio recording - AudioWorklet error:', recordingError);
|
|
};
|
|
|
|
captureSourceNode.connect(captureWorkletNode);
|
|
captureWorkletNode.connect(captureSilentGainNode);
|
|
captureSilentGainNode.connect(captureAudioContext.destination);
|
|
queueAudioChunkUpload(() => dotnetRef.invokeMethodAsync('OnAudioChunkReceived', createPcmWavHeader(actualSampleRate)));
|
|
} catch (error) {
|
|
await cleanupPcmCapture();
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function flushPcmRecording() {
|
|
if (!captureWorkletNode) {
|
|
throw new Error('The PCM audio processor is unavailable.');
|
|
}
|
|
|
|
await new Promise((resolve, reject) => {
|
|
const timeoutId = setTimeout(() => {
|
|
pcmFlushResolve = null;
|
|
reject(new Error('Timed out while flushing PCM audio data.'));
|
|
}, PCM_FLUSH_TIMEOUT_MS);
|
|
|
|
pcmFlushResolve = () => {
|
|
clearTimeout(timeoutId);
|
|
resolve();
|
|
};
|
|
captureWorkletNode.port.postMessage({ type: 'flush' });
|
|
});
|
|
}
|
|
|
|
async function cleanupPcmCapture() {
|
|
captureSourceNode?.disconnect();
|
|
captureWorkletNode?.disconnect();
|
|
captureSilentGainNode?.disconnect();
|
|
captureSourceNode = null;
|
|
captureWorkletNode = null;
|
|
captureSilentGainNode = null;
|
|
pcmFlushResolve = null;
|
|
|
|
if (captureAudioContext && captureAudioContext.state !== 'closed') {
|
|
await captureAudioContext.close();
|
|
}
|
|
captureAudioContext = null;
|
|
}
|
|
|
|
window.audioRecorder = {
|
|
start: async function (dotnetRef) {
|
|
// Reset the upload and recorder state:
|
|
pendingChunkUploads = 0;
|
|
chunkUploadPromise = Promise.resolve();
|
|
chunkUploadError = null;
|
|
recordingError = null;
|
|
pcmSamplesReceived = 0;
|
|
|
|
const stream = await navigator.mediaDevices.getUserMedia({
|
|
audio: {
|
|
sampleRate: { ideal: PCM_SAMPLE_RATE },
|
|
channelCount: { ideal: 1 },
|
|
},
|
|
});
|
|
activeMediaStream = stream;
|
|
|
|
const audioTracks = stream.getAudioTracks();
|
|
if (audioTracks.length === 0) {
|
|
throw new Error('The microphone stream does not contain an audio track.');
|
|
}
|
|
observeAudioTrack(audioTracks[0]);
|
|
|
|
// Wait for Bluetooth headsets to complete the profile switch from A2DP to HFP.
|
|
// This prevents the first sound from being cut off during the switch:
|
|
console.log('Audio recording - waiting for Bluetooth profile switch...');
|
|
await new Promise(r => setTimeout(r, BLUETOOTH_PROFILE_SWITCH_DELAY_MS));
|
|
|
|
// Play start recording sound effect:
|
|
await window.playSound('/sounds/start_recording.ogg');
|
|
|
|
await startPcmRecording(stream, dotnetRef);
|
|
},
|
|
|
|
stop: async function () {
|
|
let stopError = null;
|
|
|
|
try {
|
|
try {
|
|
await flushPcmRecording();
|
|
} finally {
|
|
await cleanupPcmCapture();
|
|
}
|
|
|
|
console.log(`Audio recording - PCM/WAV capture produced ${pcmSamplesReceived} samples.`);
|
|
if (pcmSamplesReceived === 0) {
|
|
throw new Error('The microphone did not produce any PCM audio samples.');
|
|
}
|
|
} catch (error) {
|
|
stopError = error;
|
|
}
|
|
|
|
console.log(`Audio recording - waiting for ${pendingChunkUploads} pending uploads.`);
|
|
await waitForAudioChunkUploads();
|
|
console.log('Audio recording - all chunks uploaded, finalizing.');
|
|
|
|
// Play stop recording sound effect:
|
|
await window.playSound('/sounds/stop_recording.ogg');
|
|
|
|
//
|
|
// IMPORTANT: Do NOT release the microphone here!
|
|
// Bluetooth headsets switch profiles (HFP → A2DP) when the microphone is released,
|
|
// which causes audio to be interrupted. We keep the microphone open so that the
|
|
// stop_recording and transcription_done sounds can play without interruption.
|
|
//
|
|
// Call window.audioRecorder.releaseMicrophone() after the last sound has played.
|
|
//
|
|
|
|
const error = stopError || recordingError || chunkUploadError;
|
|
if (error) {
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
// Release the microphone after all sounds have been played.
|
|
// This should be called after the transcription_done sound to allow
|
|
// Bluetooth headsets to switch back to A2DP profile without interrupting audio:
|
|
releaseMicrophone: async function () {
|
|
await cleanupPcmCapture();
|
|
|
|
if (activeMediaStream) {
|
|
console.log('Audio recording - releasing microphone (Bluetooth will switch back to A2DP)');
|
|
activeMediaStream.getTracks().forEach(track => track.stop());
|
|
activeMediaStream = null;
|
|
}
|
|
}
|
|
};
|