Skip to content

Commit 004125e

Browse files
authored
Merge pull request #407 from bcorfman/musicfix
Another audio fix
2 parents 561d777 + 7fd3365 commit 004125e

5 files changed

Lines changed: 104 additions & 2 deletions

File tree

src/cloud/assetUrls.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { resolveProjectAssetPathToUrl } from '../assets/projectAssetPaths';
33
import { resolveApiUrl } from './api';
44

55
const cloudAssetUrlCache = new Map<string, Promise<string | null>>();
6+
const pathAudioUrlCache = new Map<string, Promise<string>>();
67

78
function cloudAssetContentUrl(assetId: string): string {
89
return resolveApiUrl(`/api/v1/assets/${encodeURIComponent(assetId)}/content`);
@@ -27,7 +28,26 @@ export function inlinePreviewUrlForAssetSource(source: AssetFileSource): string
2728

2829
export async function resolveAssetSourceUrl(source: AssetFileSource): Promise<string | null> {
2930
if (source.kind === 'embedded') return source.dataUrl;
30-
if (source.kind === 'path') return resolveProjectAssetPathToUrl(source.path);
31+
if (source.kind === 'path') {
32+
const directUrl = resolveProjectAssetPathToUrl(source.path);
33+
if (!source.mimeType?.startsWith('audio/')) return directUrl;
34+
35+
let pending = pathAudioUrlCache.get(source.path);
36+
if (!pending) {
37+
pending = (async () => {
38+
try {
39+
const res = await fetch(directUrl, { cache: 'force-cache' });
40+
if (!res.ok) return directUrl;
41+
const blob = await res.blob();
42+
return URL.createObjectURL(blob);
43+
} catch {
44+
return directUrl;
45+
}
46+
})();
47+
pathAudioUrlCache.set(source.path, pending);
48+
}
49+
return pending;
50+
}
3151

3252
let pending = cloudAssetUrlCache.get(source.assetId);
3353
if (!pending) {

src/phaser/EditorScene.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1589,6 +1589,7 @@ export class EditorScene extends Phaser.Scene {
15891589
pending = (async () => {
15901590
const url = await resolveAssetSourceUrl(asset.source);
15911591
if (!url || asset.source.kind !== 'path') return;
1592+
if (url.startsWith('blob:')) return;
15921593
const response = await fetch(url, { cache: 'force-cache' });
15931594
if (!response.ok) return;
15941595
await response.blob();

src/runtime/services/BasicAudioService.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,16 @@ export interface SoundManagerLike {
1919
removeByKey?(key: string): void;
2020
}
2121

22+
type AudioContextLike = {
23+
state?: string;
24+
resume?: () => Promise<unknown> | unknown;
25+
};
26+
27+
type UnlockableSoundManagerLike = SoundManagerLike & {
28+
context?: AudioContextLike;
29+
unlock?: () => void;
30+
};
31+
2232
function clamp01(value: number): number {
2333
if (!Number.isFinite(value)) return 1;
2434
return Math.max(0, Math.min(1, value));
@@ -33,7 +43,25 @@ export class BasicAudioService implements AudioService {
3343
private readonly getKey: (assetId: string) => string = (assetId) => `audio:${assetId}`,
3444
) {}
3545

46+
private resumeManagerIfNeeded(): void {
47+
const manager = this.manager as UnlockableSoundManagerLike;
48+
try {
49+
manager.unlock?.();
50+
} catch {
51+
// ignore unlock errors
52+
}
53+
const context = manager.context;
54+
if (!context?.resume) return;
55+
if (context.state && context.state !== 'suspended' && context.state !== 'interrupted') return;
56+
try {
57+
void context.resume();
58+
} catch {
59+
// ignore resume errors
60+
}
61+
}
62+
3663
public playMusic(assetId: string, options: { loop?: boolean; volume?: number; fadeMs?: number } = {}): void {
64+
this.resumeManagerIfNeeded();
3765
const loop = options.loop ?? true;
3866
const volume = clamp01(options.volume ?? 1);
3967
const fadeMs = Number.isFinite(Number(options.fadeMs)) ? Math.max(0, Number(options.fadeMs)) : 0;
@@ -101,6 +129,7 @@ export class BasicAudioService implements AudioService {
101129
}
102130

103131
public playSfx(assetId: string, options: { volume?: number } = {}): void {
132+
this.resumeManagerIfNeeded();
104133
const volume = clamp01(options.volume ?? 1);
105134
const key = this.getKey(assetId);
106135
try {

tests/cloud/asset-urls.test.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1-
import { describe, expect, it } from 'vitest';
1+
import { afterEach, describe, expect, it, vi } from 'vitest';
22

33
import { inlinePreviewUrlForAssetSource, resolveAssetSourceUrl } from '../../src/cloud/assetUrls';
44

55
describe('asset URL helpers', () => {
6+
afterEach(() => {
7+
vi.restoreAllMocks();
8+
});
9+
610
it('resolves project-relative path assets to usable browser URLs', async () => {
711
const source = {
812
kind: 'path' as const,
@@ -17,4 +21,22 @@ describe('asset URL helpers', () => {
1721
expect(inlineUrl).toContain('enemy_A.png');
1822
expect(resolvedUrl).toContain('enemy_A.png');
1923
});
24+
25+
it('resolves path-backed audio assets to blob URLs for runtime playback', async () => {
26+
const fetchMock = vi.fn(async () => new Response(new Blob(['mp3'], { type: 'audio/mpeg' }), { status: 200 }));
27+
vi.stubGlobal('fetch', fetchMock);
28+
const createObjectUrl = vi.fn(() => 'blob:demo-pack-theme');
29+
vi.spyOn(URL, 'createObjectURL').mockImplementation(createObjectUrl);
30+
31+
const resolvedUrl = await resolveAssetSourceUrl({
32+
kind: 'path',
33+
path: 'assets/demo-pack/audio/Simulacra-chosic.com_.mp3',
34+
originalName: 'Simulacra-chosic.com_.mp3',
35+
mimeType: 'audio/mpeg',
36+
});
37+
38+
expect(fetchMock).toHaveBeenCalledTimes(1);
39+
expect(createObjectUrl).toHaveBeenCalledTimes(1);
40+
expect(resolvedUrl).toBe('blob:demo-pack-theme');
41+
});
2042
});

tests/runtime/audio-service.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,36 @@ describe('BasicAudioService', () => {
146146
expect(sound?.playCount).toBeGreaterThanOrEqual(2);
147147
});
148148

149+
it('resumes a suspended audio context before starting music playback', () => {
150+
const sound = new FakeSound();
151+
let resumeCalls = 0;
152+
const context = {
153+
state: 'suspended',
154+
resume() {
155+
resumeCalls += 1;
156+
context.state = 'running';
157+
return Promise.resolve();
158+
},
159+
};
160+
const manager: SoundManagerLike = {
161+
add() {
162+
if (context.state === 'suspended') throw new Error('audio context is suspended');
163+
return sound;
164+
},
165+
removeByKey() {},
166+
context,
167+
} as SoundManagerLike & { context: typeof context };
168+
169+
const svc = new BasicAudioService(manager, (id) => `audio:${id}`);
170+
const project = { audio: { sounds: { a: { id: 'a', source: { kind: 'embedded', dataUrl: 'data:audio/mp3;base64,AAAA', originalName: 'a.mp3', mimeType: 'audio/mpeg' } } } } } as any;
171+
172+
svc.applySceneAudio({ music: { assetId: 'a', loop: true, volume: 1, fadeMs: 0 } } as any, project);
173+
174+
expect(resumeCalls).toBe(1);
175+
expect(context.state).toBe('running');
176+
expect(sound.isPlaying).toBe(true);
177+
});
178+
149179
it('retries ambience playback when the first attempt fails', () => {
150180
let shouldThrow = true;
151181
const sounds = new Map<string, FakeSound>();

0 commit comments

Comments
 (0)