UNPKG

@omnimedia/omnitool

Version:

open source video processing tools

179 lines 7.1 kB
import { AudioLevels } from "./audio-levels.js"; import { ms } from '../../../../units/ms.js'; import { realtime } from '../../parts/schedulers.js'; import { seconds } from '../../../../units/seconds.js'; import { computeItemDuration } from '../../parts/handy.js'; import { resolveAudioGain } from '../../../parts/audio.js'; import { createAudioSampler } from '../../parts/samplers/audio/sampler.js'; import { createVisualSampler } from '../../parts/samplers/visual/sampler.js'; import { CursorVisualSampler, ReverseCursorVisualSampler } from '../../export/parts/cursor.js'; export class Playback { driver; timeline; resolveMedia; audioLevels; audioSampler; seekVisualSampler; playVisualSampler = null; reversePlayVisualSampler = null; #playbackStart = ms(0); #audioStartSec = null; #playbackRate = 1; #controller = realtime(); onTick = this.#controller.onTick; audioContext = new AudioContext({ sampleRate: 48000 }); #masterGain = this.audioContext.createGain(); audioNodes = new Map(); #audioAbort = null; constructor(driver, timeline, resolveMedia) { this.driver = driver; this.timeline = timeline; this.resolveMedia = resolveMedia; this.#masterGain.connect(this.audioContext.destination); this.#updateMasterGain(); this.audioLevels = new AudioLevels(this.audioContext, () => this.currentTime, () => this.#controller.isPlaying()); this.seekVisualSampler = createVisualSampler(this.resolveMedia); this.audioSampler = createAudioSampler(this.resolveMedia); this.#samples(); } update(timeline) { this.timeline = timeline; this.#updateMasterGain(); } #updateMasterGain() { this.#masterGain.gain.value = resolveAudioGain(this.timeline.audio); } get isPlaying() { return this.#controller.isPlaying(); } async #samples() { for await (const _ of this.#controller.ticks()) { const time = this.currentTime; const layers = this.#playbackRate >= 0 ? await this.playVisualSampler?.next(time) ?? [] : await this.reversePlayVisualSampler?.next(time) ?? []; const frame = await this.driver.composite(layers); frame.close(); const hasEnded = this.#playbackRate >= 0 ? time >= this.duration : time <= 0; if (hasEnded) this.pause(); } } async seek(time) { this.pause(); this.#playbackStart = time; return await this.seekVisualSampler.sample(this.timeline, time); } async start() { if (this.#controller.isPlaying()) return; await this.audioContext.resume(); this.#playbackStart = this.currentTime; this.#audioStartSec = this.audioContext.currentTime; this.#stopAudio(); this.playVisualSampler = new CursorVisualSampler(this.driver, this.resolveMedia, this.timeline); this.reversePlayVisualSampler = new ReverseCursorVisualSampler(this.driver, this.resolveMedia, this.timeline); this.#controller.play(); this.audioLevels.start(); this.#startAudio(); } pause() { this.#playbackStart = this.currentTime; this.#controller.pause(); this.audioLevels.stop(); this.#stopAudio(); if (this.playVisualSampler) { this.playVisualSampler.cancel(); this.playVisualSampler = null; } if (this.reversePlayVisualSampler) { this.reversePlayVisualSampler.cancel(); this.reversePlayVisualSampler = null; } } get duration() { return computeItemDuration(this.timeline.rootId, this.timeline); } get currentTime() { if (!this.#controller.isPlaying() || this.#audioStartSec === null) return this.#playbackStart; const elapsedMs = (this.audioContext.currentTime - this.#audioStartSec) * 1000; const current = this.#playbackStart + elapsedMs * this.#playbackRate; return ms(Math.max(0, Math.min(this.duration, current))); } get playbackRate() { return this.#playbackRate; } set playbackRate(rate) { if (!Number.isFinite(rate) || rate === 0) throw new Error(`Invalid playback rate "${rate}".`); this.#playbackStart = this.currentTime; this.#audioStartSec = this.#controller.isPlaying() ? this.audioContext.currentTime : null; const wasReversed = this.#playbackRate < 0; this.#playbackRate = rate; if (this.#controller.isPlaying()) { if (wasReversed && rate > 0) { this.playVisualSampler?.cancel(); this.playVisualSampler = new CursorVisualSampler(this.driver, this.resolveMedia, this.timeline); } else if (!wasReversed && rate < 0) { this.reversePlayVisualSampler?.cancel(); this.reversePlayVisualSampler = new ReverseCursorVisualSampler(this.driver, this.resolveMedia, this.timeline); } this.#syncAudio(); } } setFps(fps) { this.#controller.setFPS(fps); } #syncAudio() { this.#stopAudio(); this.#startAudio(); } #stopAudio() { this.#audioAbort?.abort(); this.#audioAbort = null; for (const [node, gain] of this.audioNodes) { node.stop(); this.audioLevels.detach(gain); } this.audioNodes.clear(); } async #startAudio() { if (this.#playbackRate !== 1) return; const from = seconds(this.#playbackStart / 1000); this.#audioAbort = new AbortController(); const signal = this.#audioAbort.signal; const ctx = this.audioContext; if (this.#audioStartSec === null) return; for await (const { itemId, sample, timestamp, gain } of this.audioSampler.sampleAudio(this.timeline, ms(from * 1000))) { if (signal.aborted || !this.#controller.isPlaying()) return; while (timestamp - (ctx.currentTime - this.#audioStartSec + from) > 0.75) await new Promise(r => setTimeout(r, 25)); const node = ctx.createBufferSource(); const itemGain = ctx.createGain(); node.buffer = sample.toAudioBuffer(); itemGain.gain.value = gain; node.connect(itemGain); itemGain.connect(this.#masterGain); node.onended = () => { this.audioNodes.delete(node); this.audioLevels.detach(itemGain); }; this.audioNodes.set(node, itemGain); this.audioLevels.attach(itemId, itemGain); const startAt = this.#audioStartSec + timestamp - from; startAt >= ctx.currentTime ? node.start(startAt) : node.start(ctx.currentTime, ctx.currentTime - startAt); } } } //# sourceMappingURL=playback.js.map