* webui: Move static build output from `tools/server/public` to `build/ui` directory * refactor: Move to `tools/ui` * refactor: rename CMake variables and preprocessor defines - Rename LLAMA_BUILD_WEBUI -> LLAMA_BUILD_UI (old kept as deprecated) - Rename LLAMA_USE_PREBUILT_WEBUI -> LLAMA_USE_PREBUILT_UI (old kept as deprecated) - Backward compat: old vars auto-forward to new ones with DEPRECATION warning - Rename internal vars: WEBUI_SOURCE -> UI_SOURCE, WEBUI_SOURCE_DIR -> UI_SOURCE_DIR, etc. - Rename HF bucket: LLAMA_WEBUI_HF_BUCKET -> LLAMA_UI_HF_BUCKET - Emit both LLAMA_BUILD_WEBUI and LLAMA_BUILD_UI preprocessor defines - Emit both LLAMA_WEBUI_DEFAULT_ENABLED and LLAMA_UI_DEFAULT_ENABLED * refactor: rename CLI flags (--webui -> --ui) with backward compat - Add --ui/--no-ui (old --webui/--no-webui kept as deprecated aliases) - Add --ui-config (old --webui-config kept as deprecated alias) - Add --ui-config-file (old --webui-config-file kept as deprecated alias) - Add --ui-mcp-proxy/--no-ui-mcp-proxy (old --webui-mcp-proxy kept as deprecated) - Add new env vars: LLAMA_ARG_UI, LLAMA_ARG_UI_CONFIG, LLAMA_ARG_UI_CONFIG_FILE, LLAMA_ARG_UI_MCP_PROXY - C++ struct fields: params.ui, params.ui_config_json, params.ui_mcp_proxy added alongside old fields - Backward compat: old fields synced to new ones in g_params_to_internals * refactor: update C++ server internals with backward compat - Rename json_webui_settings -> json_ui_settings (both kept in server_context_meta) - Rename params.webui usage -> params.ui (both synced, old still works) - JSON API emits both "ui"/"ui_settings" and "webui"/"webui_settings" keys - Server routes use params.ui_mcp_proxy || params.webui_mcp_proxy - Preprocessor guards use #if defined(LLAMA_BUILD_UI) || defined(LLAMA_BUILD_WEBUI) * refactor: rename CI/CD workflows, artifacts, and build script - Rename webui-build.yml -> ui-build.yml; artifact webui-build -> ui-build - Rename webui-publish.yml -> ui-publish.yml; var HF_BUCKET_WEBUI_STATIC_OUTPUT -> HF_BUCKET_UI_STATIC_OUTPUT - Rename server-webui.yml -> server-ui.yml; job webui-build/checks -> ui-build/checks - Update server.yml: job/artifact refs webui-build -> ui-build - Update release.yml: all webui-build/publish refs -> ui-build/publish; HF_TOKEN_WEBUI_STATIC_OUTPUT -> HF_TOKEN_UI_STATIC_OUTPUT - Update server-self-hosted.yml: webui-build -> ui-build - Update build-self-hosted.yml: HF_WEBUI_VERSION -> HF_UI_VERSION - Rename webui-download.cmake -> ui-download.cmake (internal refs updated) - Update labeler.yml: server/webui -> server/ui path label * docs: update CODEOWNERS and server README docs - Update CODEOWNERS: team ggml-org/llama-webui -> ggml-org/llama-ui, path /tools/server/webui/ -> /tools/ui/ - Update server README.md: CLI tables show --ui flags with deprecated --webui aliases - Update server README-dev.md: "WebUI" -> "UI", paths updated to tools/ui/ * fix: Small fixes for UI build * fix: CMake.txt syntax * chore: Formatting * fix: `.editorconfig` for llama-ui * chore: Formatting * refactor: Use `APP_NAME` in Error route * refactor: Cleanup * refactor: Single migration service * make llama-ui a linkable target * fix: UI Build output * fix: Missing change * fix: separate llama-ui npm build output into build/tools/ui/dist subfolder + use cmake npm build instead of downloading ui-build.yml artifacts in CI * refactor: UI workflows cleanup --------- Co-authored-by: Xuan Son Nguyen <son@huggingface.co>
258 lines
7.3 KiB
TypeScript
258 lines
7.3 KiB
TypeScript
import { MimeTypeAudio } from '$lib/enums';
|
|
|
|
/**
|
|
* AudioRecorder - Browser-based audio recording with MediaRecorder API
|
|
*
|
|
* This class provides a complete audio recording solution using the browser's MediaRecorder API.
|
|
* It handles microphone access, recording state management, and audio format optimization.
|
|
*
|
|
* **Features:**
|
|
* - Automatic microphone permission handling
|
|
* - Audio enhancement (echo cancellation, noise suppression, auto gain)
|
|
* - Multiple format support with fallback (WAV, WebM, MP4, AAC)
|
|
* - Real-time recording state tracking
|
|
* - Proper cleanup and resource management
|
|
*/
|
|
export class AudioRecorder {
|
|
private mediaRecorder: MediaRecorder | null = null;
|
|
private audioChunks: Blob[] = [];
|
|
private stream: MediaStream | null = null;
|
|
private recordingState: boolean = false;
|
|
|
|
async startRecording(): Promise<void> {
|
|
try {
|
|
this.stream = await navigator.mediaDevices.getUserMedia({
|
|
audio: {
|
|
echoCancellation: true,
|
|
noiseSuppression: true,
|
|
autoGainControl: true
|
|
}
|
|
});
|
|
|
|
this.initializeRecorder(this.stream);
|
|
|
|
this.audioChunks = [];
|
|
// Start recording with a small timeslice to ensure we get data
|
|
this.mediaRecorder!.start(100);
|
|
this.recordingState = true;
|
|
} catch (error) {
|
|
console.error('Failed to start recording:', error);
|
|
throw new Error('Failed to access microphone. Please check permissions.');
|
|
}
|
|
}
|
|
|
|
async stopRecording(): Promise<Blob> {
|
|
return new Promise((resolve, reject) => {
|
|
const recorder = this.mediaRecorder;
|
|
const chunks = this.audioChunks;
|
|
const stream = this.stream;
|
|
|
|
if (!recorder || recorder.state === 'inactive') {
|
|
reject(new Error('No active recording to stop'));
|
|
return;
|
|
}
|
|
|
|
// Detach instance state right away so a new startRecording can take over without race
|
|
this.mediaRecorder = null;
|
|
this.audioChunks = [];
|
|
this.stream = null;
|
|
this.recordingState = false;
|
|
|
|
recorder.onstop = () => {
|
|
const audioBlob = new Blob(chunks, {
|
|
type: recorder.mimeType || MimeTypeAudio.WAV
|
|
});
|
|
|
|
if (stream) {
|
|
for (const track of stream.getTracks()) {
|
|
track.stop();
|
|
}
|
|
}
|
|
|
|
resolve(audioBlob);
|
|
};
|
|
|
|
recorder.onerror = (event) => {
|
|
console.error('Recording error:', event);
|
|
|
|
if (stream) {
|
|
for (const track of stream.getTracks()) {
|
|
track.stop();
|
|
}
|
|
}
|
|
|
|
reject(new Error('Recording failed'));
|
|
};
|
|
|
|
recorder.stop();
|
|
});
|
|
}
|
|
|
|
isRecording(): boolean {
|
|
return this.recordingState;
|
|
}
|
|
|
|
cancelRecording(): void {
|
|
const recorder = this.mediaRecorder;
|
|
const stream = this.stream;
|
|
|
|
this.mediaRecorder = null;
|
|
this.audioChunks = [];
|
|
this.stream = null;
|
|
this.recordingState = false;
|
|
|
|
if (recorder && recorder.state !== 'inactive') {
|
|
// Drop the original handlers so the pending stop event does not touch the instance
|
|
recorder.onstop = null;
|
|
recorder.onerror = null;
|
|
recorder.stop();
|
|
}
|
|
|
|
if (stream) {
|
|
for (const track of stream.getTracks()) {
|
|
track.stop();
|
|
}
|
|
}
|
|
}
|
|
|
|
private initializeRecorder(stream: MediaStream): void {
|
|
const options: MediaRecorderOptions = {};
|
|
|
|
if (MediaRecorder.isTypeSupported(MimeTypeAudio.WAV)) {
|
|
options.mimeType = MimeTypeAudio.WAV;
|
|
} else if (MediaRecorder.isTypeSupported(MimeTypeAudio.WEBM_OPUS)) {
|
|
options.mimeType = MimeTypeAudio.WEBM_OPUS;
|
|
} else if (MediaRecorder.isTypeSupported(MimeTypeAudio.WEBM)) {
|
|
options.mimeType = MimeTypeAudio.WEBM;
|
|
} else if (MediaRecorder.isTypeSupported(MimeTypeAudio.MP4)) {
|
|
options.mimeType = MimeTypeAudio.MP4;
|
|
} else {
|
|
console.warn('No preferred audio format supported, using default');
|
|
}
|
|
|
|
this.mediaRecorder = new MediaRecorder(stream, options);
|
|
|
|
this.mediaRecorder.ondataavailable = (event) => {
|
|
if (event.data.size > 0) {
|
|
this.audioChunks.push(event.data);
|
|
}
|
|
};
|
|
|
|
this.mediaRecorder.onstop = () => {
|
|
this.recordingState = false;
|
|
};
|
|
|
|
this.mediaRecorder.onerror = (event) => {
|
|
console.error('MediaRecorder error:', event);
|
|
this.recordingState = false;
|
|
};
|
|
}
|
|
}
|
|
|
|
export async function convertToWav(audioBlob: Blob): Promise<Blob> {
|
|
try {
|
|
if (audioBlob.type.includes('wav')) {
|
|
return audioBlob;
|
|
}
|
|
|
|
const arrayBuffer = await audioBlob.arrayBuffer();
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
|
|
|
|
try {
|
|
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
|
return audioBufferToWav(audioBuffer);
|
|
} finally {
|
|
audioContext.close();
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to convert audio to WAV:', error);
|
|
return audioBlob;
|
|
}
|
|
}
|
|
|
|
function audioBufferToWav(buffer: AudioBuffer): Blob {
|
|
const length = buffer.length;
|
|
const numberOfChannels = buffer.numberOfChannels;
|
|
const sampleRate = buffer.sampleRate;
|
|
const bytesPerSample = 2; // 16-bit
|
|
const blockAlign = numberOfChannels * bytesPerSample;
|
|
const byteRate = sampleRate * blockAlign;
|
|
const dataSize = length * blockAlign;
|
|
const bufferSize = 44 + dataSize;
|
|
|
|
const arrayBuffer = new ArrayBuffer(bufferSize);
|
|
const view = new DataView(arrayBuffer);
|
|
|
|
const writeString = (offset: number, string: string) => {
|
|
for (let i = 0; i < string.length; i++) {
|
|
view.setUint8(offset + i, string.charCodeAt(i));
|
|
}
|
|
};
|
|
|
|
writeString(0, 'RIFF'); // ChunkID
|
|
view.setUint32(4, bufferSize - 8, true); // ChunkSize
|
|
writeString(8, 'WAVE'); // Format
|
|
writeString(12, 'fmt '); // Subchunk1ID
|
|
view.setUint32(16, 16, true); // Subchunk1Size
|
|
view.setUint16(20, 1, true); // AudioFormat (PCM)
|
|
view.setUint16(22, numberOfChannels, true); // NumChannels
|
|
view.setUint32(24, sampleRate, true); // SampleRate
|
|
view.setUint32(28, byteRate, true); // ByteRate
|
|
view.setUint16(32, blockAlign, true); // BlockAlign
|
|
view.setUint16(34, 16, true); // BitsPerSample
|
|
writeString(36, 'data'); // Subchunk2ID
|
|
view.setUint32(40, dataSize, true); // Subchunk2Size
|
|
|
|
// Cache channel arrays, write PCM via Int16Array (native little-endian, matches WAV)
|
|
const channels: Float32Array[] = new Array(numberOfChannels);
|
|
for (let c = 0; c < numberOfChannels; c++) {
|
|
channels[c] = buffer.getChannelData(c);
|
|
}
|
|
|
|
const pcm = new Int16Array(arrayBuffer, 44, length * numberOfChannels);
|
|
let p = 0;
|
|
for (let i = 0; i < length; i++) {
|
|
for (let c = 0; c < numberOfChannels; c++) {
|
|
let s = channels[c][i];
|
|
if (s > 1) s = 1;
|
|
else if (s < -1) s = -1;
|
|
pcm[p++] = s * 0x7fff;
|
|
}
|
|
}
|
|
|
|
return new Blob([arrayBuffer], { type: MimeTypeAudio.WAV });
|
|
}
|
|
|
|
/**
|
|
* Create a File object from audio blob with timestamp-based naming
|
|
* @param audioBlob - The audio blob to wrap
|
|
* @param filename - Optional custom filename
|
|
* @returns File object with appropriate name and metadata
|
|
*/
|
|
export function createAudioFile(audioBlob: Blob, filename?: string): File {
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
const extension = audioBlob.type.includes('wav') ? 'wav' : 'mp3';
|
|
const defaultFilename = `recording-${timestamp}.${extension}`;
|
|
|
|
return new File([audioBlob], filename || defaultFilename, {
|
|
type: audioBlob.type,
|
|
lastModified: Date.now()
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Check if audio recording is supported in the current browser
|
|
* @returns True if MediaRecorder and getUserMedia are available
|
|
*/
|
|
export function isAudioRecordingSupported(): boolean {
|
|
return !!(
|
|
typeof navigator !== 'undefined' &&
|
|
navigator.mediaDevices &&
|
|
typeof navigator.mediaDevices.getUserMedia === 'function' &&
|
|
typeof window !== 'undefined' &&
|
|
window.MediaRecorder
|
|
);
|
|
}
|