Stream long sounds from an Opus copy instead of decoding them (#349)
A sound was decoded whole as soon as the project opened, in the editor and in a released game: five minutes of stereo held some 115 MB for as long as the page was open. A sound with Stream on now plays from a copy as Opus in Ogg, fetched a piece at a time and decoded a few seconds ahead by WebCodecs, so a game holds a few megabytes for it. - The Stream switch is a row of the sound, off by default. The Sound tab suggests it from a minute on and says how far the copy has got. - StreamKeeper makes the copies in the browser, a moment after the last change, one at a time: the sound's own and one per clip of its cut, fades and Reverse baked in. A copy that is no longer wanted is taken off and deleted once the project is saved without it. - streamTable says which played names stream from which copy. A clip without a fitting copy, a Sample and a Reverb impulse stay decoded. A sound with Stream on is never decoded ahead. - The page fetches by Range and takes the whole file from a server that ignores it, as the CI's PHP server does. It keeps the bytes and the first two seconds decoded, the rest goes four seconds ahead. - A jump decodes 32 packets before the place first. Measured in Chrome, what follows is then the same to the bit as decoding from the start. With 4 packets it was 0.06 off. - The stream-player on the FX worklet reads the samples at the rate of the context along a Catmull-Rom curve. What comes late is silent and skipped once it is there, so a clip keeps its beat. - The opening stretch goes with the player as it is made. Sent by message it came a few milliseconds late, and 557 samples of the attack were lost. - Pitch on a streamed sound is a shifter after the player, as on a bus: the length stays and it is heard 42.7 ms late. - Bakes of patterns and songs (#340) always stream now. - The song timeline draws a streamed sound from its copy decoded at 8 kHz, keeping only the peaks. Tests: - Gate: CHECK EXIT 0, 280 files, 4312 tests (31 new: ogg +3, stream-queue 6, streams 11, streaming 6, sound-library +5). - e2e 34: ALL PASSED, 106 checks (7 new). Five minutes of mono, 4.8 MB as WAV, 3.6 MB as its copy: streamed the renderer grows by 5.8 MB, decoded by 69.6 MB. Offline at 48 kHz, start, loop seam and a reversed clip are the copy decoded whole to the sample (0). - Counter-checks red as they should be: no pre-roll, no catch-up of late samples at the end, no opening stretch, a streamed sound decoded ahead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
84cd36f902
commit
2764919bb8
30 files changed
+2443
-146
No files matched your search
@@ -317,6 +317,21 @@ export interface SoundAsset extends AssetBase {
|
||||
file: string;
|
||||
/** Absent for a sound without clips. */
|
||||
clips?: SoundClip[];
|
||||
/** The copy it is streamed from while its `stream` is on (#349). */
|
||||
stream?: SoundStream;
|
||||
}
|
||||
|
||||
/**
|
||||
* A sound or a clip of it as Opus in Ogg (#349), what it is streamed from rather than decoded whole.
|
||||
* Made in the browser from what it plays; `from` says what that was – the file, or the name of the
|
||||
* cut a clip is – and once it is something else the copy is stale and made again.
|
||||
*/
|
||||
export interface SoundStream {
|
||||
file: string;
|
||||
from: string;
|
||||
/** Samples per channel at 48 kHz, and the size of the file, to show. */
|
||||
frames: number;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -329,6 +344,8 @@ export interface SoundClip {
|
||||
id: string;
|
||||
name: string;
|
||||
props: PropertyBag;
|
||||
/** Its cut baked to a copy of its own, while its sound streams (#349). */
|
||||
stream?: SoundStream;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { validBake } from '../music/bake';
|
||||
import { streamsOf } from '../properties/sounds';
|
||||
import { isFontFile } from './font';
|
||||
import { celFilesOf } from './pixelart';
|
||||
import type { Project } from './Project';
|
||||
@@ -17,7 +18,8 @@ export interface ProjectFiles {
|
||||
* Every file an asset names, used or not: which frame an animation or a tile reaches next is only
|
||||
* known while it plays, and a file fetched then shows as a gap. A name that is no stored file –
|
||||
* an upload that failed – is left out, as the libraries would never get it anyway. So is a bake that
|
||||
* no longer holds (#340): it would never be played, and decoded it weighs as much as the music.
|
||||
* no longer holds (#340): it would never be played. So is a sound that streams (#349): the sound
|
||||
* library fetches the start of its copy instead, and decoded ahead it would weigh what streaming saves.
|
||||
*/
|
||||
export function projectFiles(project: Project): ProjectFiles {
|
||||
const files: ProjectFiles = { textures: [], fonts: [], cels: [], sounds: [] };
|
||||
@@ -25,7 +27,8 @@ export function projectFiles(project: Project): ProjectFiles {
|
||||
if (asset.type === 'texture' && isTextureFile(asset.file)) files.textures.push(asset.file);
|
||||
else if (asset.type === 'font' && isFontFile(asset.file)) files.fonts.push(asset.file);
|
||||
else if (asset.type === 'pixelart') files.cels.push(...celFilesOf(asset));
|
||||
else if (asset.type === 'sound' && isSoundFile(asset.file)) files.sounds.push(asset.file);
|
||||
else if (asset.type === 'sound' && isSoundFile(asset.file) && !streamsOf(asset.props))
|
||||
files.sounds.push(asset.file);
|
||||
else if ((asset.type === 'pattern' || asset.type === 'song') && validBake(project, asset))
|
||||
files.sounds.push(asset.bake!.file);
|
||||
}
|
||||
|
||||
+98
-25
@@ -140,38 +140,111 @@ export interface ReadOpus {
|
||||
pages: number;
|
||||
}
|
||||
|
||||
/** Reads a file `oggOpus` wrote, checking every page; throws at the first that is not right. */
|
||||
/** Reads a whole file `oggOpus` wrote, checking every page; throws at the first that is not right. */
|
||||
export function readOggOpus(bytes: Uint8Array): ReadOpus {
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const all: Uint8Array[] = [];
|
||||
let open: Uint8Array[] = [];
|
||||
let granule = 0;
|
||||
let pages = 0;
|
||||
for (let at = 0; at < bytes.length; pages++) {
|
||||
if (text(bytes.subarray(at, at + 4)) !== 'OggS') throw new Error(`no page at byte ${at}`);
|
||||
const count = bytes[at + 26]!;
|
||||
const lacing = bytes.subarray(at + 27, at + 27 + count);
|
||||
const size = 27 + count + lacing.reduce((sum, value) => sum + value, 0);
|
||||
const page = bytes.slice(at, at + size);
|
||||
const crc = view.getUint32(at + 22, true);
|
||||
page.fill(0, 22, 26);
|
||||
if (oggCrc(page) !== crc) throw new Error(`page ${pages} has a wrong checksum`);
|
||||
granule = view.getUint32(at + 6, true) + view.getUint32(at + 10, true) * 2 ** 32;
|
||||
let from = at + 27 + count;
|
||||
const reader = new OggOpusReader();
|
||||
reader.push(bytes);
|
||||
if (reader.channels === null || reader.preSkip === null) throw new Error('no OpusHead');
|
||||
return {
|
||||
channels: reader.channels,
|
||||
preSkip: reader.preSkip,
|
||||
granule: reader.granule,
|
||||
packets: reader.packets.map((packet) => packet.data),
|
||||
pages: reader.pages,
|
||||
};
|
||||
}
|
||||
|
||||
/** A packet of a stream (#349) and where it lies, in samples counted from the first, pre-skip and all. */
|
||||
export interface PlacedPacket {
|
||||
data: Uint8Array;
|
||||
at: number;
|
||||
samples: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads Ogg Opus as its bytes come in (#349), a page once all of it is there, checking each; throws
|
||||
* at the first that is not right. The packets after the tags are placed by the samples each stands for.
|
||||
*/
|
||||
export class OggOpusReader {
|
||||
channels: number | null = null;
|
||||
preSkip: number | null = null;
|
||||
/** The granule position of the last page read, and whether it was the last of the stream. */
|
||||
granule = 0;
|
||||
ended = false;
|
||||
pages = 0;
|
||||
readonly packets: PlacedPacket[] = [];
|
||||
private held: Uint8Array = new Uint8Array(0);
|
||||
private open: Uint8Array[] = [];
|
||||
private headers = 0;
|
||||
private next = 0;
|
||||
|
||||
push(bytes: Uint8Array): void {
|
||||
let data = this.held.length > 0 ? concat([this.held, bytes]) : bytes;
|
||||
let at = 0;
|
||||
while (data.length - at >= 27) {
|
||||
if (text(data.subarray(at, at + 4)) !== 'OggS') throw new Error(`no page at byte ${at}`);
|
||||
const count = data[at + 26]!;
|
||||
if (data.length - at < 27 + count) break;
|
||||
const lacing = data.subarray(at + 27, at + 27 + count);
|
||||
const size = 27 + count + lacing.reduce((sum, value) => sum + value, 0);
|
||||
if (data.length - at < size) break;
|
||||
this.page(data.subarray(at, at + size), lacing);
|
||||
at += size;
|
||||
}
|
||||
data = data.subarray(at);
|
||||
this.held = data.length > 0 ? data.slice() : new Uint8Array(0);
|
||||
}
|
||||
|
||||
private page(page: Uint8Array, lacing: Uint8Array): void {
|
||||
const view = new DataView(page.buffer, page.byteOffset, page.byteLength);
|
||||
const crc = view.getUint32(22, true);
|
||||
const blank = page.slice();
|
||||
blank.fill(0, 22, 26);
|
||||
if (oggCrc(blank) !== crc) throw new Error(`page ${this.pages} has a wrong checksum`);
|
||||
this.granule = view.getUint32(6, true) + view.getUint32(10, true) * 2 ** 32;
|
||||
this.ended = (page[5]! & 0x04) !== 0;
|
||||
this.pages += 1;
|
||||
let from = 27 + lacing.length;
|
||||
for (const value of lacing) {
|
||||
open.push(bytes.subarray(from, from + value));
|
||||
this.open.push(page.subarray(from, from + value));
|
||||
from += value;
|
||||
if (value < 255) {
|
||||
all.push(concat(open));
|
||||
open = [];
|
||||
this.packet(concat(this.open));
|
||||
this.open = [];
|
||||
}
|
||||
}
|
||||
at += size;
|
||||
}
|
||||
const head = all[0];
|
||||
const preSkip = head ? preSkipOf(head) : null;
|
||||
if (!head || preSkip === null) throw new Error('no OpusHead');
|
||||
return { channels: head[9]!, preSkip, granule, packets: all.slice(2), pages };
|
||||
|
||||
private packet(data: Uint8Array): void {
|
||||
if (this.headers === 0) {
|
||||
this.preSkip = preSkipOf(data);
|
||||
if (this.preSkip === null) throw new Error('no OpusHead');
|
||||
this.channels = data[9]!;
|
||||
}
|
||||
if (this.headers < 2) {
|
||||
this.headers += 1;
|
||||
return;
|
||||
}
|
||||
const samples = opusPacketSamples(data);
|
||||
this.packets.push({ data, at: this.next, samples });
|
||||
this.next += samples;
|
||||
}
|
||||
}
|
||||
|
||||
/** The samples per channel at 48 kHz an Opus packet stands for, from its TOC byte (RFC 6716 3.1). */
|
||||
export function opusPacketSamples(packet: Uint8Array): number {
|
||||
if (packet.length === 0) return 0;
|
||||
const toc = packet[0]!;
|
||||
const config = toc >> 3;
|
||||
const size =
|
||||
config < 12
|
||||
? [480, 960, 1920, 2880][config & 3]!
|
||||
: config < 16
|
||||
? [480, 960][config & 1]!
|
||||
: [120, 240, 480, 960][config & 3]!;
|
||||
const code = toc & 3;
|
||||
const frames = code === 0 ? 1 : code === 3 ? (packet[1] ?? 0) & 63 : 2;
|
||||
return size * frames;
|
||||
}
|
||||
|
||||
function ascii(value: string): Uint8Array {
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* What a streamed sound (#349) is played from on the audio thread: the samples the page decoded, in
|
||||
* the order they are heard, at `STREAM_RATE`. It reads them out at the rate of the context, `step`
|
||||
* of its samples per sample heard, between two of them along a cubic curve. Time runs on whatever
|
||||
* has arrived: a stretch that comes late is silent where it would have been heard and skipped once it
|
||||
* is there, so a sound in a song stays on its beat.
|
||||
*/
|
||||
export const STREAM_RATE = 48000;
|
||||
|
||||
export class StreamQueue {
|
||||
private readonly chunks: Float32Array[][] = [];
|
||||
/** Where in the first chunk the next sample is, and how far between it and the one after. */
|
||||
private index = 0;
|
||||
private fraction = 0;
|
||||
/** The sample of each channel read last, for the curve. */
|
||||
private readonly last: number[];
|
||||
private finished = false;
|
||||
/** Samples of the stream read so far, heard or skipped, and those that were due but not there. */
|
||||
used = 0;
|
||||
owed = 0;
|
||||
/** Samples that came too late to be heard (test hook). */
|
||||
skipped = 0;
|
||||
|
||||
constructor(
|
||||
readonly channels: number,
|
||||
private readonly step: number,
|
||||
) {
|
||||
this.last = Array.from({ length: channels }, () => 0);
|
||||
}
|
||||
|
||||
push(chunk: Float32Array[]): void {
|
||||
if (chunk.length === 0 || (chunk[0]?.length ?? 0) === 0) return;
|
||||
this.chunks.push(chunk);
|
||||
this.payOwed();
|
||||
}
|
||||
|
||||
/** Nothing more comes: it ends once what is held has been read. */
|
||||
end(): void {
|
||||
this.finished = true;
|
||||
this.payOwed();
|
||||
}
|
||||
|
||||
/** Samples held and not yet read. */
|
||||
get held(): number {
|
||||
let count = -this.index;
|
||||
for (const chunk of this.chunks) count += chunk[0]!.length;
|
||||
return Math.max(0, count);
|
||||
}
|
||||
|
||||
/** Whether all of it has been read. */
|
||||
get done(): boolean {
|
||||
return this.finished && this.held === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills `out` from what is held; what is not there yet is silence. Says whether it goes on: false
|
||||
* once it has ended and been read to the end.
|
||||
*/
|
||||
render(out: Float32Array[]): boolean {
|
||||
const length = out[0]?.length ?? 0;
|
||||
for (let i = 0; i < length; i++) {
|
||||
if (!this.ahead(2)) {
|
||||
if (this.finished && this.held === 0) {
|
||||
for (const channel of out) channel.fill(0, i);
|
||||
return false;
|
||||
}
|
||||
if (!this.finished) {
|
||||
for (const channel of out) channel[i] = 0;
|
||||
this.owe();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const t = this.fraction;
|
||||
for (let c = 0; c < out.length; c++) {
|
||||
const side = Math.min(c, this.channels - 1);
|
||||
out[c]![i] = cubic(this.last[side]!, this.at(side, 0), this.at(side, 1), this.at(side, 2), t);
|
||||
}
|
||||
this.advance(this.step);
|
||||
}
|
||||
return !this.done;
|
||||
}
|
||||
|
||||
/** Whether `count` samples past the next one are held, or the stream ends before them. */
|
||||
private ahead(count: number): boolean {
|
||||
if (this.finished) return this.held > 0;
|
||||
return this.held > count;
|
||||
}
|
||||
|
||||
/** The sample `k` places after the next one, 0 past the end. */
|
||||
private at(side: number, k: number): number {
|
||||
let offset = this.index + k;
|
||||
for (const chunk of this.chunks) {
|
||||
const data = chunk[Math.min(side, chunk.length - 1)]!;
|
||||
if (offset < data.length) return data[offset]!;
|
||||
offset -= data.length;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private advance(by: number): void {
|
||||
this.fraction += by;
|
||||
while (this.fraction >= 1) {
|
||||
this.fraction -= 1;
|
||||
this.take();
|
||||
}
|
||||
}
|
||||
|
||||
/** Moves past the next sample, keeping it for the curve. */
|
||||
private take(): void {
|
||||
for (let side = 0; side < this.channels; side++) this.last[side] = this.at(side, 0);
|
||||
this.index += 1;
|
||||
this.used += 1;
|
||||
while (this.chunks.length > 0 && this.index >= this.chunks[0]![0]!.length) {
|
||||
this.index -= this.chunks[0]![0]!.length;
|
||||
this.chunks.shift();
|
||||
}
|
||||
}
|
||||
|
||||
/** A sample was due and not there: skipped once it is. */
|
||||
private owe(): void {
|
||||
this.fraction += this.step;
|
||||
while (this.fraction >= 1) {
|
||||
this.fraction -= 1;
|
||||
this.owed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
private payOwed(): void {
|
||||
while (this.owed > 0 && this.held > 0) {
|
||||
this.take();
|
||||
this.owed -= 1;
|
||||
this.skipped += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Catmull-Rom between `p1` and `p2`, `t` of the way; at 0 it is `p1` to the bit. */
|
||||
function cubic(p0: number, p1: number, p2: number, p3: number, t: number): number {
|
||||
if (t === 0) return p1;
|
||||
const a = -0.5 * p0 + 1.5 * p1 - 1.5 * p2 + 0.5 * p3;
|
||||
const b = p0 - 2.5 * p1 + 2 * p2 - 0.5 * p3;
|
||||
const c = -0.5 * p0 + 0.5 * p2;
|
||||
return ((a * t + b) * t + c) * t + p1;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Sounds streamed rather than decoded whole (#349). A sound with `stream` on is played from a copy as
|
||||
* Opus in Ogg, made in the browser, and each of its clips from a copy of its own cut, so no clip needs
|
||||
* the whole file either. A bake (#340) is Opus already and always streams. What a game plays is still
|
||||
* named as before – the file, or the cut a clip is – and the table here says which of those names to
|
||||
* stream from which copy.
|
||||
*/
|
||||
import type { SoundAsset, SoundClip, SoundStream } from '../model/Asset';
|
||||
import type { Project } from '../model/Project';
|
||||
import { clipsOf, cutName, isSoundFile } from '../model/sound';
|
||||
import { cutOf, streamsOf } from '../properties/sounds';
|
||||
import { STREAM_RATE } from './streamQueue';
|
||||
|
||||
/** Seconds from which the Sound tab suggests streaming. */
|
||||
export const STREAM_SUGGESTED = 60;
|
||||
|
||||
/** What a name is streamed from: the copy, and how many samples per channel it holds at `STREAM_RATE`. */
|
||||
export interface StreamedFile {
|
||||
file: string;
|
||||
frames: number;
|
||||
}
|
||||
|
||||
/** What a sound or a clip played streamed is read as (#349), in samples of the copy at `STREAM_RATE`. */
|
||||
export interface StreamPlan {
|
||||
/** Where it starts. */
|
||||
from: number;
|
||||
/** Where its first run ends: the end of the copy, or of the head of a bake. */
|
||||
until: number;
|
||||
/** What it runs round after that; null to end there. */
|
||||
loop: { start: number; end: number } | null;
|
||||
}
|
||||
|
||||
/** A sound or a clip played from `offset` seconds in, round and round where it loops. */
|
||||
export function playedPlan(frames: number, offset: number, loop: boolean): StreamPlan {
|
||||
const at = Math.max(0, Math.round(offset * STREAM_RATE));
|
||||
const from = loop && frames > 0 ? at % frames : Math.min(at, frames);
|
||||
return { from, until: frames, loop: loop && frames > 0 ? { start: 0, end: frames } : null };
|
||||
}
|
||||
|
||||
/** Walks a plan in stretches of the copy, each read straight through; a loop starts a new one. */
|
||||
export class StreamCursor {
|
||||
private at: number;
|
||||
private end: number;
|
||||
|
||||
constructor(private readonly plan: StreamPlan) {
|
||||
this.at = plan.from;
|
||||
this.end = plan.until;
|
||||
}
|
||||
|
||||
/** Where the next stretch starts; null once all of it has been read. */
|
||||
peek(): number | null {
|
||||
if (this.at < this.end) return this.at;
|
||||
const loop = this.plan.loop;
|
||||
return loop && loop.end > loop.start ? loop.start : null;
|
||||
}
|
||||
|
||||
/** The next stretch, at most `most` samples; null once all of it has been read. */
|
||||
next(most: number): { from: number; to: number } | null {
|
||||
if (this.at >= this.end) {
|
||||
const loop = this.plan.loop;
|
||||
if (!loop || loop.end <= loop.start) return null;
|
||||
this.at = loop.start;
|
||||
this.end = loop.end;
|
||||
}
|
||||
const from = this.at;
|
||||
this.at = Math.min(this.end, from + Math.max(1, most));
|
||||
return { from, to: this.at };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every name a game streams, with its copy: each sound with `stream` on whose copy is of its file,
|
||||
* each clip of one whose copy is of its cut – a clip without one plays decoded until it is there – and
|
||||
* every bake, which streams from itself.
|
||||
*/
|
||||
export function streamTable(project: Project): Map<string, StreamedFile> {
|
||||
const table = new Map<string, StreamedFile>();
|
||||
for (const asset of project.assets) {
|
||||
if (asset.type === 'sound') {
|
||||
if (!streamsOf(asset.props) || !asset.file) continue;
|
||||
const own = heldCopy(asset.stream, asset.file);
|
||||
if (own) table.set(asset.file, own);
|
||||
for (const clip of clipsOf(asset)) {
|
||||
const played = cutName(asset.file, cutOf(clip.props));
|
||||
const copy = played === asset.file ? own : heldCopy(clip.stream, played);
|
||||
if (copy) table.set(played, copy);
|
||||
}
|
||||
} else if ((asset.type === 'pattern' || asset.type === 'song') && asset.bake) {
|
||||
const bake = asset.bake;
|
||||
table.set(bake.file, { file: bake.file, frames: bake.head + bake.round });
|
||||
}
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
function heldCopy(stream: SoundStream | undefined, played: string): StreamedFile | null {
|
||||
return stream && stream.from === played ? { file: stream.file, frames: stream.frames } : null;
|
||||
}
|
||||
|
||||
/** A copy to make: of a sound, or of one of its clips, from what it plays now. */
|
||||
export interface StreamJob {
|
||||
assetId: string;
|
||||
clipId: string | null;
|
||||
from: string;
|
||||
}
|
||||
|
||||
/** A copy that is to go: its sound no longer streams, or a clip of it has become the whole file. */
|
||||
export interface StreamDrop {
|
||||
assetId: string;
|
||||
clipId: string | null;
|
||||
}
|
||||
|
||||
/** What is missing and what is left over, sounds in their order, each sound before its clips. */
|
||||
export function streamWork(project: Project): { jobs: StreamJob[]; drops: StreamDrop[] } {
|
||||
const jobs: StreamJob[] = [];
|
||||
const drops: StreamDrop[] = [];
|
||||
for (const asset of project.assets) {
|
||||
if (asset.type !== 'sound') continue;
|
||||
const on = streamsOf(asset.props) && asset.file !== '';
|
||||
const keep = (owner: SoundAsset | SoundClip, clipId: string | null, from: string | null) => {
|
||||
if (from === null) {
|
||||
if (owner.stream) drops.push({ assetId: asset.id, clipId });
|
||||
} else if (owner.stream?.from !== from) jobs.push({ assetId: asset.id, clipId, from });
|
||||
};
|
||||
keep(asset, null, on ? asset.file : null);
|
||||
for (const clip of clipsOf(asset)) {
|
||||
const played = cutName(asset.file, cutOf(clip.props));
|
||||
keep(clip, clip.id, on && played !== asset.file ? played : null);
|
||||
}
|
||||
}
|
||||
return { jobs, drops };
|
||||
}
|
||||
|
||||
/** Held to what a copy may be, else none. */
|
||||
export function heldStream(raw: unknown): SoundStream | null {
|
||||
if (typeof raw !== 'object' || raw === null) return null;
|
||||
const s = raw as Record<string, unknown>;
|
||||
const whole = (value: unknown) => typeof value === 'number' && Number.isInteger(value) && value >= 0;
|
||||
if (typeof s.file !== 'string' || !isSoundFile(s.file) || typeof s.from !== 'string' || s.from === '')
|
||||
return null;
|
||||
if (!whole(s.frames) || !whole(s.bytes)) return null;
|
||||
return { file: s.file, from: s.from, frames: s.frames as number, bytes: s.bytes as number };
|
||||
}
|
||||
|
||||
/** What a copy is uploaded as: like a bake's, behind a mark the sound picker leaves out by. */
|
||||
const STREAM_MARK = '[stream] ';
|
||||
|
||||
export function streamUploadName(name: string): string {
|
||||
return `${STREAM_MARK}${name.replace(/[/\\]/g, '-')}.ogg`;
|
||||
}
|
||||
|
||||
export function isStreamUpload(name: string): boolean {
|
||||
return name.startsWith(STREAM_MARK);
|
||||
}
|
||||
@@ -103,7 +103,11 @@ import {
|
||||
} from './particles';
|
||||
import { SHADER_KEY, shaderOf, shaderParamDefs } from './shaders';
|
||||
import { SHADER_RESOLUTION_KEY, SHADER_RESOLUTION_OPTIONS } from './cameraShader';
|
||||
import { LOOP_KEY as SOUND_LOOP_KEY, VOLUME_KEY as SOUND_VOLUME_KEY } from './sounds';
|
||||
import {
|
||||
LOOP_KEY as SOUND_LOOP_KEY,
|
||||
STREAM_KEY as SOUND_STREAM_KEY,
|
||||
VOLUME_KEY as SOUND_VOLUME_KEY,
|
||||
} from './sounds';
|
||||
import {
|
||||
DEFAULT_BARS,
|
||||
DEFAULT_GRID,
|
||||
@@ -692,6 +696,8 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
sound: [
|
||||
{ key: SOUND_VOLUME_KEY, label: 'Volume', kind: 'number', default: 1, min: 0, max: 1, step: 0.05 },
|
||||
{ key: SOUND_LOOP_KEY, label: 'Loop', kind: 'boolean', default: false },
|
||||
// A long one is streamed from a copy as Opus rather than decoded whole (#349).
|
||||
{ key: SOUND_STREAM_KEY, label: 'Stream', kind: 'boolean', default: false },
|
||||
FX_ROW,
|
||||
],
|
||||
// The effects are the asset's graph (see SoundFxAsset), not a property.
|
||||
|
||||
@@ -7,6 +7,8 @@ import type { PropertyDef } from './PropertySchema';
|
||||
export const VOLUME_KEY = 'volume';
|
||||
/** Whether this sound is music: it repeats until something stops it. */
|
||||
export const LOOP_KEY = 'loop';
|
||||
/** Whether a game streams it from a copy as Opus rather than decoding all of it first (#349). */
|
||||
export const STREAM_KEY = 'stream';
|
||||
/** Where in its file a clip starts and ends, in seconds; an end of 0 is the end of the file (#343). */
|
||||
export const START_KEY = 'start';
|
||||
export const END_KEY = 'end';
|
||||
@@ -47,6 +49,10 @@ export function loopsOf(bag: PropertyBag): boolean {
|
||||
return booleanOf(bag[LOOP_KEY]);
|
||||
}
|
||||
|
||||
export function streamsOf(bag: PropertyBag): boolean {
|
||||
return booleanOf(bag[STREAM_KEY]);
|
||||
}
|
||||
|
||||
/** The stretch of its file a clip is, its fades in seconds. */
|
||||
export function cutOf(bag: PropertyBag): SoundCut {
|
||||
const seconds = (key: string, fallback: number) => Math.max(0, coerceAs(bag[key], 'number', fallback));
|
||||
|
||||
@@ -22,6 +22,7 @@ import { isShaderNodeDef } from '../shader/nodes';
|
||||
import { isSoundFxNodeDef } from '../soundfx/nodes';
|
||||
import { INSTRUMENT_SAMPLE, isInstrumentNodeDef, withSamplePlay } from '../instrument/nodes';
|
||||
import { heldBake } from '../music/bake';
|
||||
import { heldStream } from '../music/streams';
|
||||
import { heldPatternNotes } from '../music/pattern';
|
||||
import { isEmbeddable } from '../music/embedded';
|
||||
import { heldSongItems, heldSongTracks } from '../music/song';
|
||||
@@ -263,7 +264,16 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
||||
} else if (type === 'sound') {
|
||||
const file = typeof a.file === 'string' && isSoundFile(a.file) ? a.file : '';
|
||||
const clips = normalizeClips(a.clips, takenIds);
|
||||
assets.push({ id, type, name, props, file, ...(clips.length > 0 && { clips }) });
|
||||
const stream = heldStream(a.stream);
|
||||
assets.push({
|
||||
id,
|
||||
type,
|
||||
name,
|
||||
props,
|
||||
file,
|
||||
...(clips.length > 0 && { clips }),
|
||||
...(stream && { stream }),
|
||||
});
|
||||
} else if (type === 'tile') {
|
||||
// A picture naming nothing is kept, like an animation's dangling root: it reads as missing
|
||||
// in the tab and comes back when the texture it named is restored by an undo.
|
||||
@@ -763,10 +773,12 @@ function normalizeClips(raw: unknown, takenIds: Set<string>): SoundClip[] {
|
||||
const id = typeof c.id === 'string' ? c.id : '';
|
||||
if (!id || takenIds.has(id)) continue;
|
||||
takenIds.add(id);
|
||||
const stream = heldStream(c.stream);
|
||||
clips.push({
|
||||
id,
|
||||
name: typeof c.name === 'string' ? c.name : '',
|
||||
props: normalizeBag(c.props, CLIP_ROWS, true),
|
||||
...(stream && { stream }),
|
||||
});
|
||||
}
|
||||
return clips;
|
||||
|
||||
Reference in new issue
Block a user