A game that stays silent is only half a game, so a sound is an asset like any other (#45)
game-maker / suite (push) Successful in 12m1s

Sounds follow the road fonts already paved: the file goes to the server
content-addressed under its sha256, the project keeps a named asset that
points at it, and nothing is ever re-encoded. The format is read from the
file's own signature, not from what the browser claims - the same .m4a
arrives as audio/mp4, audio/x-m4a or as nothing at all.

Playing is two nodes. "Play sound" takes the asset and may raise or lower
its volume; every call is its own voice, so two jumps in a row are two
jumps heard. Its loop input is a choice of three - from the asset, on,
off - because a checkbox has to spend one of its two positions on "not
set" and would lose a case: an asset that loops could then never be
played once.

Below the graph core stays browser-free: NodeContext hands out an asset
id, BehaviorSystem resolves it against the project and folds the asset's
own volume and loop into the call, and only viewport/SoundLibrary knows
about Web Audio. A run without a sink plays nothing instead of throwing.

The CI browser is muted, so the ear is no witness: the debug hook counts
started and stopped voices, and step 34 reads that counter.
This commit is contained in:
admin committed 2026-09-15 07:54:28 +02:00
1 parent 7babf88636
commit 5671a38044
47 files changed
+2066 -42

No files matched your search

+6 -1
View File
@@ -1,9 +1,11 @@
import type { Application } from 'pixi.js';
import { HttpFontStore } from '../core/store/HttpFontStore';
import { HttpProjectStore } from '../core/store/HttpProjectStore';
import { HttpSoundStore } from '../core/store/HttpSoundStore';
import { HttpTextureStore } from '../core/store/HttpTextureStore';
import { requestRender, type VStack } from '../ui';
import { fontLibrary } from '../viewport/FontLibrary';
import { soundLibrary } from '../viewport/SoundLibrary';
import { textureLibrary } from '../viewport/TextureLibrary';
import { installDebugHook } from './debugHook';
import { Editor } from './Editor';
@@ -13,16 +15,18 @@ const API_ENDPOINT = '/game-maker/api/index.php';
/**
* Where uploaded images live: the API writes them, the browser reads them straight off disk as
* static files named by their content hash. Fonts work the same way, one directory over.
* static files named by their content hash. Fonts and sounds work the same way, one directory over.
*/
const TEXTURE_BASE = '/game-maker/data/textures';
const FONT_BASE = '/game-maker/data/fonts';
const SOUND_BASE = '/game-maker/data/sounds';
/** Wires state, stores and the editor composition together and opens the first project. */
export async function startEditor(app: Application, root: VStack): Promise<void> {
const state = new EditorState();
textureLibrary.configure(TEXTURE_BASE);
fontLibrary.configure(FONT_BASE);
soundLibrary.configure(SOUND_BASE);
const editor = new Editor(
app,
root,
@@ -30,6 +34,7 @@ export async function startEditor(app: Application, root: VStack): Promise<void>
new HttpProjectStore(API_ENDPOINT),
new HttpTextureStore(API_ENDPOINT),
new HttpFontStore(API_ENDPOINT),
new HttpSoundStore(API_ENDPOINT),
);
// An image or a font arrives long after the click that asked for it, and the widget waiting for
// it repaints without telling anyone: both libraries are a wake-up of their own.
+4 -2
View File
@@ -3,6 +3,7 @@ import type { AssetType } from '../core/model/AssetTypes';
import { findAsset, findObjectHolder } from '../core/model/Project';
import type { ProjectStore } from '../core/store/ProjectStore';
import type { FontStore } from '../core/store/FontStore';
import type { SoundStore } from '../core/store/SoundStore';
import type { TextureStore } from '../core/store/TextureStore';
import { gameClock } from '../core/time';
import {
@@ -151,6 +152,7 @@ export class Editor implements PlayHost {
store: ProjectStore,
textures: TextureStore,
fonts: FontStore,
sounds: SoundStore,
) {
this.viewport = new SceneViewport(state);
this.gameView = new GameViewport(state);
@@ -196,9 +198,9 @@ export class Editor implements PlayHost {
this.workspace = new HStack();
this.leftColumn = new VStack({ gap: 1 });
this.assets = new AssetPanel(state, textures, fonts);
this.assets = new AssetPanel(state, textures, fonts, sounds);
this.leftColumn.add(this.assets, 1);
installFileImport(app.canvas, { state, textures, fonts });
installFileImport(app.canvas, { state, textures, fonts, sounds });
this.leftColumn.add(this.hierarchy, 1);
this.centre = new VStack();
+5 -1
View File
@@ -5,6 +5,7 @@ import { hasEditorRun, runsInEditor } from '../core/properties/editorRun';
import { addGameSystems, type BehaviorSystem, behaviorError } from '../runtime/systems/BehaviorSystem';
import { Input } from '../runtime/systems/Input';
import { type ModelWrite, World } from '../runtime/World';
import { soundLibrary } from '../viewport/SoundLibrary';
import type { LiveMachineState } from './graph/GraphCanvas';
import type { EditorState } from './state';
@@ -119,7 +120,7 @@ export class EditorRun {
// Baking a container's content into the scene would be an edit nobody asked for, so it is off
// here: the content runs, but resolved from its template (see `WorldOptions.bakeContainers`).
const fresh = World.instantiate(project, scene.id, { bakeContainers: false });
this.behaviors = addGameSystems(fresh, project, this.input, (e) => runsInEditor(e.props));
this.behaviors = addGameSystems(fresh, project, this.input, (e) => runsInEditor(e.props), soundLibrary);
this.world = fresh;
this.hadFocus = this.hooks.focused();
this.input.attach(this.hooks.focused);
@@ -153,6 +154,9 @@ export class EditorRun {
private stop(): void {
if (!this.world) return;
// Its sounds end with it, for the reason a played run's do – an editor run is restarted by any
// edit, and a loop left behind would pile up one voice per edit.
soundLibrary.stopAll();
this.world.destroy();
this.world = null;
this.behaviors = null;
+8 -1
View File
@@ -16,6 +16,7 @@ import { gameClock } from '../core/time';
import { Input } from '../runtime/systems/Input';
import { World } from '../runtime/World';
import { ui } from '../ui';
import { soundLibrary } from '../viewport/SoundLibrary';
import type { GameViewport } from './viewport/GameViewport';
import type { GraphEditor } from './graph/GraphEditor';
import type { LiveMachineState } from './graph/GraphCanvas';
@@ -72,6 +73,9 @@ export class PlaySession {
togglePause(): void {
if (!this.world) return;
this.state.setPaused(!this.state.paused);
// A held note is part of the picture a pause freezes: the sounds stop where they are and carry
// on mid-note, rather than playing to their end over a still image.
soundLibrary.setSuspended(this.state.paused);
ui().focus.blur();
this.state.setNotice(this.playNotice(), 'quiet');
}
@@ -109,7 +113,7 @@ export class PlaySession {
const world = World.instantiate(runtime, scene.id);
this.world = world;
this.input.attach();
this.behaviors = addGameSystems(world, runtime, this.input);
this.behaviors = addGameSystems(world, runtime, this.input, undefined, soundLibrary);
this.host.gameView.setWorld(world);
this.host.showGame();
this.state.setNotice(this.playNotice(), 'quiet');
@@ -126,6 +130,9 @@ export class PlaySession {
stop(): void {
// The run's seconds end with it: the editor goes back to the picture of a freshly loaded scene.
gameClock.reset();
// And its sounds end with it, looping music included – nothing may outlive the world.
soundLibrary.setSuspended(false);
soundLibrary.stopAll();
const world = this.world;
this.world = null;
this.behaviors = null;
+18
View File
@@ -7,9 +7,11 @@ import { effectiveScale, gameClock } from '../core/time';
import { activeDrag, Button, isRendering, renderCount, requestRender, ui, Widget } from '../ui';
import { particleLibrary } from '../viewport/ParticleLibrary';
import { shaderLibrary } from '../viewport/ShaderLibrary';
import { soundLibrary } from '../viewport/SoundLibrary';
import { textureLibrary } from '../viewport/TextureLibrary';
import type { CentreTab, Editor } from './Editor';
import { currentFontPicker } from './font/FontPicker';
import { currentSoundPicker } from './sound/SoundPicker';
import { currentImagePicker } from './texture/ImagePicker';
import type { EditorState } from './state';
@@ -361,6 +363,22 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
/** The same for the font picker, whose rows are the preview. */
fontPicker: () => currentFontPicker()?.debugInfo() ?? null,
fontPickerRow: (file: string) => currentFontPicker()?.rowGlobal(file) ?? null,
/** And for the sound picker, whose rows carry a ▶ of their own. */
soundPicker: () => currentSoundPicker()?.debugInfo() ?? null,
soundPickerRow: (file: string) => currentSoundPicker()?.rowGlobal(file) ?? null,
soundPickerPlay: (file: string) => currentSoundPicker()?.playGlobal(file) ?? null,
/**
* Voices started, stopped and in flight. CI runs the browser with `--mute-audio`, so nothing
* is ever heard there: these counters are the only way a check can see that a graph played
* anything at all, the way `renderStats` is for a repaint that did not happen.
*/
sound: {
stats: () => soundLibrary.stats(),
duration: (file: string) => soundLibrary.duration(file),
/** Decodes ahead of a check, so a play can be asserted without racing the network. */
load: (file: string) => soundLibrary.load(file).then((buffer) => buffer?.duration ?? null),
stopAll: () => soundLibrary.stopAll(),
},
/** Which typeface a text ended up drawn in, and how wide it made it. */
textInfo: (id: string) => viewport.textInfo(id),
gameTextInfo: (id: string) => gameView.textInfo(id),
+30 -23
View File
@@ -1,13 +1,14 @@
import { type FontImportDeps, importFonts, isFontFileName } from './fontImport';
import { importSounds, isSoundFileName, type SoundImportDeps } from './soundImport';
import { importImages, type TextureImportDeps } from './textureImport';
export type FileImportDeps = TextureImportDeps & FontImportDeps;
export type FileImportDeps = TextureImportDeps & FontImportDeps & SoundImportDeps;
/**
* Wires drop-onto-`target` and paste, and returns the remover for both. Paste is bound to the
* window because the canvas cannot hold focus, and skipped while a DOM text field has it.
*
* Pictures and typefaces arrive the same way, so one pair of handlers sorts them apart.
* Pictures, typefaces and sounds arrive the same way, so one pair of handlers sorts them apart.
*/
export function installFileImport(target: HTMLElement, deps: FileImportDeps): () => void {
const onDragOver = (event: DragEvent) => {
@@ -17,24 +18,22 @@ export function installFileImport(target: HTMLElement, deps: FileImportDeps): ()
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
};
const onDrop = (event: DragEvent) => {
const images = imageFilesOf(event.dataTransfer);
const fonts = fontFilesOf(event.dataTransfer);
if (images.length === 0 && fonts.length === 0) {
const files = sortFiles(event.dataTransfer);
if (files.none) {
if (!event.dataTransfer?.files.length) return;
event.preventDefault();
deps.state.setNotice('Only image and font files can be dropped into the editor.');
deps.state.setNotice('Only image, font and sound files can be dropped into the editor.');
return;
}
event.preventDefault();
run(images, fonts, deps);
run(files, deps);
};
const onPaste = (event: ClipboardEvent) => {
if (isTextFieldFocused()) return;
const images = imageFilesOf(event.clipboardData);
const fonts = fontFilesOf(event.clipboardData);
if (images.length === 0 && fonts.length === 0) return;
const files = sortFiles(event.clipboardData);
if (files.none) return;
event.preventDefault();
run(images, fonts, deps);
run(files, deps);
};
target.addEventListener('dragover', onDragOver);
@@ -47,23 +46,31 @@ export function installFileImport(target: HTMLElement, deps: FileImportDeps): ()
};
}
/** Images first, then fonts, so a mixed drop ends on one notice instead of two racing ones. */
function run(images: readonly File[], fonts: readonly File[], deps: FileImportDeps): void {
interface SortedFiles {
images: File[];
fonts: File[];
sounds: File[];
none: boolean;
}
function sortFiles(data: DataTransfer | null): SortedFiles {
const all = [...(data?.files ?? [])];
const images = all.filter((file) => file.type.startsWith('image/'));
const fonts = all.filter((file) => isFontFileName(file.name));
const sounds = all.filter((file) => isSoundFileName(file.name));
return { images, fonts, sounds, none: images.length + fonts.length + sounds.length === 0 };
}
/** One kind after the other, so a mixed drop ends on one notice instead of three racing ones. */
function run(files: SortedFiles, deps: FileImportDeps): void {
const work = async () => {
if (images.length) await importImages(images, deps);
if (fonts.length) await importFonts(fonts, deps);
if (files.images.length) await importImages(files.images, deps);
if (files.fonts.length) await importFonts(files.fonts, deps);
if (files.sounds.length) await importSounds(files.sounds, deps);
};
void work().catch((error: unknown) => deps.state.setNotice(String(error), 'error'));
}
function imageFilesOf(data: DataTransfer | null): File[] {
return [...(data?.files ?? [])].filter((file) => file.type.startsWith('image/'));
}
function fontFilesOf(data: DataTransfer | null): File[] {
return [...(data?.files ?? [])].filter((file) => isFontFileName(file.name));
}
/**
* During a drag the files themselves are not readable yet – only kind and type are, and a font
* usually has no type at all. Anything that is not plainly text therefore counts as a candidate;
+6
View File
@@ -24,6 +24,7 @@ import { markedAssetSchema } from '../../core/properties/deviation';
import { isModified, resolveAssetProps } from '../../core/properties/resolve';
import { Dropdown, HStack, Label, Menu, Theme } from '../../ui';
import { FontPreview } from '../font/FontPreview';
import { SoundPreview } from '../sound/SoundPreview';
import { aspectActions, sizeCommand } from './aspectAction';
import { actionIf, createFieldRow, type FieldHandlers } from './fieldFactory';
import { addNameField, addNote, addTitle, type InspectorHost } from './host';
@@ -76,6 +77,11 @@ export function buildAssetSection(host: InspectorHost, assetId: string): void {
host.body.add(
asset.file ? new FontPreview(asset.file) : new Label('Not uploaded.', { style: 'warning' }),
);
// A sound has two rows above this; the preview is the only way to find out which sound it is.
if (asset.type === 'sound')
host.body.add(
asset.file ? new SoundPreview(asset.file) : new Label('Not uploaded.', { style: 'warning' }),
);
}
/** Pushes the current asset values into the rows. False when the asset is gone (rebuild). */
+6
View File
@@ -237,6 +237,11 @@ const REFERENCES: Record<ReferenceKind, Reference> = {
choices: (project) =>
assetRefChoices(project, ['statemachine']).map((a) => ({ id: a.id, label: a.name })),
},
sound: {
none: NO_ASSET_LABEL,
label: assetRefLabel,
choices: (project) => assetRefChoices(project, ['sound']).map((a) => ({ id: a.id, label: a.name })),
},
};
/** An id in the words of its reference kind; nothing for a kind that names nothing. */
@@ -362,6 +367,7 @@ export function createField(
case 'asset':
case 'texture':
case 'statemachine':
case 'sound':
return referenceField(ctx, def, REFERENCES[def.kind], value, handlers);
}
}
+16 -2
View File
@@ -16,11 +16,13 @@ import { findAsset } from '../../core/model/Project';
import { spriteFromTexture } from '../../core/properties/aspect';
import { assetDeviates } from '../../core/properties/deviation';
import type { FontStore } from '../../core/store/FontStore';
import type { SoundStore } from '../../core/store/SoundStore';
import type { TextureStore } from '../../core/store/TextureStore';
import { Button, ListView, Menu, type MenuItem, Panel, ScrollContainer, startDrag } from '../../ui';
import { confirmDeleteAsset } from '../actions';
import type { EditorState } from '../state';
import { openFontPicker } from '../font/FontPicker';
import { openSoundPicker } from '../sound/SoundPicker';
import { openImagePicker } from '../texture/ImagePicker';
/**
@@ -39,6 +41,8 @@ export class AssetPanel extends Panel {
private readonly textures: TextureStore | null = null,
/** The same for typefaces and the Font entry. */
private readonly fonts: FontStore | null = null,
/** And for sounds and the Sound entry. */
private readonly sounds: SoundStore | null = null,
) {
super({ title: 'Assets', padding: 0, gap: 0 });
this.minWidth = 160;
@@ -100,12 +104,14 @@ export class AssetPanel extends Panel {
}
/**
* A texture is a picture and a font a file, so their menu entries offer what the server already
* holds instead of creating an empty asset. Uploading a new one is a button inside that dialog.
* A texture is a picture, a font and a sound are files, so their menu entries offer what the
* server already holds instead of creating an empty asset. Uploading a new one is a button
* inside that dialog.
*/
private addAsset(type: AssetType): void {
if (type === 'texture') this.importTextures();
else if (type === 'font') this.importFonts();
else if (type === 'sound') this.importSounds();
else this.createAsset(type);
}
@@ -125,6 +131,14 @@ export class AssetPanel extends Panel {
openFontPicker({ state: this.state, fonts: this.fonts });
}
private importSounds(): void {
if (!this.sounds) {
this.state.setNotice('Sound upload is not available.');
return;
}
openSoundPicker({ state: this.state, sounds: this.sounds });
}
/** Double-click / Enter opens the asset's tab; a scene also becomes the active one. Placing is a drag. */
private activate(id: string): void {
const asset = findAsset(this.state.project, id);
+310
View File
@@ -0,0 +1,310 @@
import { Container, type FederatedPointerEvent, Graphics, Text } from 'pixi.js';
import type { StoredSound } from '../../core/store/SoundStore';
import {
Button,
drawControlBox,
formatBytes,
HStack,
Label,
Popup,
pressCount,
ScrollContainer,
type Size,
Spacer,
Theme,
textStyle,
truncate,
Widget,
} from '../../ui';
import { soundLibrary } from '../../viewport/SoundLibrary';
import { type SoundImportDeps, importStoredSound, pickAndImportSounds } from '../soundImport';
/** The dialog keeps this size whatever the list turns out to be, so it does not jump on opening. */
const LIST_WIDTH = 560;
const LIST_HEIGHT = 340;
const ROW_HEIGHT = 44;
const ROW_GAP = 4;
const ROW_PAD = 8;
/** The play glyph's column at the left of a row: a press in here plays instead of selecting. */
const PLAY_WIDTH = 34;
/** One offered sound, with the asset that already uses it (if any). */
interface PickerItem extends StoredSound {
assetName: string | null;
}
/**
* The rows. A sound has nothing to show, so each row shows what is known about the file and plays
* it on demand: the ▶ at its left is the preview, the rest of the row is the pick.
*/
class SoundList extends Widget {
private items: PickerItem[] = [];
private rows: { box: Container; background: Graphics; play: Text; caption: Text; detail: Text }[] = [];
private selectedFile: string | null = null;
private readonly empty = new Text({ text: '', style: textStyle('muted') });
onPick?: (file: string) => void;
onActivate?: (file: string) => void;
emptyText = '';
/** The last file a ▶ started, so a check can tell a preview from a pick (test hook). */
lastPlayed: string | null = null;
constructor() {
super();
this.addChild(this.empty);
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPress(e));
}
setItems(items: PickerItem[]): void {
this.items = items;
for (const row of this.rows) row.box.destroy({ children: true });
this.rows = items.map(() => {
// A Container holding the background, not a Graphics with children: Pixi v8 only lets
// containers have them (`addChild: Only Containers will be allowed to add children`).
const box = new Container();
const background = new Graphics();
const play = new Text({ text: '▶', style: textStyle('accent') });
const caption = new Text({ style: textStyle('body') });
const detail = new Text({ style: textStyle('small') });
box.addChild(background, play, caption, detail);
this.addChild(box);
return { box, background, play, caption, detail };
});
if (!items.some((item) => item.file === this.selectedFile)) this.selectedFile = null;
this.layout();
this.redraw();
}
get selected(): PickerItem | null {
return this.items.find((item) => item.file === this.selectedFile) ?? null;
}
get fileList(): string[] {
return this.items.map((item) => item.file);
}
get usedFiles(): string[] {
return this.items.filter((item) => item.assetName !== null).map((item) => item.file);
}
/** The captions as drawn, read off the rows themselves rather than recomputed (test hook). */
get captions(): string[] {
return this.rows.map((row) => row.caption.text);
}
select(file: string | null): void {
if (file === this.selectedFile) return;
this.selectedFile = file;
this.redraw();
if (file) this.onPick?.(file);
}
/** Global centre of a row, clear of the ▶ column; null when the file is not offered (test hook). */
rowGlobal(file: string): { x: number; y: number } | null {
const index = this.items.findIndex((item) => item.file === file);
const row = this.rows[index];
if (!row) return null;
const p = row.box.getGlobalPosition();
return { x: p.x + PLAY_WIDTH + (this.w - PLAY_WIDTH) / 2, y: p.y + ROW_HEIGHT / 2 };
}
/** Global centre of a row's ▶ – what a check presses to hear one without choosing it. */
playGlobal(file: string): { x: number; y: number } | null {
const index = this.items.findIndex((item) => item.file === file);
const row = this.rows[index];
if (!row) return null;
const p = row.box.getGlobalPosition();
return { x: p.x + PLAY_WIDTH / 2, y: p.y + ROW_HEIGHT / 2 };
}
override measure(): Size {
return { w: this.minWidth, h: Math.max(this.minHeight, heightFor(this.items.length)) };
}
override layout(): void {
this.rows.forEach((row, index) => row.box.position.set(0, index * (ROW_HEIGHT + ROW_GAP)));
}
override redraw(): void {
this.empty.text = this.items.length === 0 ? this.emptyText : '';
this.empty.position.set(0, 0);
this.rows.forEach((node, index) => {
const item = this.items[index];
if (!item) return;
const selected = item.file === this.selectedFile;
node.background.clear();
drawControlBox(node.background, this.w, ROW_HEIGHT, {
fill: selected ? Theme.colors.selection : Theme.colors.control,
border: selected ? Theme.colors.accent : Theme.colors.border,
});
node.play.position.set(ROW_PAD + 2, (ROW_HEIGHT - node.play.height) / 2);
// The asset name wins where there is one – it is what the project calls this sound. Otherwise
// the name it was uploaded under, which is all a bare hash leaves to go on.
node.caption.style = textStyle(item.assetName ? 'accent' : 'body');
node.caption.text = truncate(
item.assetName ?? item.name,
node.caption.style,
this.w - PLAY_WIDTH - ROW_PAD,
);
node.caption.position.set(PLAY_WIDTH, 6);
node.detail.text = truncate(
item.assetName && item.name ? `${item.name} – ${formatBytes(item.bytes)}` : formatBytes(item.bytes),
node.detail.style,
this.w - PLAY_WIDTH - ROW_PAD,
);
node.detail.position.set(PLAY_WIDTH, ROW_HEIGHT - 16);
});
}
private onPress(e: FederatedPointerEvent): void {
const local = this.toLocal(e.global);
const index = Math.floor(local.y / (ROW_HEIGHT + ROW_GAP));
const item = local.x >= 0 && local.x <= this.w ? this.items[index] : undefined;
if (!item) return;
if (local.x < PLAY_WIDTH) {
this.lastPlayed = item.file;
soundLibrary.play(item.file, 1, false);
return;
}
this.select(item.file);
if (pressCount(e, item.file) === 2) this.onActivate?.(item.file);
}
}
function heightFor(count: number): number {
return count === 0 ? 0 : count * ROW_HEIGHT + (count - 1) * ROW_GAP;
}
/**
* Offers every sound the server holds, the way the font picker offers typefaces: uploading the same
* file twice is wasteful, and nothing collects the files a project stopped referencing.
*/
export class SoundPicker extends Popup {
private readonly list = new SoundList();
private readonly scroll = new ScrollContainer();
private readonly status: Label;
private readonly detail: Label;
private readonly use: Button;
constructor(private readonly deps: SoundImportDeps) {
super({ modal: true, padding: Theme.padding * 2, gap: Theme.gap });
this.content.add(new Label('Choose a sound', { style: 'bodyBold' }));
this.status = new Label('Loading sounds…', { style: 'muted' });
this.content.add(this.status);
this.list.minWidth = LIST_WIDTH;
this.list.emptyText = 'No sounds on the server yet – upload one.';
this.list.onPick = () => this.refreshSelection();
this.list.onActivate = () => this.confirm();
this.scroll.minWidth = LIST_WIDTH;
this.scroll.minHeight = LIST_HEIGHT;
this.scroll.setContent(this.list);
this.content.add(this.scroll);
this.detail = new Label('Press ▶ to hear one.', { style: 'muted' });
this.content.add(this.detail);
const buttons = new HStack({ gap: Theme.gap, align: 'center' });
const upload = new Button('Upload…');
upload.onClick = this.closeThen(() => pickAndImportSounds(this.deps));
buttons.add(upload);
buttons.add(new Spacer(), 1);
const cancel = new Button('Cancel');
cancel.onClick = this.closeThen();
buttons.add(cancel);
this.use = new Button('Use sound', { variant: 'primary' });
this.use.onClick = () => this.confirm();
this.use.setEnabled(false);
buttons.add(this.use);
this.content.add(buttons);
void this.loadList();
}
/** What the dialog offers, which of it the project already uses, and what is picked (test hook). */
debugInfo(): {
files: string[];
used: string[];
captions: string[];
selected: string | null;
played: string | null;
} {
return {
files: this.list.fileList,
used: this.list.usedFiles,
captions: this.list.captions,
selected: this.list.selected?.file ?? null,
played: this.list.lastPlayed,
};
}
rowGlobal(file: string): { x: number; y: number } | null {
return this.list.rowGlobal(file);
}
playGlobal(file: string): { x: number; y: number } | null {
return this.list.playGlobal(file);
}
override close(): void {
super.close();
if (current === this) current = null;
// A preview left running would outlive the dialog that started it, with nothing left to stop it.
soundLibrary.stopAll();
}
private async loadList(): Promise<void> {
try {
const stored = await this.deps.sounds.list();
const named = new Map<string, string>();
for (const asset of this.deps.state.project.assets) {
if (asset.type === 'sound') named.set(asset.file, asset.name);
}
this.list.setItems(stored.map((item) => ({ ...item, assetName: named.get(item.file) ?? null })));
this.scroll.minHeight = Math.min(LIST_HEIGHT, Math.max(ROW_HEIGHT, heightFor(stored.length)));
this.status.setText(
stored.length === 1 ? '1 sound on the server' : `${stored.length} sounds on the server`,
);
} catch (error) {
this.status.setText(
`Could not list the sounds: ${error instanceof Error ? error.message : String(error)}`,
);
}
this.refreshSelection();
}
private refreshSelection(): void {
const item = this.list.selected;
this.use.setEnabled(item !== null);
this.detail.setText(item ? describe(item) : 'Press ▶ to hear one.');
this.refreshLayout();
}
private confirm(): void {
const item = this.list.selected;
if (!item) return;
this.close();
importStoredSound(item, this.deps);
}
}
function describe(item: PickerItem): string {
const size = item.name ? `${item.name} – ${formatBytes(item.bytes)}` : formatBytes(item.bytes);
return item.assetName ? `${size} – already in the project as "${item.assetName}"` : size;
}
let current: SoundPicker | null = null;
export function openSoundPicker(deps: SoundImportDeps): SoundPicker {
current?.close();
current = new SoundPicker(deps);
current.openCentered();
return current;
}
/** The open dialog, for the test hook. */
export function currentSoundPicker(): SoundPicker | null {
return current;
}
+44
View File
@@ -0,0 +1,44 @@
import { Button, HStack, Label, Theme } from '../../ui';
import { soundLibrary } from '../../viewport/SoundLibrary';
/**
* What a sound asset looks like: a button that plays it and the length the decoder reports. There
* is nothing else to show – its file name is a hash, and the waveform would be a picture of bytes
* nobody reads.
*
* Playing here goes through the same library a running game uses, so what the inspector plays is
* exactly what the graph will: same decode, same device, and `Stop` reaches both.
*/
export class SoundPreview extends HStack {
private readonly length: Label;
constructor(private readonly file: string) {
super({ gap: Theme.gap, align: 'center' });
const play = new Button('▶ Play');
play.onClick = () => soundLibrary.play(this.file, 1, false);
this.add(play);
const stop = new Button('■ Stop');
stop.onClick = () => soundLibrary.stop(this.file);
this.add(stop);
this.length = new Label('', { style: 'muted' });
this.add(this.length, 1);
this.showLength();
}
/**
* The length is only known once the bytes are decoded, and decoding needs an audio device. So it
* is asked for rather than waited on: a preview nobody has played yet says nothing about length,
* which is more honest than a number guessed from the file size.
*/
private showLength(): void {
const known = soundLibrary.duration(this.file);
if (known !== null) {
this.length.setText(`${known.toFixed(2)} s`);
return;
}
void soundLibrary.load(this.file).then((buffer) => {
if (this.destroyed) return;
this.length.setText(buffer ? `${buffer.duration.toFixed(2)} s` : 'Could not be decoded.');
});
}
}
+105
View File
@@ -0,0 +1,105 @@
import { CreateSoundCommand } from '../core/commands/assetCommands';
import { nextAssetName } from '../core/commands/naming';
import type { SoundAsset } from '../core/model/Asset';
import type { SoundStore, UploadedSound } from '../core/store/SoundStore';
import type { EditorState } from './state';
import { type ImportOutcome, assetNameForFile, importNotice } from './textureImport';
/** What the file dialog offers and a drop accepts; the server rejects everything else anyway. */
export const SOUND_ACCEPT = '.mp3,.ogg,.wav,.m4a';
const SOUND_EXTENSION = /\.(mp3|ogg|wav|m4a)$/i;
/**
* Sounds are told apart by their name, not their MIME type: browsers report a dropped `.m4a` as
* `audio/mp4`, `audio/x-m4a` or nothing at all, and an `.ogg` as `audio/ogg` or `video/ogg`.
*/
export function isSoundFileName(name: string): boolean {
return SOUND_EXTENSION.test(name);
}
export interface SoundImportDeps {
state: EditorState;
sounds: SoundStore;
}
/** Sequential like `importImages`, so each asset is named against the project as it then stands. */
export async function importSounds(files: readonly File[], deps: SoundImportDeps): Promise<ImportOutcome[]> {
if (files.length === 0) return [];
const outcomes: ImportOutcome[] = [];
for (const file of files) outcomes.push(await importSound(file, deps));
const created = outcomes.filter((outcome) => outcome.assetId !== null);
const last = created[created.length - 1];
if (last?.assetId) deps.state.select({ kind: 'asset', assetId: last.assetId });
deps.state.setNotice(importNotice(outcomes, 'sound'));
return outcomes;
}
async function importSound(file: File, deps: SoundImportDeps): Promise<ImportOutcome> {
try {
if (!isSoundFileName(file.name)) throw new Error('Not a sound file – use MP3, OGG, WAV or M4A.');
// The bytes go up untouched: nothing here could encode audio, and a re-encode would be a loss.
const uploaded = await deps.sounds.upload(file, file.type || 'application/octet-stream', file.name);
return adopt(uploaded, assetNameForFile(file.name, 'Sound'), file.name, deps);
} catch (error) {
return {
name: file.name,
assetId: null,
error: error instanceof Error ? error.message : String(error),
};
}
}
/** Makes an asset for a sound the server holds, or reuses the one already naming those bytes. */
function adopt(sound: UploadedSound, nameBase: string, label: string, deps: SoundImportDeps): ImportOutcome {
const existing = deps.state.project.assets.find(
(asset): asset is SoundAsset => asset.type === 'sound' && asset.file === sound.file,
);
if (existing) return { name: label, assetId: existing.id, error: null, reused: true };
const name = nextAssetName(deps.state.project, nameBase);
const command = new CreateSoundCommand(name, sound.file);
// A rejected command has already put its reason into the notice; that is the message here.
if (!deps.state.execute(command)) return { name: label, assetId: null, error: deps.state.notice };
return { name: label, assetId: command.asset.id, error: null, reused: false };
}
/**
* Adopts a sound the server already holds – no upload, the bytes are there. Chosen in the picker,
* so this is "+ → Sound" and the asset is selected the way a created one would be.
*/
export function importStoredSound(sound: UploadedSound, deps: SoundImportDeps): ImportOutcome {
const outcome = adopt(sound, assetNameForFile(sound.name, 'Sound'), sound.name || sound.file, deps);
if (outcome.assetId) deps.state.select({ kind: 'asset', assetId: outcome.assetId });
deps.state.setNotice(
outcome.error ??
(outcome.reused ? 'That sound is already in the project.' : 'Added the sound to the project.'),
);
return outcome;
}
/** Opens the browser's file dialog; resolves empty when the dialog was cancelled. */
export function pickSoundFiles(): Promise<File[]> {
return new Promise((resolve) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = SOUND_ACCEPT;
input.multiple = true;
input.style.display = 'none';
document.body.appendChild(input);
const finish = (files: File[]) => {
input.remove();
resolve(files);
};
input.addEventListener('change', () => finish([...(input.files ?? [])]));
// Without `cancel` a dismissed dialog leaves both the element and this promise behind.
input.addEventListener('cancel', () => finish([]));
input.click();
});
}
export function pickAndImportSounds(deps: SoundImportDeps): void {
void pickSoundFiles()
.then((files) => importSounds(files, deps))
.catch((error: unknown) => deps.state.setNotice(String(error), 'error'));
}