v28.5 update sync

This commit is contained in:
steveseguin
2025-12-05 10:24:21 -05:00
committed by Steve Seguin
parent f3d18ad3ee
commit c7be6d88d2
92 changed files with 52973 additions and 8512 deletions

View File

@@ -0,0 +1,69 @@
class MeterProcessor extends AudioWorkletProcessor {
constructor() {
super();
this._peak = 0;
this._rms = 0;
this._clipped = 0;
this._frame = 0;
this._silentFrames = 0;
this._updateInterval = Math.round(sampleRate * 0.032);
}
process(inputs) {
const input = inputs[0];
if (!input || !input.length) {
this._frame += 128;
this._silentFrames += 1;
return true;
}
const channelData = input[0];
if (!channelData) {
this._frame += 128;
this._silentFrames += 1;
return true;
}
let peak = this._peak;
let sumSquares = 0;
let clipped = this._clipped;
for (let i = 0; i < channelData.length; i++) {
const sample = channelData[i];
const absSample = Math.abs(sample);
if (absSample > peak) {
peak = absSample;
}
if (absSample >= 0.89) {
clipped += 1;
}
sumSquares += sample * sample;
}
this._frame += channelData.length;
this._peak = peak;
this._clipped = clipped;
this._rms += sumSquares;
if (this._frame >= this._updateInterval) {
const rms = Math.sqrt(this._rms / this._frame);
const payload = {
peak,
rms,
clipped,
silent: rms <= 0.001,
timestamp: currentTime,
};
this.port.postMessage(payload);
this._peak = 0;
this._rms = 0;
this._clipped = 0;
this._frame = 0;
this._silentFrames = payload.silent ? this._silentFrames + 1 : 0;
}
return true;
}
}
registerProcessor('podcast-meter', MeterProcessor);

83
core/audio/meters.js Normal file
View File

@@ -0,0 +1,83 @@
import { levelBus } from '../events/level-bus.js';
const loadedWorklets = new WeakSet();
const DEFAULT_WORKLET_URL = new URL('./meter.worklet.js', import.meta.url).toString();
async function ensureWorkletModule(audioContext, workletUrl = DEFAULT_WORKLET_URL) {
if (loadedWorklets.has(audioContext)) {
return;
}
await audioContext.audioWorklet.addModule(workletUrl);
loadedWorklets.add(audioContext);
}
function createSource(audioContext, track) {
if (window.MediaStreamTrackAudioSourceNode) {
try {
return new MediaStreamTrackAudioSourceNode(audioContext, { track });
} catch (error) {
console.warn('Falling back to MediaStream source', error);
}
}
const stream = new MediaStream([track]);
return audioContext.createMediaStreamSource(stream);
}
const DEFAULT_ANALYSER_OPTIONS = {
fftSize: 512,
minDecibels: -110,
maxDecibels: -10,
smoothingTimeConstant: 0.75,
};
export async function monitorTrackLevel(audioContext, track, { uuid, trackType = 'audio', metadata = {} } = {}) {
if (!track || track.kind !== 'audio') {
throw new Error('monitorTrackLevel expects an audio MediaStreamTrack.');
}
await ensureWorkletModule(audioContext);
const source = createSource(audioContext, track);
const workletNode = new AudioWorkletNode(audioContext, 'podcast-meter');
const analyser = audioContext.createAnalyser();
analyser.fftSize = DEFAULT_ANALYSER_OPTIONS.fftSize;
analyser.minDecibels = DEFAULT_ANALYSER_OPTIONS.minDecibels;
analyser.maxDecibels = DEFAULT_ANALYSER_OPTIONS.maxDecibels;
analyser.smoothingTimeConstant = DEFAULT_ANALYSER_OPTIONS.smoothingTimeConstant;
const silentSink = new GainNode(audioContext, { gain: 0 });
source.connect(workletNode);
source.connect(analyser);
workletNode.connect(silentSink);
analyser.connect(silentSink);
silentSink.connect(audioContext.destination);
workletNode.port.onmessage = (event) => {
levelBus.publishLevel?.({
uuid,
trackType,
metadata,
...event.data,
});
};
return {
node: workletNode,
analyser,
source,
sink: silentSink,
disconnect: (options = {}) => {
try {
workletNode.port.onmessage = null;
workletNode.disconnect();
analyser.disconnect();
silentSink.disconnect();
} catch (error) {
console.warn('Failed to disconnect meter worklet node', error);
}
try {
source.disconnect();
} catch (error) {
console.warn('Failed to disconnect meter source', error);
}
if (options.stopTrack) {
track.stop();
}
},
};
}

14
core/events/event-bus.js Normal file
View File

@@ -0,0 +1,14 @@
export class EventBus extends EventTarget {
emit(type, detail = {}) {
const event = new CustomEvent(type, { detail });
this.dispatchEvent(event);
}
on(type, callback, options) {
const handler = (event) => callback(event.detail, event);
this.addEventListener(type, handler, options);
return () => this.removeEventListener(type, handler, options);
}
}
export const createScopedBus = () => new EventBus();

19
core/events/level-bus.js Normal file
View File

@@ -0,0 +1,19 @@
import { EventBus } from './event-bus.js';
export const LEVEL_EVENT = 'level';
export const CLIP_EVENT = 'clip';
export const SILENCE_EVENT = 'silence';
class LevelBus extends EventBus {
publishLevel(payload) {
this.emit(LEVEL_EVENT, payload);
if (payload?.clipped) {
this.emit(CLIP_EVENT, payload);
}
if (payload?.silent) {
this.emit(SILENCE_EVENT, payload);
}
}
}
export const levelBus = new LevelBus();

7
core/index.js Normal file
View File

@@ -0,0 +1,7 @@
export * from './events/event-bus.js';
export * from './events/level-bus.js';
export * from './legacy/session-bridge.js';
export { bridgeLegacyMeters } from './legacy/meter-bridge.js';
export * from './recording/index.js';
export * from './audio/meters.js';
export * from './uploads/index.js';

View File

@@ -0,0 +1,75 @@
import { waitForLegacySession } from './session-bridge.js';
import { levelBus } from '../events/level-bus.js';
const DEFAULT_INTERVAL_MS = 120;
const SILENCE_THRESHOLD = 2; // matches legacy behaviour where values < 2 are ignored
function normaliseLoudness(value) {
if (typeof value !== 'number' || Number.isNaN(value)) {
return null;
}
const constrained = Math.max(0, value);
const peak = Math.min(1, constrained / 120);
const rms = Math.min(1, constrained / 160);
return { peak, rms, raw: constrained };
}
export async function bridgeLegacyMeters(options = {}) {
const { intervalMs = DEFAULT_INTERVAL_MS } = options;
const session = await waitForLegacySession({ timeoutMs: 15000 });
const lastValues = new Map();
function publish(uuid, loudness, metadata) {
if (typeof loudness !== 'number' || loudness < SILENCE_THRESHOLD) {
return;
}
const key = `${uuid}`;
if (lastValues.get(key) === loudness) {
return;
}
lastValues.set(key, loudness);
const values = normaliseLoudness(loudness);
if (!values) {
return;
}
levelBus.publishLevel({
uuid,
trackType: 'audio',
peak: values.peak,
rms: values.rms,
raw: values.raw,
source: 'legacy-meter',
timestamp: performance.now(),
metadata,
});
}
function poll() {
if (session.stats && typeof session.stats.Audio_Loudness === 'number') {
publish('local', session.stats.Audio_Loudness, { kind: 'local' });
}
Object.entries(session.rpcs || {}).forEach(([uuid, peer]) => {
if (!peer || !peer.stats) {
return;
}
const loudness = peer.stats.Audio_Loudness;
if (typeof loudness !== 'number') {
return;
}
publish(uuid, loudness, {
kind: 'remote',
streamID: peer.streamID,
label: peer.label,
});
});
}
const timer = setInterval(poll, intervalMs);
poll();
return () => {
clearInterval(timer);
lastValues.clear();
};
}

View File

@@ -0,0 +1,58 @@
const SESSION_POLL_MS = 25;
export function getLegacySession() {
if (typeof window === 'undefined') {
throw new Error('Session bridge requires a browser context.');
}
if (!window.session) {
throw new Error('Legacy session object is not initialised yet.');
}
return window.session;
}
export async function waitForLegacySession(options = {}) {
const { timeoutMs = 5000 } = options;
const start = performance.now();
while (true) {
if (window.session) {
return window.session;
}
if (performance.now() - start > timeoutMs) {
throw new Error('Timed out waiting for legacy session initialisation.');
}
await new Promise((resolve) => setTimeout(resolve, SESSION_POLL_MS));
}
}
export function onLegacyEvent(eventName, handler) {
const session = getLegacySession();
if (!session._podcastStudioListeners) {
session._podcastStudioListeners = new Map();
}
if (!session._podcastStudioListeners.has(eventName)) {
session._podcastStudioListeners.set(eventName, new Set());
}
const listeners = session._podcastStudioListeners.get(eventName);
listeners.add(handler);
return () => {
listeners.delete(handler);
};
}
// shim to forward events from legacy dispatchers
export function forwardLegacyEvent(eventName, payload) {
const session = getLegacySession();
const listeners = session._podcastStudioListeners?.get(eventName);
if (!listeners) {
return;
}
listeners.forEach((fn) => {
try {
fn(payload);
} catch (error) {
console.error('Legacy event listener failed', error);
}
});
}

3
core/recording/index.js Normal file
View File

@@ -0,0 +1,3 @@
export { MultiTrackRecorder } from './multitrack-recorder.js';
export { TrackRecorder } from './track-recorder.js';
export { convertBlobToWav, audioBufferToWav } from './wav-encoder.js';

View File

@@ -0,0 +1,398 @@
import { waitForLegacySession } from '../legacy/session-bridge.js';
import { monitorTrackLevel } from '../audio/meters.js';
import { TrackRecorder } from './track-recorder.js';
import { convertBlobToWav } from './wav-encoder.js';
const DEFAULT_OPTIONS = {
includeLocal: true,
includeRemotes: true,
includeScreenshares: false,
includeVideo: false,
mimeType: null,
timeslice: 0,
bitsPerSecond: null,
monitorLevels: true,
audioContext: null,
targetSampleRate: 48000,
filenamePrefix: 'podcast',
};
function sanitizeSegment(value, fallback = 'track') {
if (!value) {
return fallback;
}
return String(value)
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || fallback;
}
function extensionFromMime(mimeType) {
if (!mimeType) {
return 'bin';
}
if (mimeType.includes('wav')) {
return 'wav';
}
if (mimeType.includes('webm')) {
return 'webm';
}
if (mimeType.includes('ogg')) {
return 'ogg';
}
if (mimeType.includes('mp4')) {
return 'mp4';
}
if (mimeType.includes('m4a')) {
return 'm4a';
}
return 'bin';
}
function buildTimestamp(epoch = Date.now()) {
return new Date(epoch).toISOString().replace(/[:.]/g, '-');
}
function safeCloneTrack(track) {
try {
return track.clone();
} catch (error) {
console.warn('Unable to clone track, using original reference', error);
return track;
}
}
function gatherTracksFromStream(stream, { includeVideo }) {
if (!stream) {
return { audio: [], video: [] };
}
const audio = stream.getAudioTracks ? stream.getAudioTracks() : [];
const video = includeVideo && stream.getVideoTracks ? stream.getVideoTracks() : [];
return {
audio: audio.filter(Boolean),
video: video.filter(Boolean),
};
}
export class MultiTrackRecorder extends EventTarget {
constructor(options = {}) {
super();
this.options = { ...DEFAULT_OPTIONS, ...options };
this.sessionPromise = null;
this.session = null;
this.recorders = new Map();
this.files = new Map();
this.trackMeters = new Map();
this.startedAt = null;
}
async ensureSession() {
if (this.session) {
return this.session;
}
if (!this.sessionPromise) {
this.sessionPromise = waitForLegacySession();
}
this.session = await this.sessionPromise;
return this.session;
}
async listRecordableParticipants() {
const session = await this.ensureSession();
const participants = [];
if (this.options.includeLocal && session.streamSrc) {
participants.push({
uuid: 'local',
kind: 'local',
label: session.label || 'Host',
stream: session.streamSrc,
streamID: session.streamID,
});
}
if (this.options.includeRemotes && session.rpcs) {
Object.entries(session.rpcs).forEach(([uuid, peer]) => {
if (!peer) {
return;
}
const label = peer.label || peer.streamID || uuid;
const stream = peer.streamSrc || peer.stream || peer.videoElement?.srcObject;
if (!stream) {
return;
}
participants.push({
uuid,
kind: 'remote',
label,
stream,
streamID: peer.streamID,
});
if (this.options.includeScreenshares && peer.screenShareStream) {
participants.push({
uuid: `${uuid}:screen`,
kind: 'screenshare',
label: `${label} (Screen)`,
stream: peer.screenShareStream,
streamID: peer.streamID ? `${peer.streamID}:screen` : `${uuid}:screen`,
});
}
});
}
return participants;
}
createTrackRecorders(participant, options) {
const { includeVideo, mimeType, bitsPerSecond, timeslice, monitorLevels, audioContext } = options;
const { audio, video } = gatherTracksFromStream(participant.stream, { includeVideo });
const recorders = [];
audio.forEach((track, index) => {
const cloned = safeCloneTrack(track);
const recorder = new TrackRecorder({
track: cloned,
uuid: participant.uuid,
label: `${participant.label || participant.uuid}#${index + 1}`,
kind: 'audio',
mimeType,
});
recorder.start({ timeslice, bitsPerSecond });
recorders.push({ recorder, trackType: 'audio', channelIndex: index });
if (monitorLevels && audioContext) {
monitorTrackLevel(audioContext, cloned, {
uuid: participant.uuid,
trackType: 'audio',
metadata: { channelIndex: index, label: participant.label },
})
.then((meter) => {
const meterKey = `${participant.uuid}:audio:${index}`;
this.trackMeters.set(meterKey, meter);
this.dispatchEvent(
new CustomEvent('meter-ready', {
detail: {
participant,
trackType: 'audio',
channelIndex: index,
meter,
},
}),
);
})
.catch((error) => {
console.warn('Failed to attach meter to track', error);
});
}
});
video.forEach((track, index) => {
const cloned = safeCloneTrack(track);
const recorder = new TrackRecorder({
track: cloned,
uuid: participant.uuid,
label: `${participant.label || participant.uuid}-video#${index + 1}`,
kind: 'video',
mimeType: null,
});
recorder.start({ timeslice, bitsPerSecond });
recorders.push({ recorder, trackType: 'video', channelIndex: index });
});
return recorders;
}
async start(customOptions = {}) {
const options = { ...this.options, ...customOptions };
this.options = options;
this.files.clear();
this.startedAt = Date.now();
if (this.recorders.size) {
throw new Error('MultiTrackRecorder already running.');
}
const participants = await this.listRecordableParticipants();
const extras = Array.isArray(options.extraParticipants)
? options.extraParticipants
.map((participant, index) => {
if (!participant || !participant.stream) {
return null;
}
const uuid = participant.uuid || `external-${index}`;
return {
uuid,
kind: participant.kind || 'external',
label: participant.label || uuid,
stream: participant.stream,
streamID: participant.streamID || uuid,
external: true,
};
})
.filter(Boolean)
: [];
const participantLookup = new Map();
participants.forEach((participant) => {
if (participant && participant.uuid) {
participantLookup.set(participant.uuid, participant);
}
});
extras.forEach((participant) => {
if (!participantLookup.has(participant.uuid)) {
participants.push(participant);
participantLookup.set(participant.uuid, participant);
}
});
if (!participants.length) {
throw new Error('No recordable participants found.');
}
participants.forEach((participant) => {
const recorders = this.createTrackRecorders(participant, options);
recorders.forEach(({ recorder, trackType, channelIndex }) => {
const key = `${participant.uuid}:${trackType}:${channelIndex}`;
this.recorders.set(key, recorder);
recorder.addEventListener('data', (event) => {
this.dispatchEvent(
new CustomEvent('chunk', {
detail: {
participant,
trackType,
channelIndex,
data: event.detail,
},
}),
);
});
recorder.addEventListener('error', (event) => {
this.dispatchEvent(
new CustomEvent('error', {
detail: {
participant,
trackType,
channelIndex,
error: event.detail,
},
}),
);
});
recorder.addEventListener('stop', () => {
const blob = recorder.toBlob();
if (blob) {
const fileKey = `${participant.uuid}:${trackType}:${channelIndex}`;
this.files.set(fileKey, {
blob,
originalBlob: blob,
participant,
trackType,
channelIndex,
mimeType: recorder.mimeType,
originalMimeType: recorder.mimeType,
durationSeconds: typeof recorder.getDurationSeconds === 'function' ? recorder.getDurationSeconds() : null,
recorderLabel: recorder.label,
});
}
this.dispatchEvent(
new CustomEvent('track-stopped', {
detail: {
participant,
trackType,
channelIndex,
},
}),
);
});
});
});
this.dispatchEvent(new CustomEvent('start', { detail: { participants } }));
}
async stop() {
const stops = [];
this.recorders.forEach((recorder) => {
stops.push(recorder.stop());
});
this.recorders.clear();
const meterStops = [];
this.trackMeters.forEach((meter) => {
if (meter && typeof meter.disconnect === 'function') {
meterStops.push(Promise.resolve().then(() => meter.disconnect()));
}
});
this.trackMeters.clear();
await Promise.allSettled(stops);
await Promise.allSettled(meterStops);
await this.packageAudioFiles();
const packaged = this.files;
this.dispatchEvent(new CustomEvent('stop', { detail: { files: packaged } }));
return packaged;
}
getFiles() {
return this.files;
}
getTrackMeter(uuid, trackType = 'audio', channelIndex = 0) {
if (!uuid) {
return null;
}
const key = `${uuid}:${trackType}:${channelIndex}`;
return this.trackMeters.get(key) || null;
}
async packageAudioFiles() {
if (!this.files.size) {
return this.files;
}
const conversions = [];
this.files.forEach((meta, key) => {
const fileMeta = meta;
if (!fileMeta.filename) {
fileMeta.filename = this.generateFilename(fileMeta);
}
if (fileMeta.trackType !== 'audio' || !fileMeta.blob) {
// For non-audio tracks we still normalise mime type/filename
fileMeta.mimeType = fileMeta.mimeType || fileMeta.originalMimeType || fileMeta.blob?.type || 'application/octet-stream';
fileMeta.size = fileMeta.blob?.size || fileMeta.originalBlob?.size || 0;
return;
}
conversions.push(
(async () => {
try {
const wavBlob = await convertBlobToWav(fileMeta.blob, {
sampleRate: this.options.targetSampleRate,
audioContext: this.options.audioContext,
});
fileMeta.originalBlob = fileMeta.originalBlob || fileMeta.blob;
fileMeta.originalMimeType = fileMeta.originalMimeType || fileMeta.mimeType;
fileMeta.blob = wavBlob;
fileMeta.mimeType = 'audio/wav';
fileMeta.filename = this.generateFilename(fileMeta, { extension: 'wav' });
fileMeta.size = wavBlob.size;
} catch (error) {
console.warn('Failed to package track as WAV, falling back to original blob', error);
const extension = extensionFromMime(fileMeta.mimeType || fileMeta.originalMimeType || fileMeta.blob?.type);
fileMeta.filename = this.generateFilename(fileMeta, { extension });
fileMeta.packagingError = error;
fileMeta.size = fileMeta.blob?.size || fileMeta.originalBlob?.size || 0;
}
})(),
);
});
await Promise.allSettled(conversions);
return this.files;
}
generateFilename(meta, { extension } = {}) {
const prefix = sanitizeSegment(this.options.filenamePrefix, 'podcast');
const room = sanitizeSegment(this.session?.roomid || this.session?.roomID || 'room');
const participantLabel = sanitizeSegment(meta.participant?.streamID || meta.participant?.label || meta.participant?.uuid, meta.participant?.uuid || 'participant');
const trackKind = sanitizeSegment(meta.trackType || 'track');
const channel = typeof meta.channelIndex === 'number' ? `c${meta.channelIndex + 1}` : 'c1';
const timestamp = buildTimestamp(this.startedAt);
const ext = extension || extensionFromMime(meta.mimeType || meta.originalMimeType || 'audio/wav');
return `${prefix}-${room}-${participantLabel}-${trackKind}-${channel}-${timestamp}.${ext}`;
}
}

View File

@@ -0,0 +1,134 @@
const DEFAULT_AUDIO_MIME_TYPES = [
'audio/webm;codecs=opus',
'audio/ogg;codecs=opus',
'audio/webm',
'audio/ogg',
];
export class TrackRecorder extends EventTarget {
constructor({ track, uuid, label, kind = 'audio', mimeType }) {
super();
if (!track) {
throw new Error('TrackRecorder requires a MediaStreamTrack.');
}
this.track = track;
this.uuid = uuid;
this.label = label;
this.kind = kind;
this.mimeType = mimeType || TrackRecorder.pickSupportedMime(kind);
this.mediaRecorder = null;
this.chunks = [];
this.startedAt = null;
this.stoppedAt = null;
this.stopResolver = null;
this.stopComplete = null;
}
static pickSupportedMime(kind) {
if (typeof MediaRecorder === 'undefined') {
return null;
}
if (kind === 'video') {
const candidates = [
'video/webm;codecs=vp9,opus',
'video/webm;codecs=vp8,opus',
'video/webm',
];
return candidates.find((type) => MediaRecorder.isTypeSupported(type)) || null;
}
return DEFAULT_AUDIO_MIME_TYPES.find((type) => MediaRecorder.isTypeSupported(type)) || null;
}
createStream() {
const stream = new MediaStream();
stream.addTrack(this.track);
return stream;
}
start(options = {}) {
if (this.mediaRecorder) {
throw new Error('TrackRecorder already started.');
}
const stream = this.createStream();
const recorderOptions = {};
if (this.mimeType) {
recorderOptions.mimeType = this.mimeType;
}
if (options.bitsPerSecond) {
recorderOptions.bitsPerSecond = options.bitsPerSecond;
}
this.mediaRecorder = new MediaRecorder(stream, recorderOptions);
this.chunks = [];
this.startedAt = performance.now();
this.stoppedAt = null;
this.stopComplete = new Promise((resolve) => {
this.stopResolver = resolve;
});
this.mediaRecorder.ondataavailable = (event) => {
if (event.data && event.data.size > 0) {
this.chunks.push(event.data);
this.dispatchEvent(new CustomEvent('data', { detail: event.data }));
}
};
this.mediaRecorder.onstop = (event) => {
this.stoppedAt = performance.now();
if (this.stopResolver) {
this.stopResolver();
this.stopResolver = null;
}
this.dispatchEvent(new CustomEvent('stop', { detail: event }));
};
this.mediaRecorder.onerror = (event) => {
this.dispatchEvent(new CustomEvent('error', { detail: event.error || event }));
};
this.mediaRecorder.start(options.timeslice || 0);
}
stop() {
if (!this.mediaRecorder) {
return Promise.resolve();
}
const completion = this.stopComplete || Promise.resolve();
if (this.mediaRecorder.state !== 'inactive') {
try {
this.mediaRecorder.stop();
} catch (error) {
console.warn('MediaRecorder stop failed', error);
}
}
return completion
.catch((error) => {
console.warn('MediaRecorder stop did not resolve cleanly', error);
})
.finally(() => {
try {
this.track.stop();
} catch (error) {
console.warn('Failed to stop cloned track', error);
}
this.mediaRecorder = null;
this.stopComplete = null;
});
}
toBlob() {
if (!this.chunks.length) {
return null;
}
const mimeType = this.mimeType || (this.kind === 'audio' ? 'audio/webm' : 'video/webm');
return new Blob(this.chunks, { type: mimeType });
}
getDurationSeconds() {
if (!this.startedAt) {
return 0;
}
const end = this.stoppedAt || performance.now();
return (end - this.startedAt) / 1000;
}
}

View File

@@ -0,0 +1,140 @@
const DEFAULT_SAMPLE_RATE = 48000;
function writeString(view, offset, string) {
for (let i = 0; i < string.length; i += 1) {
view.setUint8(offset + i, string.charCodeAt(i));
}
}
function interleaveChannels(channelData) {
if (!channelData.length) {
return new Float32Array();
}
const length = channelData[0].length;
if (channelData.length === 1) {
return new Float32Array(channelData[0]);
}
const interleaved = new Float32Array(length * channelData.length);
let index = 0;
for (let i = 0; i < length; i += 1) {
for (let channel = 0; channel < channelData.length; channel += 1) {
interleaved[index] = channelData[channel][i];
index += 1;
}
}
return interleaved;
}
function floatTo16BitPCM(view, offset, input) {
for (let i = 0; i < input.length; i += 1, offset += 2) {
let sample = input[i];
sample = Math.max(-1, Math.min(1, sample));
const converted = sample < 0 ? sample * 0x8000 : sample * 0x7FFF;
view.setInt16(offset, converted, true);
}
}
export function audioBufferToWav(audioBuffer, { float32 = false } = {}) {
if (!audioBuffer) {
throw new Error('audioBufferToWav expects an AudioBuffer.');
}
const numberOfChannels = audioBuffer.numberOfChannels || 1;
const sampleRate = audioBuffer.sampleRate || DEFAULT_SAMPLE_RATE;
const channelData = [];
for (let i = 0; i < numberOfChannels; i += 1) {
channelData.push(audioBuffer.getChannelData(i));
}
const interleaved = interleaveChannels(channelData);
const bytesPerSample = float32 ? 4 : 2;
const format = float32 ? 3 : 1;
const dataLength = interleaved.length * bytesPerSample;
const buffer = new ArrayBuffer(44 + dataLength);
const view = new DataView(buffer);
writeString(view, 0, 'RIFF');
view.setUint32(4, 36 + dataLength, true);
writeString(view, 8, 'WAVE');
writeString(view, 12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, format, true);
view.setUint16(22, numberOfChannels, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * numberOfChannels * bytesPerSample, true);
view.setUint16(32, numberOfChannels * bytesPerSample, true);
view.setUint16(34, bytesPerSample * 8, true);
writeString(view, 36, 'data');
view.setUint32(40, dataLength, true);
if (float32) {
const floatView = new Float32Array(buffer, 44, interleaved.length);
floatView.set(interleaved);
} else {
floatTo16BitPCM(view, 44, interleaved);
}
return buffer;
}
async function resampleIfNeeded(audioBuffer, targetSampleRate) {
if (!targetSampleRate || !audioBuffer) {
return audioBuffer;
}
if (Math.abs(audioBuffer.sampleRate - targetSampleRate) < 1) {
return audioBuffer;
}
if (typeof OfflineAudioContext === 'undefined') {
return audioBuffer;
}
const length = Math.ceil(audioBuffer.duration * targetSampleRate);
const offline = new OfflineAudioContext(audioBuffer.numberOfChannels, length, targetSampleRate);
const source = offline.createBufferSource();
source.buffer = audioBuffer;
source.connect(offline.destination);
source.start(0);
return offline.startRendering();
}
export async function convertBlobToWav(blob, { sampleRate = DEFAULT_SAMPLE_RATE, float32 = false, audioContext = null } = {}) {
if (!blob) {
throw new Error('convertBlobToWav expects a Blob.');
}
if (typeof window === 'undefined') {
throw new Error('convertBlobToWav requires a browser environment.');
}
const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
if (!audioContext && !AudioContextCtor) {
throw new Error('AudioContext not supported in this environment.');
}
const context = audioContext || new AudioContextCtor();
const shouldCloseContext = !audioContext && typeof context.close === 'function';
let audioBuffer;
try {
const arrayBuffer = await blob.arrayBuffer();
const bufferCopy = arrayBuffer.slice(0);
audioBuffer = await new Promise((resolve, reject) => {
context.decodeAudioData(bufferCopy, resolve, reject);
});
} catch (error) {
if (shouldCloseContext) {
await context.close();
}
throw error;
}
if (shouldCloseContext) {
await context.close();
}
let processedBuffer = audioBuffer;
try {
processedBuffer = await resampleIfNeeded(audioBuffer, sampleRate);
} catch (error) {
console.warn('Failed to resample audio buffer, using original sample rate', error);
}
const wavArrayBuffer = audioBufferToWav(processedBuffer, { float32 });
return new Blob([wavArrayBuffer], { type: 'audio/wav' });
}

View File

@@ -0,0 +1,242 @@
import { waitForLegacySession } from '../legacy/session-bridge.js';
const DRIVE_CHUNK_ALIGNMENT = 256 * 1024;
const DEFAULT_DRIVE_CHUNK_SIZE = 4 * 1024 * 1024;
const DEFAULT_DROPBOX_CHUNK_SIZE = 8 * 1024 * 1024;
function createAbortError() {
const error = new Error('Aborted');
error.name = 'AbortError';
return error;
}
export class CloudUploadCoordinator {
constructor(session) {
this.session = session;
}
static async create() {
const session = await waitForLegacySession();
return new CloudUploadCoordinator(session);
}
ensureDriveClient() {
if (!this.session.gdrive && typeof window.setupGoogleDriveUploader === 'function') {
this.session.gdrive = window.setupGoogleDriveUploader();
}
return this.session.gdrive || null;
}
async ensureDropboxClient(token, options = {}) {
let opts = options;
if (typeof token === 'object' && token !== null && !Array.isArray(token)) {
opts = token;
token = undefined;
}
opts = opts || {};
const forceReauth = Boolean(opts.forceReauth);
if (typeof token === 'string' && token.trim().length) {
token = token.trim();
}
const manualTokenProvided = typeof token === 'string' && token.length > 0;
const previousClient = this.session.dbx || null;
const oauthRecord =
(this.session && this.session.dropboxOAuth) ||
(typeof session !== 'undefined' ? session.dropboxOAuth : null) ||
null;
const oauthFresh = Boolean(
oauthRecord && (!oauthRecord.expiresAt || Date.now() < oauthRecord.expiresAt),
);
if (!forceReauth && !manualTokenProvided && this.session.dbx && oauthFresh) {
return this.session.dbx;
}
if (typeof window.setupDropbox !== 'function') {
return this.session.dbx || null;
}
try {
const client = await window.setupDropbox(token, opts);
if (client) {
this.session.dbx = client;
return client;
}
} catch (error) {
if (forceReauth && previousClient && !this.session.dbx) {
this.session.dbx = previousClient;
}
throw error;
}
if (!this.session.dbx && previousClient) {
this.session.dbx = previousClient;
}
return this.session.dbx || null;
}
startDriveUpload(filename, sessionUri) {
if (typeof window.setupGoogleDriveUploader !== 'function') {
throw new Error('Google Drive uploader is not available in this build.');
}
return window.setupGoogleDriveUploader(filename, sessionUri);
}
createDriveChunkWriter(filename, sessionUri) {
const uploader = this.startDriveUpload(filename, sessionUri);
return {
addChunk: (chunk) => uploader?.addChunk?.(chunk),
finalize: () => uploader?.finalize?.(),
uploader,
};
}
createDropboxChunkWriter(filename) {
if (typeof window.streamVideoToDropbox !== 'function') {
throw new Error('Dropbox uploader is not available in this build.');
}
return window.streamVideoToDropbox(filename);
}
hasDriveAccess() {
return Boolean(this.session.gdrive && this.session.gdrive.accessToken);
}
hasDropboxAccess() {
return Boolean(this.session.dbx);
}
async uploadBlob(blob, options = {}) {
if (!blob) {
throw new Error('uploadBlob expects a Blob.');
}
const {
filename,
drive = true,
dropbox = true,
onProgress,
signal,
driveChunkSize = DEFAULT_DRIVE_CHUNK_SIZE,
dropboxChunkSize = DEFAULT_DROPBOX_CHUNK_SIZE,
} = options;
const results = {};
if (drive) {
try {
results.drive = await this.uploadBlobToDrive(blob, {
filename,
onProgress,
signal,
chunkSize: driveChunkSize,
});
} catch (error) {
results.drive = { status: 'error', service: 'drive', error };
}
} else {
results.drive = { status: 'skipped', service: 'drive', reason: 'disabled' };
}
if (dropbox) {
try {
results.dropbox = await this.uploadBlobToDropbox(blob, {
filename,
onProgress,
signal,
chunkSize: dropboxChunkSize,
});
} catch (error) {
results.dropbox = { status: 'error', service: 'dropbox', error };
}
} else {
results.dropbox = { status: 'skipped', service: 'dropbox', reason: 'disabled' };
}
return results;
}
async uploadBlobToDrive(blob, { filename, onProgress, signal, chunkSize = DEFAULT_DRIVE_CHUNK_SIZE } = {}) {
const client = this.ensureDriveClient();
if (!client) {
return { status: 'skipped', service: 'drive', reason: 'unavailable' };
}
if (signal?.aborted) {
throw createAbortError();
}
const name = filename || `recording-${Date.now()}.wav`;
let writer;
try {
writer = this.createDriveChunkWriter(name, this.session.gdrive?.sessionUri);
} catch (error) {
return { status: 'error', service: 'drive', error };
}
if (!writer?.addChunk) {
return { status: 'error', service: 'drive', error: new Error('Drive writer unavailable') };
}
const total = blob.size || 0;
const alignment = DRIVE_CHUNK_ALIGNMENT;
const adjustedChunkSize = Math.max(alignment, Math.floor(chunkSize / alignment) * alignment);
let uploaded = 0;
for (let offset = 0; offset < total; offset += adjustedChunkSize) {
if (signal?.aborted) {
throw createAbortError();
}
const chunk = blob.slice(offset, Math.min(total, offset + adjustedChunkSize), blob.type || 'application/octet-stream');
writer.addChunk(chunk);
uploaded += chunk.size;
if (typeof onProgress === 'function') {
onProgress({
service: 'drive',
uploaded,
total,
percentage: total ? Math.min(100, Math.round((uploaded / total) * 100)) : 0,
});
}
}
writer.addChunk(false);
if (typeof writer.finalize === 'function') {
try {
await writer.finalize();
} catch (error) {
return { status: 'error', service: 'drive', error };
}
}
return { status: 'uploaded', service: 'drive', filename: name, bytes: uploaded };
}
async uploadBlobToDropbox(blob, { filename, onProgress, signal, chunkSize = DEFAULT_DROPBOX_CHUNK_SIZE } = {}) {
const client = await this.ensureDropboxClient();
if (!client) {
return { status: 'skipped', service: 'dropbox', reason: 'unavailable' };
}
if (signal?.aborted) {
throw createAbortError();
}
let writer;
const name = filename || `recording-${Date.now()}.wav`;
try {
writer = await this.createDropboxChunkWriter(name);
} catch (error) {
return { status: 'error', service: 'dropbox', error };
}
if (typeof writer !== 'function') {
return { status: 'error', service: 'dropbox', error: new Error('Dropbox writer unavailable') };
}
const total = blob.size || 0;
let uploaded = 0;
for (let offset = 0; offset < total; offset += chunkSize) {
if (signal?.aborted) {
throw createAbortError();
}
const chunk = blob.slice(offset, Math.min(total, offset + chunkSize), blob.type || 'application/octet-stream');
await writer(chunk);
uploaded += chunk.size;
if (typeof onProgress === 'function') {
onProgress({
service: 'dropbox',
uploaded,
total,
percentage: total ? Math.min(100, Math.round((uploaded / total) * 100)) : 0,
});
}
}
await writer(false);
return { status: 'uploaded', service: 'dropbox', filename: name, bytes: uploaded };
}
}

1
core/uploads/index.js Normal file
View File

@@ -0,0 +1 @@
export { CloudUploadCoordinator } from './cloud-storage.js';