27b30f974b
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.
191 lines
5.3 KiB
TypeScript
191 lines
5.3 KiB
TypeScript
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`;
|
|
}
|