feat: added audio recording feature

This commit is contained in:
Xiaohan-Tian
2026-05-09 15:57:46 -07:00
parent aa6ffd32dd
commit 490cd19f68
24 changed files with 1540 additions and 38 deletions
+13
View File
@@ -49,6 +49,7 @@ export class KGCore {
// Callback for external state updates (e.g., store)
private playheadUpdateCallback: ((position: number) => void) | null = null;
private playbackStateChangeCallback: ((isPlaying: boolean) => void) | null = null;
private loopBoundaryReachedCallback: ((loopEndBeat: number) => void) | null = null;
// Selection change callbacks for store synchronization
private selectionChangeCallbacks: (() => void)[] = [];
@@ -200,6 +201,10 @@ export class KGCore {
this.playbackStateChangeCallback = callback;
}
public setLoopBoundaryReachedCallback(callback: ((loopEndBeat: number) => void) | null): void {
this.loopBoundaryReachedCallback = callback;
}
// Selection change callback management
public onSelectionChanged(callback: () => void): void {
this.selectionChangeCallbacks.push(callback);
@@ -414,6 +419,14 @@ export class KGCore {
// Wrap playhead position within loop range
if (newPosition >= loopEndBeats) {
if (this.loopBoundaryReachedCallback) {
const callback = this.loopBoundaryReachedCallback;
this.loopBoundaryReachedCallback = null;
this.setPlayheadPosition(loopEndBeats);
callback(loopEndBeats);
return;
}
// Calculate how far we've overshot and wrap back
const overshot = newPosition - loopEndBeats;
newPosition = loopStartBeats + (overshot % loopLengthBeats);
@@ -24,6 +24,12 @@ import {
import * as Tone from 'tone';
import { KGAudioBus } from './KGAudioBus';
import { KGAudioPlayerBus } from './KGAudioPlayerBus';
import {
KGAudioRecorder,
type AudioRecordingPeak,
type AudioRecordingResult,
type AudioRecordingStartResult,
} from './KGAudioRecorder';
import type { InstrumentType } from '../track/KGMidiTrack';
import type { KGAudioRegion } from '../region/KGAudioRegion';
import { KGCore } from '../KGCore';
@@ -80,6 +86,9 @@ export class KGAudioInterface {
private captureDestination: MediaStreamAudioDestinationNode | null = null;
private captureStream: MediaStream | null = null;
// Microphone recorder
private audioRecorder: KGAudioRecorder = new KGAudioRecorder();
// Private constructor to prevent direct instantiation
private constructor() {
console.log("KGAudioInterface initialized");
@@ -194,6 +203,8 @@ export class KGAudioInterface {
this.captureDestination = null;
this.captureStream = null;
}
await this.audioRecorder.cancel();
this.isInitialized = false;
this.isAudioContextStarted = false;
@@ -1071,6 +1082,45 @@ export class KGAudioInterface {
}
}
public async startAudioRecording(
inputDeviceId: string = 'default',
onPeaks?: (peaks: AudioRecordingPeak[]) => void
): Promise<AudioRecordingStartResult> {
return await this.audioRecorder.start(inputDeviceId, onPeaks);
}
public async stopAudioRecording(): Promise<AudioRecordingResult | null> {
return await this.audioRecorder.stop();
}
public async cancelAudioRecording(): Promise<void> {
await this.audioRecorder.cancel();
}
public async applyConfiguredOutputDevice(outputDeviceId: string): Promise<boolean> {
if (outputDeviceId === 'default') {
return false;
}
const rawContext = Tone.getContext().rawContext as AudioContext & {
setSinkId?: (sinkId: string) => Promise<void>;
sinkId?: string;
};
if (typeof rawContext.setSinkId !== 'function') {
return false;
}
try {
await rawContext.setSinkId(outputDeviceId);
console.log(`Applied audio output device ${outputDeviceId}`);
return true;
} catch (error) {
console.warn(`Unable to apply audio output device ${outputDeviceId}:`, error);
return false;
}
}
/**
* Clear all scheduled events
*/
+289
View File
@@ -0,0 +1,289 @@
import * as Tone from 'tone';
export interface AudioRecordingPeak {
min: number;
max: number;
}
export interface AudioRecordingResult {
blob: Blob;
mimeType: string;
durationSeconds: number;
peaks: AudioRecordingPeak[];
}
export interface AudioRecordingStartResult {
usedDeviceId: string;
fellBackToDefault: boolean;
}
export class KGAudioRecorder {
private static readonly DEBUG_LOG_PREFIX = '[KGAudioRecorder]';
private static readonly PEAK_LOG_EVERY_FRAMES = 20;
private mediaStream: MediaStream | null = null;
private mediaRecorder: MediaRecorder | null = null;
private audioSource: MediaStreamAudioSourceNode | null = null;
private analyserNode: AnalyserNode | null = null;
private peakPollIntervalId: number | null = null;
private chunks: Blob[] = [];
private peaks: AudioRecordingPeak[] = [];
private recordingStartedAtMs: number | null = null;
private onPeaks: ((peaks: AudioRecordingPeak[]) => void) | null = null;
private peakFrameCount: number = 0;
public async start(
inputDeviceId: string = 'default',
onPeaks?: (peaks: AudioRecordingPeak[]) => void
): Promise<AudioRecordingStartResult> {
if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
throw new Error('Audio recording is already in progress.');
}
if (!navigator.mediaDevices?.getUserMedia) {
throw new Error('This browser does not support microphone recording.');
}
if (typeof MediaRecorder === 'undefined') {
throw new Error('This browser does not support MediaRecorder.');
}
this.cleanup(false);
this.onPeaks = onPeaks ?? null;
this.chunks = [];
this.peaks = [];
this.peakFrameCount = 0;
let stream: MediaStream;
let usedDeviceId = inputDeviceId;
let fellBackToDefault = false;
try {
stream = await navigator.mediaDevices.getUserMedia({
audio: inputDeviceId === 'default'
? true
: { deviceId: { exact: inputDeviceId } },
});
} catch (error) {
if (inputDeviceId === 'default') {
throw error;
}
console.warn(`${KGAudioRecorder.DEBUG_LOG_PREFIX} selected input unavailable, retrying default input`, {
inputDeviceId,
error,
});
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
usedDeviceId = 'default';
fellBackToDefault = true;
}
this.mediaStream = stream;
this.logInputTracks(stream);
const audioContext = Tone.getContext().rawContext as AudioContext;
this.audioSource = audioContext.createMediaStreamSource(stream);
this.analyserNode = audioContext.createAnalyser();
this.analyserNode.fftSize = 2048;
this.audioSource.connect(this.analyserNode);
const mimeType = this.getPreferredMimeType();
this.mediaRecorder = mimeType
? new MediaRecorder(stream, { mimeType })
: new MediaRecorder(stream);
console.info(
`${KGAudioRecorder.DEBUG_LOG_PREFIX} MediaRecorder created`,
{
mimeType: this.mediaRecorder.mimeType || mimeType || 'browser-default',
audioContextState: audioContext.state,
sampleRate: audioContext.sampleRate,
}
);
this.mediaRecorder.ondataavailable = (event: BlobEvent) => {
if (event.data.size > 0) {
this.chunks.push(event.data);
console.info(
`${KGAudioRecorder.DEBUG_LOG_PREFIX} dataavailable`,
{ chunkBytes: event.data.size, totalChunks: this.chunks.length }
);
}
};
this.mediaRecorder.onerror = (event) => {
console.error(`${KGAudioRecorder.DEBUG_LOG_PREFIX} MediaRecorder error`, event);
};
this.mediaRecorder.start(250);
this.recordingStartedAtMs = performance.now();
console.info(`${KGAudioRecorder.DEBUG_LOG_PREFIX} recording started`);
this.startPeakPolling();
return {
usedDeviceId,
fellBackToDefault,
};
}
public async stop(): Promise<AudioRecordingResult | null> {
const recorder = this.mediaRecorder;
if (!recorder) {
return null;
}
if (recorder.state === 'inactive') {
const blob = this.chunks.length > 0
? new Blob(this.chunks, { type: recorder.mimeType || 'audio/webm' })
: null;
const durationSeconds = this.recordingStartedAtMs === null
? 0
: Math.max(0, (performance.now() - this.recordingStartedAtMs) / 1000);
this.cleanup(false);
console.info(
`${KGAudioRecorder.DEBUG_LOG_PREFIX} stop requested on inactive recorder`,
{ blobBytes: blob?.size ?? 0, peakFrames: this.peaks.length, durationSeconds }
);
return blob
? { blob, mimeType: blob.type || recorder.mimeType || 'audio/webm', durationSeconds, peaks: [...this.peaks] }
: null;
}
return await new Promise<AudioRecordingResult | null>((resolve) => {
const handleStop = () => {
recorder.removeEventListener('stop', handleStop);
this.capturePeakFrame();
const mimeType = recorder.mimeType || 'audio/webm';
const blob = new Blob(this.chunks, { type: mimeType });
const durationSeconds = this.recordingStartedAtMs === null
? 0
: Math.max(0, (performance.now() - this.recordingStartedAtMs) / 1000);
const peaks = [...this.peaks];
console.info(
`${KGAudioRecorder.DEBUG_LOG_PREFIX} recording stopped`,
{
mimeType,
blobBytes: blob.size,
peakFrames: peaks.length,
durationSeconds,
}
);
this.cleanup(false);
resolve(blob.size > 0 ? { blob, mimeType: blob.type || mimeType, durationSeconds, peaks } : null);
};
recorder.addEventListener('stop', handleStop, { once: true });
recorder.stop();
});
}
public async cancel(): Promise<void> {
const recorder = this.mediaRecorder;
if (recorder && recorder.state !== 'inactive') {
await new Promise<void>((resolve) => {
recorder.addEventListener('stop', () => resolve(), { once: true });
recorder.stop();
});
}
this.cleanup(false);
}
private startPeakPolling(): void {
this.stopPeakPolling();
this.peakPollIntervalId = window.setInterval(() => {
this.capturePeakFrame();
}, 50);
}
private stopPeakPolling(): void {
if (this.peakPollIntervalId !== null) {
window.clearInterval(this.peakPollIntervalId);
this.peakPollIntervalId = null;
}
}
private capturePeakFrame(): void {
if (!this.analyserNode) {
return;
}
const buffer = new Float32Array(this.analyserNode.fftSize);
this.analyserNode.getFloatTimeDomainData(buffer);
let min = 1;
let max = -1;
for (const sample of buffer) {
if (sample < min) min = sample;
if (sample > max) max = sample;
}
this.peaks.push({ min, max });
this.peakFrameCount += 1;
if (this.peakFrameCount === 1 || this.peakFrameCount % KGAudioRecorder.PEAK_LOG_EVERY_FRAMES === 0) {
console.info(
`${KGAudioRecorder.DEBUG_LOG_PREFIX} analyser peak`,
{
frame: this.peakFrameCount,
min: Number(min.toFixed(4)),
max: Number(max.toFixed(4)),
span: Number((max - min).toFixed(4)),
}
);
}
this.onPeaks?.([...this.peaks]);
}
private getPreferredMimeType(): string | undefined {
const candidates = [
'audio/webm;codecs=opus',
'audio/ogg;codecs=opus',
'audio/mp4',
'audio/webm',
];
return candidates.find(type => typeof MediaRecorder.isTypeSupported === 'function' && MediaRecorder.isTypeSupported(type));
}
private cleanup(resetPeaks: boolean): void {
this.stopPeakPolling();
if (this.audioSource) {
this.audioSource.disconnect();
this.audioSource = null;
}
if (this.analyserNode) {
this.analyserNode.disconnect();
this.analyserNode = null;
}
if (this.mediaStream) {
this.mediaStream.getTracks().forEach(track => track.stop());
this.mediaStream = null;
}
this.mediaRecorder = null;
this.recordingStartedAtMs = null;
this.onPeaks = null;
this.peakFrameCount = 0;
if (resetPeaks) {
this.peaks = [];
this.chunks = [];
}
}
private logInputTracks(stream: MediaStream): void {
const tracks = stream.getAudioTracks();
console.info(
`${KGAudioRecorder.DEBUG_LOG_PREFIX} acquired audio stream`,
tracks.map(track => ({
id: track.id,
label: track.label,
enabled: track.enabled,
muted: track.muted,
readyState: track.readyState,
settings: typeof track.getSettings === 'function' ? track.getSettings() : {},
}))
);
}
}
+4
View File
@@ -78,8 +78,10 @@ interface AppConfig {
};
audio: {
enable_audio_capture_for_screen_sharing: boolean;
input_device_id: string;
lookahead_time: number;
midi_automation_interpolation_interval_ms: number;
output_device_id: string;
playback_delay: number;
recording_offset: number;
};
@@ -253,8 +255,10 @@ export class ConfigManager {
},
audio: {
enable_audio_capture_for_screen_sharing: false,
input_device_id: 'default',
lookahead_time: 0.05,
midi_automation_interpolation_interval_ms: 10,
output_device_id: 'default',
playback_delay: 0.2,
recording_offset: 0
},