UNPKG

@omnimedia/omnitool

Version:

open source video processing tools

239 lines (189 loc) 6.31 kB
import {AudioLevels} from "./audio-levels.js" import {Fps} from '../../../../units/fps.js' import {ms, Ms} from '../../../../units/ms.js' import {Driver} from '../../../../driver/driver.js' import {realtime} from '../../parts/schedulers.js' import {seconds} from '../../../../units/seconds.js' import {computeItemDuration} from '../../parts/handy.js' import {TimelineFile} from '../../../parts/basics.js' import {resolveAudioGain} from '../../../parts/audio.js' import {DecoderSource} from '../../../../driver/fns/schematic.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 { audioLevels audioSampler seekVisualSampler playVisualSampler: CursorVisualSampler | null = null reversePlayVisualSampler: ReverseCursorVisualSampler | null = null #playbackStart = ms(0) #audioStartSec: number | null = null #playbackRate = 1 #controller = realtime() onTick = this.#controller.onTick audioContext = new AudioContext({sampleRate: 48000}) #masterGain = this.audioContext.createGain() audioNodes = new Map<AudioBufferSourceNode, GainNode>() #audioAbort: AbortController | null = null constructor( private driver: Driver, private timeline: TimelineFile, private resolveMedia: (hash: string) => DecoderSource ) { 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: TimelineFile) { 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: Ms) { 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: number) { 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: 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) } } }