A music player that connects to your cloud/distributed storage. diffuse.sh
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696import { describe, it } from "@std/testing/bdd";import { expect } from "@std/expect";
import { testWeb } from "@tests/common/index.ts";
describe("components/engine/audio", () => { it("has default volume of 0.75", async () => { const result = await testWeb(async () => { const mod = await import("~/components/engine/audio/element.js"); const engine = new mod.CLASS(); document.body.append(engine); return engine.volume(); });
expect(result).toBe(0.75); });
it("adjustVolume updates the global volume signal", async () => { const result = await testWeb(async () => { const mod = await import("~/components/engine/audio/element.js"); const engine = new mod.CLASS(); document.body.append(engine); engine.adjustVolume({ volume: 0.5 }); return engine.volume(); });
expect(result).toBe(0.5); });
it("adjustVolume clamps to the provided value", async () => { const result = await testWeb(async () => { const mod = await import("~/components/engine/audio/element.js"); const engine = new mod.CLASS(); document.body.append(engine); engine.adjustVolume({ volume: 1.0 }); return engine.volume(); });
expect(result).toBe(1.0); });
it("isPlaying returns false with no items", async () => { const result = await testWeb(async () => { const mod = await import("~/components/engine/audio/element.js"); const engine = new mod.CLASS(); document.body.append(engine); return engine.isPlaying(); });
expect(result).toBe(false); });
it("supply with URL items updates the items signal", async () => { const result = await testWeb(async () => { const mod = await import("~/components/engine/audio/element.js"); const { trackA } = await import("~/testing/sample/tracks.js"); const engine = new mod.CLASS(); document.body.append(engine);
engine.supply({ audio: [ { id: "audio-a", url: "/testing/sample/audio.mp3", isPreload: false, track: trackA, }, { id: "audio-b", url: "/testing/sample/audio.mp3", isPreload: false, track: trackA, }, ], });
return engine.items().map((i) => i.id); });
expect(result).toEqual(["audio-a", "audio-b"]); });
it("supply with same IDs does not update items signal", async () => { const result = await testWeb(async () => { const mod = await import("~/components/engine/audio/element.js"); const { trackA } = await import("~/testing/sample/tracks.js"); const engine = new mod.CLASS(); document.body.append(engine);
const item = { id: "audio-a", url: "/testing/sample/audio.mp3", isPreload: false, track: trackA, };
engine.supply({ audio: [item] }); const itemsAfterFirst = engine.items();
engine.supply({ audio: [item] }); const itemsAfterSecond = engine.items();
// Same reference means the signal was not updated return itemsAfterFirst === itemsAfterSecond; });
expect(result).toBe(true); });
it("supply replaces items when IDs change", async () => { const result = await testWeb(async () => { const mod = await import("~/components/engine/audio/element.js"); const { trackA } = await import("~/testing/sample/tracks.js"); const engine = new mod.CLASS(); document.body.append(engine);
engine.supply({ audio: [{ id: "audio-a", url: "/testing/sample/audio.mp3", isPreload: false, track: trackA, }], });
engine.supply({ audio: [{ id: "audio-b", url: "/testing/sample/audio.mp3", isPreload: false, track: trackA, }], });
return engine.items().map((i) => i.id); });
expect(result).toEqual(["audio-b"]); });
it("supply with isPreload change triggers items update", async () => { const result = await testWeb(async () => { const mod = await import("~/components/engine/audio/element.js"); const { trackA } = await import("~/testing/sample/tracks.js"); const engine = new mod.CLASS(); document.body.append(engine);
engine.supply({ audio: [{ id: "audio-a", url: "/testing/sample/audio.mp3", isPreload: true, track: trackA, }], });
engine.supply({ audio: [{ id: "audio-a", url: "/testing/sample/audio.mp3", isPreload: false, track: trackA, }], });
return engine.items()[0]?.isPreload; });
expect(result).toBe(false); });
it("persists volume to localStorage", async () => { const stored = await testWeb(async () => { const mod = await import("~/components/engine/audio/element.js"); const engine = new mod.CLASS(); document.body.append(engine); engine.adjustVolume({ volume: 0.3 });
for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i)!; if (key.includes("engine/audio") && key.endsWith("/volume")) { return localStorage.getItem(key); } } return null; });
expect(stored).toBe("0.3"); });
it("restores volume from localStorage on connect", async () => { const result = await testWeb(async () => { const mod = await import("~/components/engine/audio/element.js");
// Set volume with first engine instance const engine1 = new mod.CLASS(); document.body.append(engine1); engine1.adjustVolume({ volume: 0.4 });
// Second instance reads from localStorage const engine2 = new mod.CLASS(); document.body.append(engine2); return engine2.volume(); });
expect(result).toBe(0.4); });
it("persists the audio output device to localStorage", async () => { const stored = await testWeb(async () => { const mod = await import("~/components/engine/audio/element.js"); const engine = new mod.CLASS(); document.body.append(engine); await engine.setSink("test-output-device");
for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i)!; if (key.includes("engine/audio") && key.endsWith("/sink")) { return localStorage.getItem(key); } } return null; });
expect(stored).toBe("test-output-device"); });
it("clears the stored audio output device when set to the system default", async () => { const stored = await testWeb(async () => { const mod = await import("~/components/engine/audio/element.js"); const engine = new mod.CLASS(); document.body.append(engine); await engine.setSink("test-output-device"); await engine.setSink("");
for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i)!; if (key.includes("engine/audio") && key.endsWith("/sink")) { return localStorage.getItem(key); } } return null; });
expect(stored).toBe(null); });
it("applies the stored audio output device to the shared AudioContext", async () => { const calls = await testWeb(async () => { const calls: string[] = [];
// Stub `setSinkId` so the test doesn't depend on native support and can // observe what the engine applies. const proto = AudioContext.prototype as unknown as { setSinkId?: (id: string) => Promise<void>; }; const original = proto.setSinkId; proto.setSinkId = (id: string) => { calls.push(id); return Promise.resolve(); };
try { localStorage.setItem("diffuse/engine/audio/default/sink", "device-x");
const mod = await import("~/components/engine/audio/element.js"); const engine = new mod.CLASS(); document.body.append(engine);
// Accessing `webAudio` lazily creates the graph, which applies the // stored sink for the group. void engine.webAudio; await new Promise((resolve) => setTimeout(resolve, 0));
return calls; } finally { if (original === undefined) delete proto.setSinkId; else proto.setSinkId = original; } });
expect(calls).toContain("device-x"); });
// Sample audio tests
it("state returns undefined for unknown audio id", async () => { const result = await testWeb(async () => { const mod = await import("~/components/engine/audio/element.js"); const { trackA } = await import("~/testing/sample/tracks.js"); const engine = new mod.CLASS(); document.body.append(engine);
engine.supply({ audio: [{ id: "audio-a", url: "/testing/sample/audio.mp3", isPreload: false, track: trackA, }], });
return engine.state("no-such-id"); });
expect(result).toBeUndefined(); });
it("state has initial loadingState of loading before audio loads", async () => { const result = await testWeb(async () => { const mod = await import("~/components/engine/audio/element.js"); const { trackA } = await import("~/testing/sample/tracks.js"); const engine = new mod.CLASS(); document.body.append(engine);
engine.supply({ audio: [{ id: "audio-a", url: "/testing/sample/audio.mp3", isPreload: false, track: trackA, }], });
const st = engine.state("audio-a"); return { loadingState: st?.loadingState(), currentTime: st?.currentTime(), isPlaying: st?.isPlaying(), hasEnded: st?.hasEnded(), }; });
expect(result.loadingState).toBe("loading"); expect(result.currentTime).toBe(0); expect(result.isPlaying).toBe(false); expect(result.hasEnded).toBe(false); });
it("loadingState becomes loaded after audio loads", async () => { const result = await testWeb(async () => { const mod = await import("~/components/engine/audio/element.js"); const { trackA } = await import("~/testing/sample/tracks.js"); const engine = new mod.CLASS(); document.body.append(engine);
engine.supply({ audio: [{ id: "audio-a", url: "/testing/sample/audio.mp3", isPreload: false, track: trackA, }], });
const audioEl = engine.querySelector( 'de-audio-item[id="audio-a"]:not([preload]) audio', ) as HTMLAudioElement;
await new Promise<void>((resolve, reject) => { const st = engine.state("audio-a"); if (st?.loadingState() === "loaded") { resolve(); return; } const timer = setTimeout( () => reject(new Error("timeout waiting for audio load")), 10000, ); const done = () => { clearTimeout(timer); resolve(); }; audioEl.addEventListener("canplay", done, { once: true }); audioEl.addEventListener("suspend", done, { once: true }); audioEl.addEventListener( "error", () => { clearTimeout(timer); reject(new Error("audio load error")); }, { once: true }, ); });
return engine.state("audio-a")?.loadingState(); });
expect(result).toBe("loaded"); });
it("duration is positive after audio loads", async () => { const result = await testWeb(async () => { const mod = await import("~/components/engine/audio/element.js"); const { trackA } = await import("~/testing/sample/tracks.js"); const engine = new mod.CLASS(); document.body.append(engine);
engine.supply({ audio: [{ id: "audio-a", url: "/testing/sample/audio.mp3", isPreload: false, track: trackA, }], });
const audioEl = engine.querySelector( 'de-audio-item[id="audio-a"]:not([preload]) audio', ) as HTMLAudioElement;
await new Promise<void>((resolve, reject) => { if (audioEl.duration > 0 && isFinite(audioEl.duration)) { resolve(); return; } const timer = setTimeout( () => reject(new Error("timeout waiting for duration")), 10000, ); const done = () => { clearTimeout(timer); resolve(); }; audioEl.addEventListener("durationchange", done, { once: true }); audioEl.addEventListener( "error", () => { clearTimeout(timer); reject(new Error("audio load error")); }, { once: true }, ); });
return engine.state("audio-a")?.duration(); });
expect(result).toBeGreaterThan(0); });
it("currentTime is 0 before seek", async () => { const result = await testWeb(async () => { const mod = await import("~/components/engine/audio/element.js"); const { trackA } = await import("~/testing/sample/tracks.js"); const engine = new mod.CLASS(); document.body.append(engine);
engine.supply({ audio: [{ id: "audio-a", url: "/testing/sample/audio.mp3", isPreload: false, track: trackA, }], });
const audioEl = engine.querySelector( 'de-audio-item[id="audio-a"]:not([preload]) audio', ) as HTMLAudioElement;
await new Promise<void>((resolve, reject) => { const st = engine.state("audio-a"); if (st?.loadingState() === "loaded") { resolve(); return; } const timer = setTimeout( () => reject(new Error("timeout")), 10000, ); const done = () => { clearTimeout(timer); resolve(); }; audioEl.addEventListener("canplay", done, { once: true }); audioEl.addEventListener("suspend", done, { once: true }); audioEl.addEventListener( "error", () => { clearTimeout(timer); reject(new Error("audio load error")); }, { once: true }, ); });
return engine.state("audio-a")?.currentTime(); });
expect(result).toBe(0); });
it("seek by currentTime updates audio element currentTime", async () => { const result = await testWeb(async () => { const mod = await import("~/components/engine/audio/element.js"); const { trackA } = await import("~/testing/sample/tracks.js"); const engine = new mod.CLASS(); document.body.append(engine);
engine.supply({ audio: [{ id: "audio-a", url: "/testing/sample/audio.mp3", isPreload: false, track: trackA, }], });
const audioEl = engine.querySelector( 'de-audio-item[id="audio-a"]:not([preload]) audio', ) as HTMLAudioElement;
await new Promise<void>((resolve, reject) => { const st = engine.state("audio-a"); if (st?.loadingState() === "loaded") { resolve(); return; } const timer = setTimeout(() => reject(new Error("timeout")), 10000); const done = () => { clearTimeout(timer); resolve(); }; audioEl.addEventListener("canplay", done, { once: true }); audioEl.addEventListener("suspend", done, { once: true }); audioEl.addEventListener( "error", () => { clearTimeout(timer); reject(new Error("audio load error")); }, { once: true }, ); });
engine.seek({ audioId: "audio-a", currentTime: 1 });
return audioEl.currentTime; });
expect(result).toBe(1); });
it("seek by percentage updates audio element currentTime proportionally", async () => { const result = await testWeb(async () => { const mod = await import("~/components/engine/audio/element.js"); const { trackA } = await import("~/testing/sample/tracks.js"); const engine = new mod.CLASS(); document.body.append(engine);
engine.supply({ audio: [{ id: "audio-a", url: "/testing/sample/audio.mp3", isPreload: false, track: trackA, }], });
const audioEl = engine.querySelector( 'de-audio-item[id="audio-a"]:not([preload]) audio', ) as HTMLAudioElement;
await new Promise<void>((resolve, reject) => { if (audioEl.duration > 0 && isFinite(audioEl.duration)) { resolve(); return; } const timer = setTimeout(() => reject(new Error("timeout")), 10000); const done = () => { clearTimeout(timer); resolve(); }; audioEl.addEventListener("durationchange", done, { once: true }); audioEl.addEventListener( "error", () => { clearTimeout(timer); reject(new Error("audio load error")); }, { once: true }, ); });
engine.seek({ audioId: "audio-a", percentage: 0.5 });
return { currentTime: audioEl.currentTime, expected: 0.5 * audioEl.duration, }; });
expect(result.currentTime).toBe(result.expected); });
it("play sets isPlaying to true", async () => { const result = await testWeb(async () => { const mod = await import("~/components/engine/audio/element.js"); const { trackA } = await import("~/testing/sample/tracks.js"); const engine = new mod.CLASS(); document.body.append(engine);
engine.supply({ audio: [{ id: "audio-a", url: "/testing/sample/audio.mp3", isPreload: false, track: trackA, }], });
const audioEl = engine.querySelector( 'de-audio-item[id="audio-a"]:not([preload]) audio', ) as HTMLAudioElement;
await new Promise<void>((resolve, reject) => { const st = engine.state("audio-a"); if (st?.loadingState() === "loaded") { resolve(); return; } const timer = setTimeout(() => reject(new Error("timeout")), 10000); const done = () => { clearTimeout(timer); resolve(); }; audioEl.addEventListener("canplay", done, { once: true }); audioEl.addEventListener("suspend", done, { once: true }); audioEl.addEventListener( "error", () => { clearTimeout(timer); reject(new Error("audio load error")); }, { once: true }, ); });
// play() sets isPlaying optimistically before the audio.play() promise settles engine.play({ audioId: "audio-a", volume: 0.5 });
return engine.state("audio-a")?.isPlaying(); });
expect(result).toBe(true); });
it("pause resets isPlaying to false", async () => { const result = await testWeb(async () => { const mod = await import("~/components/engine/audio/element.js"); const { trackA } = await import("~/testing/sample/tracks.js"); const engine = new mod.CLASS(); document.body.append(engine);
engine.supply({ audio: [{ id: "audio-a", url: "/testing/sample/audio.mp3", isPreload: false, track: trackA, }], });
const audioEl = engine.querySelector( 'de-audio-item[id="audio-a"]:not([preload]) audio', ) as HTMLAudioElement;
await new Promise<void>((resolve, reject) => { const st = engine.state("audio-a"); if (st?.loadingState() === "loaded") { resolve(); return; } const timer = setTimeout(() => reject(new Error("timeout")), 10000); const done = () => { clearTimeout(timer); resolve(); }; audioEl.addEventListener("canplay", done, { once: true }); audioEl.addEventListener("suspend", done, { once: true }); audioEl.addEventListener( "error", () => { clearTimeout(timer); reject(new Error("audio load error")); }, { once: true }, ); });
engine.play({ audioId: "audio-a", volume: 0.5 }); engine.pause({ audioId: "audio-a" });
// Wait for pause event to propagate await new Promise<void>((resolve) => setTimeout(resolve, 50));
return engine.state("audio-a")?.isPlaying(); });
expect(result).toBe(false); });
it("playing event sets isPlaying to true", async () => { const result = await testWeb(async () => { const mod = await import("~/components/engine/audio/element.js"); const { trackA } = await import("~/testing/sample/tracks.js"); const engine = new mod.CLASS(); document.body.append(engine);
engine.supply({ audio: [{ id: "audio-a", url: "/testing/sample/audio.mp3", isPreload: false, track: trackA, }], });
const audioEl = engine.querySelector( 'de-audio-item[id="audio-a"]:not([preload]) audio', ) as HTMLAudioElement;
await new Promise<void>((resolve, reject) => { const st = engine.state("audio-a"); if (st?.loadingState() === "loaded") { resolve(); return; } const timer = setTimeout(() => reject(new Error("timeout")), 10000); const done = () => { clearTimeout(timer); resolve(); }; audioEl.addEventListener("canplay", done, { once: true }); audioEl.addEventListener("suspend", done, { once: true }); audioEl.addEventListener( "error", () => { clearTimeout(timer); reject(new Error("audio load error")); }, { once: true }, ); });
// Pause first so isPlaying is false, then dispatch `playing` to verify // that playingEvent sets isPlaying to true on its own. engine.play({ audioId: "audio-a", volume: 0.5 }); engine.pause({ audioId: "audio-a" });
await new Promise<void>((resolve) => setTimeout(resolve, 50)); const isPlayingBefore = engine.state("audio-a")?.isPlaying();
audioEl.dispatchEvent(new Event("playing")); const isPlayingAfter = engine.state("audio-a")?.isPlaying();
return { isPlayingBefore, isPlayingAfter }; });
expect(result.isPlayingBefore).toBe(false); expect(result.isPlayingAfter).toBe(true); });});