chaimu
Version:
✨ Chaimu is an audio player that synchronizes audio with video
469 lines (468 loc) • 13.5 kB
JavaScript
import debug from "./debug.js";
export const videoLipSyncEvents = [
"playing",
"ratechange",
"play",
"waiting",
"pause",
"seeked",
];
export function initAudioContext() {
const audioContext = window.AudioContext || window.webkitAudioContext;
return audioContext ? new audioContext() : undefined;
}
export class BasePlayer {
static name = "BasePlayer";
chaimu;
fetch;
_src;
fetchOpts;
constructor(chaimu, src) {
this.chaimu = chaimu;
this._src = src;
this.fetch = this.chaimu.fetchFn;
this.fetchOpts = this.chaimu.fetchOpts;
}
async init() {
return this;
}
async clear() {
return this;
}
lipSync(_mode = false) {
return this;
}
handleVideoEvent = (event) => {
debug.log(`handle video ${event.type}`);
this.lipSync(event.type);
return this;
};
removeVideoEvents() {
for (const e of videoLipSyncEvents) {
this.chaimu.video?.removeEventListener(e, this.handleVideoEvent);
}
return this;
}
addVideoEvents() {
for (const e of videoLipSyncEvents) {
this.chaimu.video?.addEventListener(e, this.handleVideoEvent);
}
return this;
}
async play() {
return this;
}
async pause() {
return this;
}
get name() {
return this.constructor.name;
}
set src(url) {
this._src = url;
}
get src() {
return this._src;
}
get currentSrc() {
return this._src;
}
set volume(_value) {
return;
}
get volume() {
return 0;
}
get playbackRate() {
return 0;
}
set playbackRate(_value) {
return;
}
get currentTime() {
return 0;
}
}
export class AudioPlayer extends BasePlayer {
static name = "AudioPlayer";
audio;
gainNode;
audioSource;
constructor(chaimu, src) {
super(chaimu, src);
this.updateAudio();
}
initAudioBooster() {
if (!this.chaimu.audioContext) {
return this;
}
this.disconnectAudioNodes();
this.gainNode = this.chaimu.audioContext.createGain();
this.gainNode.connect(this.chaimu.audioContext.destination);
this.audioSource = this.chaimu.audioContext.createMediaElementSource(this.audio);
this.audioSource.connect(this.gainNode);
return this;
}
disconnectAudioNodes() {
if (this.audioSource) {
this.audioSource.disconnect();
this.audioSource = undefined;
}
if (this.gainNode) {
this.gainNode.disconnect();
this.gainNode = undefined;
}
}
updateAudio() {
this.audio = new Audio(this.src);
this.audio.crossOrigin = "anonymous";
return this;
}
async init() {
this.updateAudio();
this.initAudioBooster();
return this;
}
audioErrorHandle = (e) => {
console.error("[AudioPlayer]", e);
};
lipSync(mode = false) {
debug.log("[AudioPlayer] lipsync video", this.chaimu.video);
if (!this.chaimu.video) {
return this;
}
this.audio.currentTime = this.chaimu.video.currentTime;
this.audio.playbackRate = this.chaimu.video.playbackRate;
if (!mode) {
debug.log("[AudioPlayer] lipsync mode isn't set");
return this;
}
debug.log(`[AudioPlayer] lipsync mode is ${mode}`);
switch (mode) {
case "play":
case "playing":
case "seeked": {
if (!this.chaimu.video.paused) {
this.syncPlay();
}
return this;
}
case "pause":
case "waiting": {
void this.pause();
return this;
}
default: {
return this;
}
}
}
async clear() {
this.audio.pause();
this.audio.src = "";
this.audio.removeAttribute("src");
this.disconnectAudioNodes();
return this;
}
syncPlay() {
debug.log("[AudioPlayer] sync play called");
if (this.audio) {
this.audio.play().catch(this.audioErrorHandle);
}
return this;
}
async play() {
debug.log("[AudioPlayer] play called");
if (this.audio) {
await this.audio.play().catch(this.audioErrorHandle);
}
return this;
}
async pause() {
debug.log("[AudioPlayer] pause called");
if (this.audio) {
this.audio.pause();
}
return this;
}
set src(url) {
this._src = url;
if (!url) {
void this.clear();
return;
}
this.audio.src = url;
}
get src() {
return this._src;
}
get currentSrc() {
return this.audio.currentSrc;
}
set volume(value) {
if (this.gainNode) {
this.gainNode.gain.value = value;
return;
}
this.audio.volume = value;
}
get volume() {
return this.gainNode ? this.gainNode.gain.value : this.audio.volume;
}
get playbackRate() {
return this.audio.playbackRate;
}
set playbackRate(value) {
this.audio.playbackRate = value;
}
get currentTime() {
return this.audio.currentTime;
}
}
export class ChaimuPlayer extends BasePlayer {
static name = "ChaimuPlayer";
audioBuffer;
audioElement;
mediaElementSource;
gainNode;
blobUrl;
isClearing = false;
isInitializing = false;
clearingPromise;
async fetchAudio() {
if (!this._src) {
throw new Error("No audio source provided");
}
if (!this.chaimu.audioContext) {
throw new Error("No audio context available");
}
debug.log(`[ChaimuPlayer] Fetching audio from ${this._src}...`);
let tempBlobUrl;
try {
const res = await this.fetch(this._src, this.fetchOpts);
debug.log(`[ChaimuPlayer] Decoding fetched audio...`);
const data = await res.arrayBuffer();
const blob = new Blob([data]);
tempBlobUrl = URL.createObjectURL(blob);
this.audioBuffer = await this.chaimu.audioContext.decodeAudioData(data);
if (this.blobUrl) {
URL.revokeObjectURL(this.blobUrl);
}
this.blobUrl = tempBlobUrl;
tempBlobUrl = undefined;
}
catch (err) {
if (tempBlobUrl) {
URL.revokeObjectURL(tempBlobUrl);
}
throw new Error(`Failed to fetch audio file, because ${err.message}`);
}
return this;
}
initAudioBooster() {
if (!this.chaimu.audioContext) {
return this;
}
this.disconnectAudioNodes();
this.gainNode = this.chaimu.audioContext.createGain();
return this;
}
disconnectAudioNodes() {
if (this.mediaElementSource) {
this.mediaElementSource.disconnect();
this.mediaElementSource = undefined;
}
if (this.gainNode) {
this.gainNode.disconnect();
this.gainNode = undefined;
}
}
async init() {
if (this.isInitializing) {
throw new Error("Initialization already in progress");
}
this.isInitializing = true;
try {
await this.fetchAudio();
this.initAudioBooster();
this.createAudioElement();
return this;
}
finally {
this.isInitializing = false;
}
}
createAudioElement() {
if (!this.chaimu.audioContext) {
throw new Error("No audio context available");
}
if (!this.blobUrl) {
throw new Error("No blob URL available.");
}
const audio = new Audio(this.blobUrl);
audio.crossOrigin = "anonymous";
if ("preservesPitch" in audio) {
audio.preservesPitch = true;
if ("mozPreservesPitch" in audio)
audio.mozPreservesPitch = true;
if ("webkitPreservesPitch" in audio)
audio.webkitPreservesPitch = true;
}
this.audioElement = audio;
this.mediaElementSource = this.chaimu.audioContext.createMediaElementSource(audio);
this.mediaElementSource.connect(this.gainNode);
this.gainNode.connect(this.chaimu.audioContext.destination);
}
lipSync(mode = false) {
debug.log("[ChaimuPlayer] lipsync video", this.chaimu.video, this);
if (!this.chaimu.video) {
return this;
}
if (!mode) {
debug.log("[ChaimuPlayer] lipsync mode isn't set");
return this;
}
debug.log(`[ChaimuPlayer] lipsync mode is ${mode}`);
switch (mode) {
case "play":
case "playing":
case "ratechange":
case "seeked": {
if (!this.chaimu.video.paused) {
void this.start();
}
return this;
}
case "pause":
case "waiting": {
void this.pause();
return this;
}
default: {
return this;
}
}
}
async reopenCtx() {
if (!this.chaimu.audioContext) {
throw new Error("No audio context available");
}
try {
if (this.chaimu.audioContext.state !== "closed") {
await this.chaimu.audioContext.close();
}
}
catch (err) {
debug.log("[ChaimuPlayer] Failed to close audio context:", err);
}
this.chaimu.audioContext = initAudioContext();
return this;
}
async clear() {
if (this.isClearing && this.clearingPromise) {
return this.clearingPromise;
}
if (!this.chaimu.audioContext) {
throw new Error("No audio context available");
}
debug.log("clear audio context");
this.isClearing = true;
this.clearingPromise = (async () => {
try {
await this.pause();
if (this.audioElement) {
this.audioElement.pause();
this.audioElement = undefined;
}
if (this.blobUrl) {
URL.revokeObjectURL(this.blobUrl);
this.blobUrl = undefined;
}
this.disconnectAudioNodes();
const oldVolume = this.gainNode ? this.gainNode.gain.value : 1;
await this.reopenCtx();
if (this.chaimu.audioContext) {
this.initAudioBooster();
this.volume = oldVolume;
}
return this;
}
finally {
this.isClearing = false;
this.clearingPromise = undefined;
}
})();
return this.clearingPromise;
}
async start() {
if (!this.chaimu.audioContext) {
throw new Error("No audio context available");
}
if (!this.audioElement) {
throw new Error("Audio element is missing");
}
if (this.isClearing && this.clearingPromise) {
debug.log("The other cleaner is still running, waiting...");
await this.clearingPromise;
}
debug.log("starting audio via HTMLAudioElement");
await this.play();
if (this.chaimu.video) {
this.audioElement.currentTime = this.chaimu.video.currentTime;
this.audioElement.playbackRate = this.chaimu.video.playbackRate;
}
this.audioElement
.play()
.catch((err) => debug.log("[ChaimuPlayer] Play audioElement failed:", err));
return this;
}
async pause() {
if (!this.chaimu.audioContext) {
throw new Error("No audio context available");
}
if (this.audioElement) {
this.audioElement.pause();
}
if (this.chaimu.audioContext.state === "running") {
await this.chaimu.audioContext.suspend();
}
return this;
}
async play() {
if (!this.chaimu.audioContext) {
throw new Error("No audio context available");
}
await this.chaimu.audioContext.resume();
return this;
}
set src(url) {
this._src = url;
}
get src() {
return this._src;
}
get currentSrc() {
return this._src;
}
set volume(value) {
if (this.gainNode) {
this.gainNode.gain.value = value;
}
}
get volume() {
return this.gainNode ? this.gainNode.gain.value : 0;
}
set playbackRate(value) {
if (this.audioElement) {
this.audioElement.playbackRate = value;
}
}
get playbackRate() {
return this.audioElement
? this.audioElement.playbackRate
: (this.chaimu.video?.playbackRate ?? 1);
}
get currentTime() {
return this.chaimu.video?.currentTime ?? 0;
}
}