Add audio asset handling and import command
This commit is contained in:
214
src/assets/audio-assets.ts
Normal file
214
src/assets/audio-assets.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { createOpaqueId } from "../core/ids";
|
||||
|
||||
import { createProjectAssetStorageKey, type AudioAssetMetadata, type AudioAssetRecord } from "./project-assets";
|
||||
import type {
|
||||
ProjectAssetStorage,
|
||||
ProjectAssetStorageFileRecord,
|
||||
ProjectAssetStoragePackageRecord
|
||||
} from "./project-asset-storage";
|
||||
|
||||
export interface LoadedAudioAsset {
|
||||
assetId: string;
|
||||
storageKey: string;
|
||||
metadata: AudioAssetMetadata;
|
||||
buffer: AudioBuffer;
|
||||
}
|
||||
|
||||
export interface ImportedAudioAssetResult {
|
||||
asset: AudioAssetRecord;
|
||||
loadedAsset: LoadedAudioAsset;
|
||||
}
|
||||
|
||||
function getErrorDetail(error: unknown): string {
|
||||
if (error instanceof Error && error.message.trim().length > 0) {
|
||||
return error.message.trim();
|
||||
}
|
||||
|
||||
return "Unknown error.";
|
||||
}
|
||||
|
||||
function getFileExtension(sourceName: string): string {
|
||||
const match = /\.([^.]+)$/u.exec(sourceName.trim());
|
||||
return match === null ? "" : match[1].toLowerCase();
|
||||
}
|
||||
|
||||
function inferAudioMimeType(sourceName: string, fallbackMimeType: string): string {
|
||||
if (fallbackMimeType.trim().startsWith("audio/")) {
|
||||
return fallbackMimeType.trim();
|
||||
}
|
||||
|
||||
switch (getFileExtension(sourceName)) {
|
||||
case "aac":
|
||||
return "audio/aac";
|
||||
case "flac":
|
||||
return "audio/flac";
|
||||
case "m4a":
|
||||
case "mp4":
|
||||
return "audio/mp4";
|
||||
case "mp3":
|
||||
return "audio/mpeg";
|
||||
case "oga":
|
||||
case "ogg":
|
||||
return "audio/ogg";
|
||||
case "wav":
|
||||
case "wave":
|
||||
return "audio/wav";
|
||||
case "webm":
|
||||
return "audio/webm";
|
||||
default:
|
||||
throw new Error(`Unsupported audio asset format for ${sourceName}. Use a browser-supported audio file.`);
|
||||
}
|
||||
}
|
||||
|
||||
function getImportedFilePath(file: File): string {
|
||||
const relativePath = typeof file.webkitRelativePath === "string" ? file.webkitRelativePath.trim() : "";
|
||||
const sourcePath = relativePath.length > 0 ? relativePath : file.name.trim();
|
||||
return sourcePath.replace(/\\/gu, "/");
|
||||
}
|
||||
|
||||
function createAudioContext(): AudioContext {
|
||||
const AudioContextConstructor =
|
||||
globalThis.AudioContext ??
|
||||
(globalThis as typeof globalThis & {
|
||||
webkitAudioContext?: typeof AudioContext;
|
||||
}).webkitAudioContext;
|
||||
|
||||
if (AudioContextConstructor === undefined) {
|
||||
throw new Error("Audio context is unavailable in this browser environment.");
|
||||
}
|
||||
|
||||
return new AudioContextConstructor();
|
||||
}
|
||||
|
||||
async function decodeAudioBuffer(bytes: ArrayBuffer): Promise<AudioBuffer> {
|
||||
const audioContext = createAudioContext();
|
||||
|
||||
try {
|
||||
return await audioContext.decodeAudioData(bytes.slice(0));
|
||||
} catch (error) {
|
||||
throw new Error(getErrorDetail(error));
|
||||
} finally {
|
||||
await audioContext.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function extractAudioAssetMetadata(buffer: AudioBuffer): AudioAssetMetadata {
|
||||
if (!Number.isFinite(buffer.duration) || buffer.duration <= 0) {
|
||||
throw new Error("Imported audio assets must have measurable duration.");
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "audio",
|
||||
durationSeconds: buffer.duration,
|
||||
channelCount: buffer.numberOfChannels,
|
||||
sampleRateHz: buffer.sampleRate,
|
||||
warnings: []
|
||||
};
|
||||
}
|
||||
|
||||
function createLoadedAudioAsset(asset: AudioAssetRecord, buffer: AudioBuffer): LoadedAudioAsset {
|
||||
return {
|
||||
assetId: asset.id,
|
||||
storageKey: asset.storageKey,
|
||||
metadata: asset.metadata,
|
||||
buffer
|
||||
};
|
||||
}
|
||||
|
||||
function createAudioAssetRecord(sourceName: string, mimeType: string, byteLength: number, metadata: AudioAssetMetadata): AudioAssetRecord {
|
||||
const assetId = createOpaqueId("asset-audio");
|
||||
|
||||
return {
|
||||
id: assetId,
|
||||
kind: "audio",
|
||||
sourceName,
|
||||
mimeType,
|
||||
storageKey: createProjectAssetStorageKey(assetId),
|
||||
byteLength,
|
||||
metadata
|
||||
};
|
||||
}
|
||||
|
||||
async function loadAudioAssetFromFileRecord(asset: AudioAssetRecord, fileRecord: ProjectAssetStorageFileRecord): Promise<LoadedAudioAsset> {
|
||||
try {
|
||||
const buffer = await decodeAudioBuffer(fileRecord.bytes);
|
||||
return createLoadedAudioAsset(asset, buffer);
|
||||
} catch (error) {
|
||||
throw new Error(`Audio asset reload failed for ${asset.sourceName}: ${getErrorDetail(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function getStoredAudioAssetFile(asset: AudioAssetRecord, storedAsset: ProjectAssetStoragePackageRecord): ProjectAssetStorageFileRecord | null {
|
||||
const directFile = storedAsset.files[asset.sourceName];
|
||||
|
||||
if (directFile !== undefined) {
|
||||
return directFile;
|
||||
}
|
||||
|
||||
const storedFiles = Object.values(storedAsset.files);
|
||||
|
||||
if (storedFiles.length === 1) {
|
||||
return storedFiles[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function importAudioAssetFromFile(file: File, storage: ProjectAssetStorage): Promise<ImportedAudioAssetResult> {
|
||||
const sourceName = getImportedFilePath(file);
|
||||
const mimeType = inferAudioMimeType(sourceName, file.type);
|
||||
const bytes = await file.arrayBuffer();
|
||||
|
||||
let buffer: AudioBuffer;
|
||||
|
||||
try {
|
||||
buffer = await decodeAudioBuffer(bytes);
|
||||
} catch (error) {
|
||||
throw new Error(`Audio import failed for ${sourceName}: ${getErrorDetail(error)}`);
|
||||
}
|
||||
|
||||
const metadata = extractAudioAssetMetadata(buffer);
|
||||
const asset = createAudioAssetRecord(sourceName, mimeType, bytes.byteLength, metadata);
|
||||
const loadedAsset = createLoadedAudioAsset(asset, buffer);
|
||||
const packageRecord: ProjectAssetStoragePackageRecord = {
|
||||
files: {
|
||||
[sourceName]: {
|
||||
bytes,
|
||||
mimeType
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await storage.putAsset(asset.storageKey, packageRecord);
|
||||
return {
|
||||
asset,
|
||||
loadedAsset
|
||||
};
|
||||
} catch (error) {
|
||||
await storage.deleteAsset(asset.storageKey).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadAudioAssetFromStorage(storage: ProjectAssetStorage, asset: AudioAssetRecord): Promise<LoadedAudioAsset> {
|
||||
let storedAsset: ProjectAssetStoragePackageRecord | null;
|
||||
|
||||
try {
|
||||
storedAsset = await storage.getAsset(asset.storageKey);
|
||||
} catch (error) {
|
||||
throw new Error(`Audio asset reload failed for ${asset.sourceName}: ${getErrorDetail(error)}`);
|
||||
}
|
||||
|
||||
if (storedAsset === null) {
|
||||
throw new Error(`Missing stored binary data for imported audio asset ${asset.sourceName}.`);
|
||||
}
|
||||
|
||||
const storedAudioFile = getStoredAudioAssetFile(asset, storedAsset);
|
||||
|
||||
if (storedAudioFile === null) {
|
||||
throw new Error(`Missing stored audio file for imported audio asset ${asset.sourceName}.`);
|
||||
}
|
||||
|
||||
return loadAudioAssetFromFileRecord(asset, storedAudioFile);
|
||||
}
|
||||
46
src/commands/import-audio-asset-command.ts
Normal file
46
src/commands/import-audio-asset-command.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { createOpaqueId } from "../core/ids";
|
||||
import { cloneProjectAssetRecord, type AudioAssetRecord } from "../assets/project-assets";
|
||||
|
||||
import type { EditorCommand } from "./command";
|
||||
|
||||
interface ImportAudioAssetCommandOptions {
|
||||
asset: AudioAssetRecord;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function createImportAudioAssetCommand(options: ImportAudioAssetCommandOptions): EditorCommand {
|
||||
const nextAsset = cloneProjectAssetRecord(options.asset);
|
||||
|
||||
return {
|
||||
id: createOpaqueId("command"),
|
||||
label: options.label ?? `Import ${nextAsset.sourceName}`,
|
||||
execute(context) {
|
||||
const currentDocument = context.getDocument();
|
||||
|
||||
if (currentDocument.assets[nextAsset.id] !== undefined) {
|
||||
throw new Error(`Asset ${nextAsset.id} already exists.`);
|
||||
}
|
||||
|
||||
context.setDocument({
|
||||
...currentDocument,
|
||||
assets: {
|
||||
...currentDocument.assets,
|
||||
[nextAsset.id]: cloneProjectAssetRecord(nextAsset)
|
||||
}
|
||||
});
|
||||
},
|
||||
undo(context) {
|
||||
const currentDocument = context.getDocument();
|
||||
const nextAssets = {
|
||||
...currentDocument.assets
|
||||
};
|
||||
|
||||
delete nextAssets[nextAsset.id];
|
||||
|
||||
context.setDocument({
|
||||
...currentDocument,
|
||||
assets: nextAssets
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { compareEntityInstances, getEntityKindLabel, getEntityInstances, type EntityInstance } from "./entity-instances";
|
||||
import type { ProjectAssetRecord } from "../assets/project-assets";
|
||||
|
||||
function getSortedEntitiesByKind(entities: Record<string, EntityInstance>, kind: EntityInstance["kind"]): EntityInstance[] {
|
||||
return Object.values(entities)
|
||||
@@ -6,27 +7,61 @@ function getSortedEntitiesByKind(entities: Record<string, EntityInstance>, kind:
|
||||
.sort(compareEntityInstances);
|
||||
}
|
||||
|
||||
export function getEntityDisplayLabel(entity: EntityInstance, entities: Record<string, EntityInstance>): string {
|
||||
function getSoundEmitterLabelSuffix(entity: EntityInstance & { kind: "soundEmitter" }, assets?: Record<string, ProjectAssetRecord>): string {
|
||||
if (entity.audioAssetId === null) {
|
||||
return "No Audio Asset";
|
||||
}
|
||||
|
||||
const asset = assets?.[entity.audioAssetId];
|
||||
|
||||
if (asset === undefined) {
|
||||
return `Missing Audio Asset (${entity.audioAssetId})`;
|
||||
}
|
||||
|
||||
if (asset.kind !== "audio") {
|
||||
return `Invalid Audio Asset (${asset.sourceName})`;
|
||||
}
|
||||
|
||||
return asset.sourceName;
|
||||
}
|
||||
|
||||
export function getEntityDisplayLabel(
|
||||
entity: EntityInstance,
|
||||
entities: Record<string, EntityInstance>,
|
||||
assets?: Record<string, ProjectAssetRecord>
|
||||
): string {
|
||||
const typedEntities = getSortedEntitiesByKind(entities, entity.kind);
|
||||
const entityIndex = typedEntities.findIndex((candidate) => candidate.id === entity.id);
|
||||
const baseLabel = getEntityKindLabel(entity.kind);
|
||||
const numberedLabel = entityIndex <= 0 ? baseLabel : `${baseLabel} ${entityIndex + 1}`;
|
||||
|
||||
return entityIndex <= 0 ? baseLabel : `${baseLabel} ${entityIndex + 1}`;
|
||||
if (entity.kind !== "soundEmitter" || assets === undefined) {
|
||||
return numberedLabel;
|
||||
}
|
||||
|
||||
return `${numberedLabel} · ${getSoundEmitterLabelSuffix(entity, assets)}`;
|
||||
}
|
||||
|
||||
export function getEntityDisplayLabelById(entityId: string, entities: Record<string, EntityInstance>): string {
|
||||
export function getEntityDisplayLabelById(
|
||||
entityId: string,
|
||||
entities: Record<string, EntityInstance>,
|
||||
assets?: Record<string, ProjectAssetRecord>
|
||||
): string {
|
||||
const entity = entities[entityId];
|
||||
|
||||
if (entity === undefined) {
|
||||
return "Entity";
|
||||
}
|
||||
|
||||
return getEntityDisplayLabel(entity, entities);
|
||||
return getEntityDisplayLabel(entity, entities, assets);
|
||||
}
|
||||
|
||||
export function getSortedEntityDisplayLabels(entities: Record<string, EntityInstance>): Array<{ entity: EntityInstance; label: string }> {
|
||||
export function getSortedEntityDisplayLabels(
|
||||
entities: Record<string, EntityInstance>,
|
||||
assets?: Record<string, ProjectAssetRecord>
|
||||
): Array<{ entity: EntityInstance; label: string }> {
|
||||
return getEntityInstances(entities).map((entity) => ({
|
||||
entity,
|
||||
label: getEntityDisplayLabel(entity, entities)
|
||||
label: getEntityDisplayLabel(entity, entities, assets)
|
||||
}));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user