mirror of
https://github.com/SrIzan10/vdo.ninja.git
synced 2026-05-01 11:05:24 +00:00
v28.5 update sync
This commit is contained in:
committed by
Steve Seguin
parent
f3d18ad3ee
commit
c7be6d88d2
69
core/audio/meter.worklet.js
Normal file
69
core/audio/meter.worklet.js
Normal 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
83
core/audio/meters.js
Normal 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
14
core/events/event-bus.js
Normal 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
19
core/events/level-bus.js
Normal 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
7
core/index.js
Normal 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';
|
||||
75
core/legacy/meter-bridge.js
Normal file
75
core/legacy/meter-bridge.js
Normal 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();
|
||||
};
|
||||
}
|
||||
58
core/legacy/session-bridge.js
Normal file
58
core/legacy/session-bridge.js
Normal 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
3
core/recording/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export { MultiTrackRecorder } from './multitrack-recorder.js';
|
||||
export { TrackRecorder } from './track-recorder.js';
|
||||
export { convertBlobToWav, audioBufferToWav } from './wav-encoder.js';
|
||||
398
core/recording/multitrack-recorder.js
Normal file
398
core/recording/multitrack-recorder.js
Normal 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}`;
|
||||
}
|
||||
}
|
||||
134
core/recording/track-recorder.js
Normal file
134
core/recording/track-recorder.js
Normal 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;
|
||||
}
|
||||
}
|
||||
140
core/recording/wav-encoder.js
Normal file
140
core/recording/wav-encoder.js
Normal 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' });
|
||||
}
|
||||
242
core/uploads/cloud-storage.js
Normal file
242
core/uploads/cloud-storage.js
Normal 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
1
core/uploads/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { CloudUploadCoordinator } from './cloud-storage.js';
|
||||
Reference in New Issue
Block a user