Update spacetime-client bindings and frontend

Large update across server and web clients: regenerated/added many spacetime-client module bindings and input types (including new delete/work_delete input types and numerous procedure/reducer files), updates to server-rs API modules (bark_battle, jump_hop, wooden_fish, auth, module-runtime and shared contracts), and fixes in module-runtime behavior and domain logic. Frontend changes include new/updated components and tests (creative audio helpers, bark-battle/jump-hop/wooden-fish clients and views, unified generation pages, RPG entry views, and runtime shells), plus CSS and service updates. Documentation and operational notes updated (.hermes pitfalls and multiple PRD/docs) to cover daily-task refresh, banner asset fallback, recommend-key bug, and other platform behaviors. Tests and verification steps added/updated alongside these changes.
This commit is contained in:
2026-06-04 22:44:19 +08:00
parent 2678954627
commit 27b30f974b
326 changed files with 4374 additions and 2539 deletions

View File

@@ -1,15 +1,11 @@
import { Mic, Pause, Upload } from 'lucide-react';
import { useRef, useState } from 'react';
export type CreativeAudioAsset = {
assetId: string;
audioSrc: string;
audioObjectKey: string;
assetObjectId: string;
source: string;
prompt?: string | null;
durationMs?: number | null;
};
import {
type CreativeAudioAsset,
readCreativeAudioFileAsAsset,
} from './creativeAudioFileAsset';
import { trimLeadingSilenceFromRecordedAudioFile } from './creativeAudioSilenceTrim';
type CreativeAudioInputPanelProps<TAsset extends CreativeAudioAsset> = {
disabled?: boolean;
@@ -25,32 +21,6 @@ type CreativeAudioInputPanelProps<TAsset extends CreativeAudioAsset> = {
) => Promise<TAsset>;
};
export function readCreativeAudioFileAsAsset<TAsset extends CreativeAudioAsset>(
file: File,
source: 'uploaded' | 'recorded',
) {
return new Promise<TAsset>((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error('音频读取失败,请重试。'));
reader.onload = () => {
if (typeof reader.result !== 'string') {
reject(new Error('音频读取失败,请重试。'));
return;
}
resolve({
assetId: `local-${source}-${Date.now()}`,
audioSrc: reader.result,
audioObjectKey: '',
assetObjectId: '',
source,
prompt: file.name,
durationMs: null,
} as TAsset);
};
reader.readAsDataURL(file);
});
}
export function CreativeAudioInputPanel<TAsset extends CreativeAudioAsset>({
disabled = false,
title,
@@ -94,7 +64,8 @@ export function CreativeAudioInputPanel<TAsset extends CreativeAudioAsset>({
const file = new File([blob], buildRecordedFileName(), {
type: blob.type,
});
void readFileAsAsset(file, 'recorded')
void trimLeadingSilenceFromRecordedAudioFile(file)
.then((trimmedFile) => readFileAsAsset(trimmedFile, 'recorded'))
.then(onAssetChange)
.catch((caughtError) => {
onError(

View File

@@ -0,0 +1,35 @@
export type CreativeAudioAsset = {
assetId: string;
audioSrc: string;
audioObjectKey: string;
assetObjectId: string;
source: string;
prompt?: string | null;
durationMs?: number | null;
};
export function readCreativeAudioFileAsAsset<TAsset extends CreativeAudioAsset>(
file: File,
source: 'uploaded' | 'recorded',
) {
return new Promise<TAsset>((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error('音频读取失败,请重试。'));
reader.onload = () => {
if (typeof reader.result !== 'string') {
reject(new Error('音频读取失败,请重试。'));
return;
}
resolve({
assetId: `local-${source}-${Date.now()}`,
audioSrc: reader.result,
audioObjectKey: '',
assetObjectId: '',
source,
prompt: file.name,
durationMs: null,
} as TAsset);
};
reader.readAsDataURL(file);
});
}

View File

@@ -0,0 +1,77 @@
import { expect, test } from 'vitest';
import {
buildLeadingSilenceTrimmedWavBlob,
findFirstAudibleFrame,
} from './creativeAudioSilenceTrim';
function createAudioBufferStub(
channels: number[][],
sampleRate = 1000,
): AudioBuffer {
return {
length: channels[0]?.length ?? 0,
numberOfChannels: channels.length,
sampleRate,
duration: (channels[0]?.length ?? 0) / sampleRate,
getChannelData: (channel: number) =>
new Float32Array(channels[channel] ?? []),
} as AudioBuffer;
}
test('findFirstAudibleFrame skips leading frames that are silent across all channels', () => {
const buffer = createAudioBufferStub([
[0, 0.003, -0.006, 0.012, 0.02],
[0, -0.004, 0.009, 0, 0],
]);
expect(findFirstAudibleFrame(buffer, 0.01)).toBe(3);
});
test('buildLeadingSilenceTrimmedWavBlob writes a wav that starts at the first audible frame', async () => {
const buffer = createAudioBufferStub(
[
[0, 0, 0, 0.25, -0.5],
[0, 0, 0, -0.25, 0.5],
],
1000,
);
const blob = buildLeadingSilenceTrimmedWavBlob(buffer, {
silenceThreshold: 0.01,
minimumTrimDurationMs: 1,
});
expect(blob).not.toBeNull();
expect(blob?.type).toBe('audio/wav');
const bytes = await blob!.arrayBuffer();
const view = new DataView(bytes);
expect(String.fromCharCode(...new Uint8Array(bytes, 0, 4))).toBe('RIFF');
expect(String.fromCharCode(...new Uint8Array(bytes, 8, 4))).toBe('WAVE');
expect(String.fromCharCode(...new Uint8Array(bytes, 36, 4))).toBe('data');
expect(view.getUint32(40, true)).toBe(8);
expect(view.getInt16(44, true)).toBeCloseTo(8191, -1);
expect(view.getInt16(46, true)).toBeCloseTo(-8192, -1);
expect(view.getInt16(48, true)).toBeCloseTo(-16384, -1);
expect(view.getInt16(50, true)).toBeCloseTo(16383, -1);
});
test('buildLeadingSilenceTrimmedWavBlob keeps the original recording when no leading silence is removable', () => {
const startsImmediately = createAudioBufferStub([[0.2, 0.1, 0.05]], 1000);
const allSilent = createAudioBufferStub([[0, 0.001, -0.001]], 1000);
expect(
buildLeadingSilenceTrimmedWavBlob(startsImmediately, {
silenceThreshold: 0.01,
minimumTrimDurationMs: 1,
}),
).toBeNull();
expect(
buildLeadingSilenceTrimmedWavBlob(allSilent, {
silenceThreshold: 0.01,
minimumTrimDurationMs: 1,
}),
).toBeNull();
});

View File

@@ -0,0 +1,190 @@
type BrowserAudioGlobal = typeof globalThis & {
webkitAudioContext?: typeof AudioContext;
};
export type LeadingSilenceTrimOptions = {
silenceThreshold?: number;
minimumTrimDurationMs?: number;
};
const DEFAULT_SILENCE_THRESHOLD = 0.01;
const DEFAULT_MINIMUM_TRIM_DURATION_MS = 20;
const WAV_HEADER_BYTE_LENGTH = 44;
const WAV_BITS_PER_SAMPLE = 16;
const WAV_BYTES_PER_SAMPLE = WAV_BITS_PER_SAMPLE / 8;
export function findFirstAudibleFrame(
buffer: AudioBuffer,
silenceThreshold = DEFAULT_SILENCE_THRESHOLD,
) {
const threshold = Math.max(0, silenceThreshold);
for (let frameIndex = 0; frameIndex < buffer.length; frameIndex += 1) {
for (
let channelIndex = 0;
channelIndex < buffer.numberOfChannels;
channelIndex += 1
) {
const channelData = buffer.getChannelData(channelIndex);
if (Math.abs(channelData[frameIndex] ?? 0) > threshold) {
return frameIndex;
}
}
}
return null;
}
export function buildLeadingSilenceTrimmedWavBlob(
buffer: AudioBuffer,
options: LeadingSilenceTrimOptions = {},
) {
const silenceThreshold =
options.silenceThreshold ?? DEFAULT_SILENCE_THRESHOLD;
const minimumTrimDurationMs =
options.minimumTrimDurationMs ?? DEFAULT_MINIMUM_TRIM_DURATION_MS;
const firstAudibleFrame = findFirstAudibleFrame(buffer, silenceThreshold);
if (firstAudibleFrame === null) {
return null;
}
const minimumTrimFrames = Math.max(
1,
Math.round((buffer.sampleRate * minimumTrimDurationMs) / 1000),
);
if (firstAudibleFrame < minimumTrimFrames) {
return null;
}
const frameCount = buffer.length - firstAudibleFrame;
if (frameCount <= 0) {
return null;
}
return encodeAudioBufferSectionToWavBlob(
buffer,
firstAudibleFrame,
frameCount,
);
}
export async function trimLeadingSilenceFromRecordedAudioFile(
file: File,
options: LeadingSilenceTrimOptions = {},
) {
try {
const decodedBuffer = await decodeRecordedAudioFile(file);
if (!decodedBuffer) {
return file;
}
const trimmedBlob = buildLeadingSilenceTrimmedWavBlob(
decodedBuffer,
options,
);
if (!trimmedBlob) {
return file;
}
return new File([trimmedBlob], buildTrimmedAudioFileName(file.name), {
type: trimmedBlob.type,
lastModified: Date.now(),
});
} catch {
// 录音裁剪只是体验优化,浏览器解码失败时必须保留用户刚录好的原始文件。
return file;
}
}
function getAudioContextConstructor() {
const audioGlobal = globalThis as BrowserAudioGlobal;
return audioGlobal.AudioContext ?? audioGlobal.webkitAudioContext ?? null;
}
async function decodeRecordedAudioFile(file: File) {
const AudioContextConstructor = getAudioContextConstructor();
if (!AudioContextConstructor) {
return null;
}
const context = new AudioContextConstructor();
try {
const bytes = await file.arrayBuffer();
return await context.decodeAudioData(bytes.slice(0));
} finally {
void context.close();
}
}
function encodeAudioBufferSectionToWavBlob(
buffer: AudioBuffer,
startFrame: number,
frameCount: number,
) {
// MediaRecorder 输出格式不稳定;解码后统一写成 WAV避免再依赖浏览器重新编码。
const channelCount = Math.max(1, buffer.numberOfChannels);
const dataByteLength =
frameCount * channelCount * WAV_BYTES_PER_SAMPLE;
const output = new ArrayBuffer(WAV_HEADER_BYTE_LENGTH + dataByteLength);
const view = new DataView(output);
const channelData = Array.from({ length: channelCount }, (_value, index) =>
index < buffer.numberOfChannels
? buffer.getChannelData(index)
: new Float32Array(buffer.length),
);
writeAscii(view, 0, 'RIFF');
view.setUint32(4, 36 + dataByteLength, true);
writeAscii(view, 8, 'WAVE');
writeAscii(view, 12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, channelCount, true);
view.setUint32(24, buffer.sampleRate, true);
view.setUint32(
28,
buffer.sampleRate * channelCount * WAV_BYTES_PER_SAMPLE,
true,
);
view.setUint16(32, channelCount * WAV_BYTES_PER_SAMPLE, true);
view.setUint16(34, WAV_BITS_PER_SAMPLE, true);
writeAscii(view, 36, 'data');
view.setUint32(40, dataByteLength, true);
let outputOffset = WAV_HEADER_BYTE_LENGTH;
for (let frameOffset = 0; frameOffset < frameCount; frameOffset += 1) {
const sourceFrame = startFrame + frameOffset;
for (let channelIndex = 0; channelIndex < channelCount; channelIndex += 1) {
const sample = channelData[channelIndex]?.[sourceFrame] ?? 0;
view.setInt16(outputOffset, toSignedPcm16(sample), true);
outputOffset += WAV_BYTES_PER_SAMPLE;
}
}
return new Blob([output], { type: 'audio/wav' });
}
function toSignedPcm16(sample: number) {
const clamped = Math.max(-1, Math.min(1, sample));
return clamped < 0
? Math.round(clamped * 0x8000)
: Math.round(clamped * 0x7fff);
}
function writeAscii(view: DataView, offset: number, value: string) {
for (let index = 0; index < value.length; index += 1) {
view.setUint8(offset + index, value.charCodeAt(index));
}
}
function buildTrimmedAudioFileName(fileName: string) {
const normalizedName = fileName.trim();
if (!normalizedName) {
return 'recorded-audio.wav';
}
return /\.[^.]+$/u.test(normalizedName)
? normalizedName.replace(/\.[^.]+$/u, '.wav')
: `${normalizedName}.wav`;
}