@jjlmoya/utils-games-development 1.49.0 → 1.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/category/index.ts +2 -1
- package/src/entries.ts +5 -1
- package/src/tests/locale_completeness.test.ts +1 -1
- package/src/tests/tool_validation.test.ts +2 -2
- package/src/tool/audioLoopPointFinder/audio-loop-point-finder.css +322 -0
- package/src/tool/audioLoopPointFinder/audio-player.ts +84 -0
- package/src/tool/audioLoopPointFinder/bibliography.astro +6 -0
- package/src/tool/audioLoopPointFinder/bibliography.ts +16 -0
- package/src/tool/audioLoopPointFinder/client.ts +262 -0
- package/src/tool/audioLoopPointFinder/component.astro +147 -0
- package/src/tool/audioLoopPointFinder/dom-utils.ts +55 -0
- package/src/tool/audioLoopPointFinder/entry.ts +27 -0
- package/src/tool/audioLoopPointFinder/i18n/de.ts +211 -0
- package/src/tool/audioLoopPointFinder/i18n/en.ts +211 -0
- package/src/tool/audioLoopPointFinder/i18n/es.ts +211 -0
- package/src/tool/audioLoopPointFinder/i18n/fr.ts +211 -0
- package/src/tool/audioLoopPointFinder/i18n/id.ts +211 -0
- package/src/tool/audioLoopPointFinder/i18n/it.ts +211 -0
- package/src/tool/audioLoopPointFinder/i18n/ja.ts +211 -0
- package/src/tool/audioLoopPointFinder/i18n/ko.ts +211 -0
- package/src/tool/audioLoopPointFinder/i18n/nl.ts +211 -0
- package/src/tool/audioLoopPointFinder/i18n/pl.ts +211 -0
- package/src/tool/audioLoopPointFinder/i18n/pt.ts +211 -0
- package/src/tool/audioLoopPointFinder/i18n/ru.ts +211 -0
- package/src/tool/audioLoopPointFinder/i18n/sv.ts +211 -0
- package/src/tool/audioLoopPointFinder/i18n/tr.ts +211 -0
- package/src/tool/audioLoopPointFinder/i18n/zh.ts +211 -0
- package/src/tool/audioLoopPointFinder/index.ts +11 -0
- package/src/tool/audioLoopPointFinder/logic.test.ts +72 -0
- package/src/tool/audioLoopPointFinder/logic.ts +214 -0
- package/src/tool/audioLoopPointFinder/metadata-parser.ts +94 -0
- package/src/tool/audioLoopPointFinder/seo.astro +15 -0
- package/src/tool/audioLoopPointFinder/ui.ts +42 -0
- package/src/tool/audioLoopPointFinder/waveform-renderer.ts +53 -0
- package/src/tool/spriteSheetPacker/sprite-sheet-packer.css +55 -19
- package/src/tools.ts +3 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
samplesToSeconds,
|
|
4
|
+
secondsToSamples,
|
|
5
|
+
formatTime,
|
|
6
|
+
validateLoopPoints,
|
|
7
|
+
findNearestZeroCrossing,
|
|
8
|
+
createWavWithLoopMetadata,
|
|
9
|
+
parseAudioLoopMetadata,
|
|
10
|
+
parseCommentKV,
|
|
11
|
+
} from './logic';
|
|
12
|
+
|
|
13
|
+
describe('audioLoopPointFinder logic', () => {
|
|
14
|
+
it('converts samples to seconds and seconds to samples', () => {
|
|
15
|
+
expect(samplesToSeconds(44100, 44100)).toBe(1);
|
|
16
|
+
expect(samplesToSeconds(22050, 44100)).toBe(0.5);
|
|
17
|
+
expect(samplesToSeconds(100, 0)).toBe(0);
|
|
18
|
+
|
|
19
|
+
expect(secondsToSamples(1, 44100)).toBe(44100);
|
|
20
|
+
expect(secondsToSamples(0.5, 44100)).toBe(22050);
|
|
21
|
+
expect(secondsToSamples(1, 0)).toBe(0);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('formats time accurately', () => {
|
|
25
|
+
expect(formatTime(0)).toBe('00:00.000');
|
|
26
|
+
expect(formatTime(65.432)).toBe('01:05.432');
|
|
27
|
+
expect(formatTime(-5)).toBe('00:00.000');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('validates loop points correctly', () => {
|
|
31
|
+
expect(validateLoopPoints(-10, 1000, 5000).isValid).toBe(false);
|
|
32
|
+
expect(validateLoopPoints(1000, 6000, 5000).isValid).toBe(false);
|
|
33
|
+
expect(validateLoopPoints(2000, 1000, 5000).isValid).toBe(false);
|
|
34
|
+
expect(validateLoopPoints(1000, 1050, 5000).isValid).toBe(false);
|
|
35
|
+
expect(validateLoopPoints(1000, 4000, 5000).isValid).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('finds nearest zero crossing in waveform data', () => {
|
|
39
|
+
const data = new Float32Array([-0.5, -0.2, 0.1, 0.4, 0.2, -0.1, -0.3]);
|
|
40
|
+
const zeroCrossing = findNearestZeroCrossing(data, 1, 3);
|
|
41
|
+
expect(zeroCrossing).toBe(2);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('generates valid WAV file buffer', () => {
|
|
45
|
+
const mockChannel = new Float32Array([0, 0.5, -0.5, 0]);
|
|
46
|
+
const mockAudioBuffer = {
|
|
47
|
+
numberOfChannels: 1,
|
|
48
|
+
sampleRate: 44100,
|
|
49
|
+
length: 4,
|
|
50
|
+
duration: 4 / 44100,
|
|
51
|
+
getChannelData: () => mockChannel,
|
|
52
|
+
} as unknown as AudioBuffer;
|
|
53
|
+
|
|
54
|
+
const wavBuffer = createWavWithLoopMetadata(mockAudioBuffer, 1, 3);
|
|
55
|
+
expect(wavBuffer).toBeInstanceOf(Uint8Array);
|
|
56
|
+
expect(wavBuffer.length).toBeGreaterThan(44);
|
|
57
|
+
|
|
58
|
+
const riffHeader = String.fromCharCode(wavBuffer[0] ?? 0, wavBuffer[1] ?? 0, wavBuffer[2] ?? 0, wavBuffer[3] ?? 0);
|
|
59
|
+
expect(riffHeader).toBe('RIFF');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('parses audio loop metadata from WAV and comment KVs', () => {
|
|
63
|
+
const res: { loopStart?: number; loopEnd?: number } = {};
|
|
64
|
+
parseCommentKV('LOOPSTART=1200', res);
|
|
65
|
+
parseCommentKV('LOOPEND=4800', res);
|
|
66
|
+
expect(res.loopStart).toBe(1200);
|
|
67
|
+
expect(res.loopEnd).toBe(4800);
|
|
68
|
+
|
|
69
|
+
const emptyMeta = parseAudioLoopMetadata(new Uint8Array([0, 1, 2, 3]));
|
|
70
|
+
expect(emptyMeta.loopStart).toBeUndefined();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
export interface AudioMetadata {
|
|
2
|
+
duration: number;
|
|
3
|
+
sampleRate: number;
|
|
4
|
+
numberOfChannels: number;
|
|
5
|
+
length: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface LoopPoints {
|
|
9
|
+
startSample: number;
|
|
10
|
+
endSample: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface LoopValidationResult {
|
|
14
|
+
isValid: boolean;
|
|
15
|
+
error?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SmplChunkConfig {
|
|
19
|
+
buffer: Uint8Array;
|
|
20
|
+
view: DataView;
|
|
21
|
+
sampleRate: number;
|
|
22
|
+
loopStart: number;
|
|
23
|
+
loopEnd: number;
|
|
24
|
+
offset: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface WavHeaderConfig {
|
|
28
|
+
buffer: Uint8Array;
|
|
29
|
+
view: DataView;
|
|
30
|
+
sampleRate: number;
|
|
31
|
+
numChannels: number;
|
|
32
|
+
totalFileSize: number;
|
|
33
|
+
blockAlign: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export {
|
|
37
|
+
parseWavLoopTags,
|
|
38
|
+
parseCommentKV,
|
|
39
|
+
parseOggVorbisComments,
|
|
40
|
+
parseAudioLoopMetadata,
|
|
41
|
+
} from './metadata-parser';
|
|
42
|
+
export type { ParsedLoopMetadata } from './metadata-parser';
|
|
43
|
+
|
|
44
|
+
export function samplesToSeconds(samples: number, sampleRate: number): number {
|
|
45
|
+
if (sampleRate <= 0) return 0;
|
|
46
|
+
return samples / sampleRate;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function secondsToSamples(seconds: number, sampleRate: number): number {
|
|
50
|
+
if (sampleRate <= 0) return 0;
|
|
51
|
+
return Math.round(seconds * sampleRate);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function formatTime(seconds: number): string {
|
|
55
|
+
if (isNaN(seconds) || seconds < 0) return '00:00.000';
|
|
56
|
+
const mins = Math.floor(seconds / 60);
|
|
57
|
+
const secs = Math.floor(seconds % 60);
|
|
58
|
+
const ms = Math.floor((seconds - Math.floor(seconds)) * 1000);
|
|
59
|
+
const pad = (n: number, z = 2) => String(n).padStart(z, '0');
|
|
60
|
+
return `${pad(mins)}:${pad(secs)}.${pad(ms, 3)}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function validateLoopPoints(startSample: number, endSample: number, totalSamples: number): LoopValidationResult {
|
|
64
|
+
if (startSample < 0) return { isValid: false, error: 'Start sample cannot be negative' };
|
|
65
|
+
if (endSample > totalSamples) return { isValid: false, error: 'End sample exceeds total audio length' };
|
|
66
|
+
if (startSample >= endSample) return { isValid: false, error: 'Start sample must be before end sample' };
|
|
67
|
+
if (endSample - startSample < 100) return { isValid: false, error: 'Loop duration is too short' };
|
|
68
|
+
return { isValid: true };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function isZeroCrossingPoint(v1: number, v2: number): boolean {
|
|
72
|
+
return (v1 <= 0 && v2 >= 0) || (v1 >= 0 && v2 <= 0);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function checkZeroCrossingCandidate(
|
|
76
|
+
data: Float32Array,
|
|
77
|
+
idx: number,
|
|
78
|
+
clamped: number,
|
|
79
|
+
best: { sample: number; dist: number }
|
|
80
|
+
): void {
|
|
81
|
+
const val = data[idx] ?? 0;
|
|
82
|
+
const nextVal = data[idx + 1] ?? 0;
|
|
83
|
+
if (isZeroCrossingPoint(val, nextVal)) {
|
|
84
|
+
const zeroPoint = Math.abs(val) < Math.abs(nextVal) ? idx : idx + 1;
|
|
85
|
+
const dist = Math.abs(zeroPoint - clamped);
|
|
86
|
+
if (dist < best.dist) {
|
|
87
|
+
best.dist = dist;
|
|
88
|
+
best.sample = zeroPoint;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function findNearestZeroCrossing(
|
|
94
|
+
channelData: Float32Array,
|
|
95
|
+
targetSample: number,
|
|
96
|
+
searchRadius: number = 500
|
|
97
|
+
): number {
|
|
98
|
+
if (!channelData || channelData.length === 0) return 0;
|
|
99
|
+
const clamped = Math.max(0, Math.min(channelData.length - 1, targetSample));
|
|
100
|
+
const minIdx = Math.max(0, clamped - searchRadius);
|
|
101
|
+
const maxIdx = Math.min(channelData.length - 2, clamped + searchRadius);
|
|
102
|
+
|
|
103
|
+
const best = { sample: clamped, dist: Infinity };
|
|
104
|
+
for (let i = minIdx; i <= maxIdx; i++) {
|
|
105
|
+
checkZeroCrossingCandidate(channelData, i, clamped, best);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return best.sample;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function encodePcmSamples(
|
|
112
|
+
view: DataView,
|
|
113
|
+
audioBuffer: AudioBuffer,
|
|
114
|
+
offset: number
|
|
115
|
+
): void {
|
|
116
|
+
const numChannels = audioBuffer.numberOfChannels;
|
|
117
|
+
const numSamples = audioBuffer.length;
|
|
118
|
+
let currentOffset = offset;
|
|
119
|
+
|
|
120
|
+
const channels: Float32Array[] = [];
|
|
121
|
+
for (let c = 0; c < numChannels; c++) {
|
|
122
|
+
channels.push(audioBuffer.getChannelData(c));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
for (let i = 0; i < numSamples; i++) {
|
|
126
|
+
for (let c = 0; c < numChannels; c++) {
|
|
127
|
+
const chData = channels[c];
|
|
128
|
+
const rawSample = chData ? (chData[i] ?? 0) : 0;
|
|
129
|
+
const sample = Math.max(-1, Math.min(1, rawSample));
|
|
130
|
+
const intSample = sample < 0 ? sample * 0x8000 : sample * 0x7FFF;
|
|
131
|
+
view.setInt16(currentOffset, Math.round(intSample), true);
|
|
132
|
+
currentOffset += 2;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function writeSmplChunkData(cfg: SmplChunkConfig): number {
|
|
138
|
+
let offset = cfg.offset;
|
|
139
|
+
const writeStr = (s: string) => {
|
|
140
|
+
for (let i = 0; i < s.length; i++) cfg.buffer[offset++] = s.charCodeAt(i);
|
|
141
|
+
};
|
|
142
|
+
const write32 = (v: number) => {
|
|
143
|
+
cfg.view.setUint32(offset, v, true);
|
|
144
|
+
offset += 4;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
writeStr('smpl');
|
|
148
|
+
write32(60);
|
|
149
|
+
write32(0);
|
|
150
|
+
write32(0);
|
|
151
|
+
write32(Math.round(1000000000 / cfg.sampleRate));
|
|
152
|
+
write32(60);
|
|
153
|
+
write32(0);
|
|
154
|
+
write32(0);
|
|
155
|
+
write32(0);
|
|
156
|
+
write32(1);
|
|
157
|
+
write32(0);
|
|
158
|
+
write32(0);
|
|
159
|
+
write32(0);
|
|
160
|
+
write32(cfg.loopStart);
|
|
161
|
+
write32(cfg.loopEnd);
|
|
162
|
+
write32(0);
|
|
163
|
+
write32(0);
|
|
164
|
+
|
|
165
|
+
return offset;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function writeWavFmtHeader(cfg: WavHeaderConfig): number {
|
|
169
|
+
let offset = 0;
|
|
170
|
+
for (const c of 'RIFF') cfg.buffer[offset++] = c.charCodeAt(0);
|
|
171
|
+
cfg.view.setUint32(offset, cfg.totalFileSize - 8, true);
|
|
172
|
+
offset += 4;
|
|
173
|
+
for (const c of 'WAVEfmt ') cfg.buffer[offset++] = c.charCodeAt(0);
|
|
174
|
+
cfg.view.setUint32(offset, 16, true);
|
|
175
|
+
offset += 4;
|
|
176
|
+
cfg.view.setUint16(offset, 1, true);
|
|
177
|
+
offset += 2;
|
|
178
|
+
cfg.view.setUint16(offset, cfg.numChannels, true);
|
|
179
|
+
offset += 2;
|
|
180
|
+
cfg.view.setUint32(offset, cfg.sampleRate, true);
|
|
181
|
+
offset += 4;
|
|
182
|
+
cfg.view.setUint32(offset, cfg.sampleRate * cfg.blockAlign, true);
|
|
183
|
+
offset += 4;
|
|
184
|
+
cfg.view.setUint16(offset, cfg.blockAlign, true);
|
|
185
|
+
offset += 2;
|
|
186
|
+
cfg.view.setUint16(offset, 16, true);
|
|
187
|
+
offset += 2;
|
|
188
|
+
return offset;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function createWavWithLoopMetadata(
|
|
192
|
+
audioBuffer: AudioBuffer,
|
|
193
|
+
loopStart: number,
|
|
194
|
+
loopEnd: number
|
|
195
|
+
): Uint8Array {
|
|
196
|
+
const numChannels = audioBuffer.numberOfChannels;
|
|
197
|
+
const sampleRate = audioBuffer.sampleRate;
|
|
198
|
+
const blockAlign = numChannels * 2;
|
|
199
|
+
const dataSize = audioBuffer.length * blockAlign;
|
|
200
|
+
const totalFileSize = 112 + dataSize;
|
|
201
|
+
|
|
202
|
+
const buffer = new Uint8Array(totalFileSize);
|
|
203
|
+
const view = new DataView(buffer.buffer);
|
|
204
|
+
|
|
205
|
+
let offset = writeWavFmtHeader({ buffer, view, sampleRate, numChannels, totalFileSize, blockAlign });
|
|
206
|
+
offset = writeSmplChunkData({ buffer, view, sampleRate, loopStart, loopEnd, offset });
|
|
207
|
+
|
|
208
|
+
for (const char of 'data') buffer[offset++] = char.charCodeAt(0);
|
|
209
|
+
view.setUint32(offset, dataSize, true);
|
|
210
|
+
offset += 4;
|
|
211
|
+
|
|
212
|
+
encodePcmSamples(view, audioBuffer, offset);
|
|
213
|
+
return buffer;
|
|
214
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
export interface ParsedLoopMetadata {
|
|
2
|
+
loopStart?: number;
|
|
3
|
+
loopEnd?: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function parseWavLoopTags(bytes: Uint8Array): ParsedLoopMetadata {
|
|
7
|
+
const result: ParsedLoopMetadata = {};
|
|
8
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
9
|
+
const len = bytes.length - 12;
|
|
10
|
+
|
|
11
|
+
for (let i = 0; i < len; i++) {
|
|
12
|
+
const isSmpl = bytes[i] === 0x73 && bytes[i + 1] === 0x6d && bytes[i + 2] === 0x70 && bytes[i + 3] === 0x6c;
|
|
13
|
+
if (isSmpl && view.getUint32(i + 36, true) > 0 && i + 56 <= bytes.length) {
|
|
14
|
+
result.loopStart = view.getUint32(i + 44, true);
|
|
15
|
+
result.loopEnd = view.getUint32(i + 48, true);
|
|
16
|
+
break;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return result;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function assignLoopKey(key: string, val: number, res: ParsedLoopMetadata): void {
|
|
23
|
+
if (key === 'LOOPSTART' || key === 'LOOP_START') res.loopStart = val;
|
|
24
|
+
else if (key === 'LOOPEND' || key === 'LOOP_END') res.loopEnd = val;
|
|
25
|
+
else if ((key === 'LOOPLENGTH' || key === 'LOOP_LENGTH') && res.loopStart !== undefined) {
|
|
26
|
+
res.loopEnd = res.loopStart + val;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function parseCommentKV(kv: string, res: ParsedLoopMetadata): void {
|
|
31
|
+
const parts = kv.split('=');
|
|
32
|
+
if (parts.length < 2) return;
|
|
33
|
+
const key = (parts[0] ?? '').toUpperCase().trim();
|
|
34
|
+
const val = parseInt((parts[1] ?? '').trim(), 10);
|
|
35
|
+
if (!isNaN(val)) {
|
|
36
|
+
assignLoopKey(key, val, res);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function isVorbisHeaderAt(bytes: Uint8Array, idx: number): boolean {
|
|
41
|
+
return (
|
|
42
|
+
bytes[idx] === 0x03 &&
|
|
43
|
+
bytes[idx + 1] === 118 &&
|
|
44
|
+
bytes[idx + 2] === 111 &&
|
|
45
|
+
bytes[idx + 3] === 114 &&
|
|
46
|
+
bytes[idx + 4] === 98 &&
|
|
47
|
+
bytes[idx + 5] === 105 &&
|
|
48
|
+
bytes[idx + 6] === 115
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function findVorbisHeaderPos(bytes: Uint8Array): number {
|
|
53
|
+
const len = bytes.length - 7;
|
|
54
|
+
for (let i = 0; i < len; i++) {
|
|
55
|
+
if (isVorbisHeaderAt(bytes, i)) return i + 7;
|
|
56
|
+
}
|
|
57
|
+
return -1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function parseOggVorbisComments(bytes: Uint8Array): ParsedLoopMetadata {
|
|
61
|
+
const res: ParsedLoopMetadata = {};
|
|
62
|
+
let pos = findVorbisHeaderPos(bytes);
|
|
63
|
+
if (pos === -1 || pos + 4 > bytes.length) return res;
|
|
64
|
+
|
|
65
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
66
|
+
pos += 4 + view.getUint32(pos, true);
|
|
67
|
+
if (pos + 4 > bytes.length) return res;
|
|
68
|
+
|
|
69
|
+
const userCommentCount = view.getUint32(pos, true);
|
|
70
|
+
pos += 4;
|
|
71
|
+
const decoder = new TextDecoder('utf-8');
|
|
72
|
+
|
|
73
|
+
for (let c = 0; c < userCommentCount && pos + 4 <= bytes.length; c++) {
|
|
74
|
+
const commentLen = view.getUint32(pos, true);
|
|
75
|
+
pos += 4;
|
|
76
|
+
if (pos + commentLen > bytes.length) break;
|
|
77
|
+
parseCommentKV(decoder.decode(bytes.subarray(pos, pos + commentLen)), res);
|
|
78
|
+
pos += commentLen;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return res;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function getFileSignature(bytes: Uint8Array): string {
|
|
85
|
+
if (bytes.length < 4) return '';
|
|
86
|
+
return String.fromCharCode(bytes[0] ?? 0, bytes[1] ?? 0, bytes[2] ?? 0, bytes[3] ?? 0);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function parseAudioLoopMetadata(bytes: Uint8Array): ParsedLoopMetadata {
|
|
90
|
+
const sig = getFileSignature(bytes);
|
|
91
|
+
if (sig === 'OggS') return parseOggVorbisComments(bytes);
|
|
92
|
+
if (sig === 'RIFF') return parseWavLoopTags(bytes);
|
|
93
|
+
return {};
|
|
94
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { SEORenderer } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { audioLoopPointFinder } from './entry';
|
|
4
|
+
import type { KnownLocale } from '../../types';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
locale?: KnownLocale;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { locale = 'en' } = Astro.props;
|
|
11
|
+
const loader = audioLoopPointFinder.i18n[locale] ?? audioLoopPointFinder.i18n.en;
|
|
12
|
+
const content = loader ? await loader() : null;
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
{content && content.seo && content.seo.length > 0 && <SEORenderer content={{ locale, sections: content.seo }} />}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export interface AudioLoopPointFinderUI extends Record<string, string> {
|
|
2
|
+
title: string;
|
|
3
|
+
subtitle: string;
|
|
4
|
+
dropzoneTitle: string;
|
|
5
|
+
dropzoneSubtitle: string;
|
|
6
|
+
dropzoneButton: string;
|
|
7
|
+
audioInfoTitle: string;
|
|
8
|
+
durationLabel: string;
|
|
9
|
+
sampleRateLabel: string;
|
|
10
|
+
channelsLabel: string;
|
|
11
|
+
totalSamplesLabel: string;
|
|
12
|
+
loopControlsTitle: string;
|
|
13
|
+
loopStartLabel: string;
|
|
14
|
+
loopEndLabel: string;
|
|
15
|
+
loopDurationLabel: string;
|
|
16
|
+
zeroCrossingLabel: string;
|
|
17
|
+
snapZeroCrossingButton: string;
|
|
18
|
+
playLoopButton: string;
|
|
19
|
+
pauseLoopButton: string;
|
|
20
|
+
stopLoopButton: string;
|
|
21
|
+
exportWavButton: string;
|
|
22
|
+
sampleUnitLabel: string;
|
|
23
|
+
secondUnitLabel: string;
|
|
24
|
+
zoomLabel: string;
|
|
25
|
+
zoomInButton: string;
|
|
26
|
+
zoomOutButton: string;
|
|
27
|
+
resetZoomButton: string;
|
|
28
|
+
noFileSelected: string;
|
|
29
|
+
invalidAudioFile: string;
|
|
30
|
+
presetsTitle: string;
|
|
31
|
+
presetFullTrack: string;
|
|
32
|
+
presetIntroCut: string;
|
|
33
|
+
presetMiddleLoop: string;
|
|
34
|
+
statusLooping: string;
|
|
35
|
+
statusPaused: string;
|
|
36
|
+
statusReady: string;
|
|
37
|
+
statusLoaded: string;
|
|
38
|
+
statusDecodeError: string;
|
|
39
|
+
statusSnapped: string;
|
|
40
|
+
statusStopped: string;
|
|
41
|
+
statusExported: string;
|
|
42
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export interface WaveformOverlayConfig {
|
|
2
|
+
ctx: CanvasRenderingContext2D;
|
|
3
|
+
loopStartSample: number;
|
|
4
|
+
loopEndSample: number;
|
|
5
|
+
totalSamples: number;
|
|
6
|
+
width: number;
|
|
7
|
+
height: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function renderWaveformBars(
|
|
11
|
+
ctx: CanvasRenderingContext2D,
|
|
12
|
+
audioBuffer: AudioBuffer,
|
|
13
|
+
width: number,
|
|
14
|
+
height: number
|
|
15
|
+
) {
|
|
16
|
+
const channelData = audioBuffer.getChannelData(0);
|
|
17
|
+
const step = Math.ceil(channelData.length / width);
|
|
18
|
+
const amp = height / 2;
|
|
19
|
+
|
|
20
|
+
ctx.fillStyle = '#4f46e5';
|
|
21
|
+
for (let i = 0; i < width; i++) {
|
|
22
|
+
let min = 1.0;
|
|
23
|
+
let max = -1.0;
|
|
24
|
+
for (let j = 0; j < step; j++) {
|
|
25
|
+
const datum = channelData[i * step + j] ?? 0;
|
|
26
|
+
if (datum < min) min = datum;
|
|
27
|
+
if (datum > max) max = datum;
|
|
28
|
+
}
|
|
29
|
+
ctx.fillRect(i, (1 + min) * amp, 1, Math.max(1, (max - min) * amp));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function renderWaveformOverlay(cfg: WaveformOverlayConfig) {
|
|
34
|
+
const startX = (cfg.loopStartSample / cfg.totalSamples) * cfg.width;
|
|
35
|
+
const endX = (cfg.loopEndSample / cfg.totalSamples) * cfg.width;
|
|
36
|
+
|
|
37
|
+
cfg.ctx.fillStyle = 'rgba(99, 102, 241, 0.2)';
|
|
38
|
+
cfg.ctx.fillRect(startX, 0, Math.max(1, endX - startX), cfg.height);
|
|
39
|
+
|
|
40
|
+
cfg.ctx.strokeStyle = '#10b981';
|
|
41
|
+
cfg.ctx.lineWidth = 2;
|
|
42
|
+
cfg.ctx.beginPath();
|
|
43
|
+
cfg.ctx.moveTo(startX, 0);
|
|
44
|
+
cfg.ctx.lineTo(startX, cfg.height);
|
|
45
|
+
cfg.ctx.stroke();
|
|
46
|
+
|
|
47
|
+
cfg.ctx.strokeStyle = '#ef4444';
|
|
48
|
+
cfg.ctx.lineWidth = 2;
|
|
49
|
+
cfg.ctx.beginPath();
|
|
50
|
+
cfg.ctx.moveTo(endX, 0);
|
|
51
|
+
cfg.ctx.lineTo(endX, cfg.height);
|
|
52
|
+
cfg.ctx.stroke();
|
|
53
|
+
}
|
|
@@ -108,7 +108,7 @@
|
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
.ssp-dropzone {
|
|
111
|
-
border: 2px dashed #818cf8;
|
|
111
|
+
border: 2px dashed var(--color-primary-light, #818cf8);
|
|
112
112
|
border-radius: 12px;
|
|
113
113
|
padding: 1.5rem 1rem;
|
|
114
114
|
text-align: center;
|
|
@@ -120,7 +120,7 @@
|
|
|
120
120
|
.ssp-dropzone:hover,
|
|
121
121
|
.ssp-dropzone.dragover {
|
|
122
122
|
background: rgba(129, 140, 248, 0.12);
|
|
123
|
-
border-color: #6366f1;
|
|
123
|
+
border-color: var(--color-primary, #6366f1);
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
.ssp-drop-title {
|
|
@@ -161,13 +161,13 @@
|
|
|
161
161
|
|
|
162
162
|
.ssp-chip-btn:hover {
|
|
163
163
|
background: rgba(129, 140, 248, 0.2);
|
|
164
|
-
border-color: #6366f1;
|
|
164
|
+
border-color: var(--color-primary, #6366f1);
|
|
165
165
|
}
|
|
166
166
|
|
|
167
167
|
.ssp-chip-btn.active {
|
|
168
168
|
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
|
169
169
|
color: var(--text-on-primary, #fff);
|
|
170
|
-
border-color: #6366f1;
|
|
170
|
+
border-color: var(--color-primary, #6366f1);
|
|
171
171
|
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3);
|
|
172
172
|
}
|
|
173
173
|
|
|
@@ -220,7 +220,7 @@
|
|
|
220
220
|
|
|
221
221
|
.ssp-step-btn:hover {
|
|
222
222
|
background: rgba(99, 102, 241, 0.2);
|
|
223
|
-
border-color: #6366f1;
|
|
223
|
+
border-color: var(--color-primary, #6366f1);
|
|
224
224
|
}
|
|
225
225
|
|
|
226
226
|
.ssp-step-btn:active {
|
|
@@ -229,7 +229,7 @@
|
|
|
229
229
|
|
|
230
230
|
.ssp-slider {
|
|
231
231
|
flex: 1;
|
|
232
|
-
accent-color: #6366f1;
|
|
232
|
+
accent-color: var(--color-primary, #6366f1);
|
|
233
233
|
}
|
|
234
234
|
|
|
235
235
|
.ssp-select,
|
|
@@ -259,7 +259,7 @@
|
|
|
259
259
|
.ssp-toggle-input {
|
|
260
260
|
width: 1.25rem;
|
|
261
261
|
height: 1.25rem;
|
|
262
|
-
accent-color: #6366f1;
|
|
262
|
+
accent-color: var(--color-primary, #6366f1);
|
|
263
263
|
cursor: pointer;
|
|
264
264
|
}
|
|
265
265
|
|
|
@@ -428,7 +428,12 @@
|
|
|
428
428
|
flex-direction: column;
|
|
429
429
|
border-radius: 10px;
|
|
430
430
|
overflow: hidden;
|
|
431
|
-
border: 1px solid #
|
|
431
|
+
border: 1px solid var(--border-base, #e2e8f0);
|
|
432
|
+
background: var(--bg-surface-secondary, #f8fafc);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
.theme-dark .ssp-code-container {
|
|
436
|
+
border-color: #1e293b;
|
|
432
437
|
background: #090d16;
|
|
433
438
|
}
|
|
434
439
|
|
|
@@ -436,21 +441,30 @@
|
|
|
436
441
|
display: flex;
|
|
437
442
|
align-items: center;
|
|
438
443
|
justify-content: space-between;
|
|
439
|
-
background:
|
|
444
|
+
background: rgba(148, 163, 184, 0.12);
|
|
440
445
|
padding: 0.6rem 0.85rem;
|
|
441
|
-
border-bottom: 1px solid #
|
|
446
|
+
border-bottom: 1px solid var(--border-base, #e2e8f0);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
.theme-dark .ssp-code-header {
|
|
450
|
+
background: #0f172a;
|
|
451
|
+
border-color: #1e293b;
|
|
442
452
|
}
|
|
443
453
|
|
|
444
454
|
.ssp-code-title {
|
|
445
455
|
font-size: 0.82rem;
|
|
446
456
|
font-weight: 600;
|
|
457
|
+
color: var(--text-base, #334155);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
.theme-dark .ssp-code-title {
|
|
447
461
|
color: var(--text-muted, #94a3b8);
|
|
448
462
|
}
|
|
449
463
|
|
|
450
464
|
.ssp-copy-btn {
|
|
451
|
-
background: rgba(
|
|
452
|
-
border: 1px solid rgba(
|
|
453
|
-
color: var(--color-
|
|
465
|
+
background: rgba(99, 102, 241, 0.1);
|
|
466
|
+
border: 1px solid rgba(99, 102, 241, 0.3);
|
|
467
|
+
color: var(--color-primary, #4f46e5);
|
|
454
468
|
padding: 0.35rem 0.75rem;
|
|
455
469
|
border-radius: 6px;
|
|
456
470
|
font-size: 0.78rem;
|
|
@@ -459,7 +473,17 @@
|
|
|
459
473
|
transition: all 0.15s ease;
|
|
460
474
|
}
|
|
461
475
|
|
|
476
|
+
.theme-dark .ssp-copy-btn {
|
|
477
|
+
background: rgba(56, 189, 248, 0.12);
|
|
478
|
+
border-color: rgba(56, 189, 248, 0.3);
|
|
479
|
+
color: var(--color-cyan, #38bdf8);
|
|
480
|
+
}
|
|
481
|
+
|
|
462
482
|
.ssp-copy-btn:hover {
|
|
483
|
+
background: rgba(99, 102, 241, 0.2);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
.theme-dark .ssp-copy-btn:hover {
|
|
463
487
|
background: rgba(56, 189, 248, 0.25);
|
|
464
488
|
}
|
|
465
489
|
|
|
@@ -470,8 +494,8 @@
|
|
|
470
494
|
.ssp-code-block {
|
|
471
495
|
width: 100%;
|
|
472
496
|
margin: 0;
|
|
473
|
-
background: #
|
|
474
|
-
color: var(--
|
|
497
|
+
background: #f1f5f9;
|
|
498
|
+
color: var(--text-base, #0f172a);
|
|
475
499
|
font-size: 0.82rem;
|
|
476
500
|
padding: 0.85rem 1rem;
|
|
477
501
|
overflow-x: auto;
|
|
@@ -479,6 +503,11 @@
|
|
|
479
503
|
word-break: break-all;
|
|
480
504
|
}
|
|
481
505
|
|
|
506
|
+
.theme-dark .ssp-code-block {
|
|
507
|
+
background: #070a12;
|
|
508
|
+
color: var(--color-cyan, #38bdf8);
|
|
509
|
+
}
|
|
510
|
+
|
|
482
511
|
.ssp-toast {
|
|
483
512
|
position: fixed;
|
|
484
513
|
bottom: 2rem;
|
|
@@ -515,15 +544,22 @@
|
|
|
515
544
|
position: absolute;
|
|
516
545
|
font-size: 0.75rem;
|
|
517
546
|
font-weight: 800;
|
|
518
|
-
color: var(--color-
|
|
519
|
-
background: rgba(
|
|
520
|
-
border: 1px solid #
|
|
547
|
+
color: var(--color-primary, #4f46e5);
|
|
548
|
+
background: rgba(255, 255, 255, 0.95);
|
|
549
|
+
border: 1px solid var(--color-primary, #6366f1);
|
|
521
550
|
border-radius: 4px;
|
|
522
551
|
padding: 0.2rem 0.5rem;
|
|
523
|
-
box-shadow: 0 0 10px rgba(
|
|
552
|
+
box-shadow: 0 0 10px rgba(99, 102, 241, 0.3);
|
|
524
553
|
animation: ssp-particle-float 1s forwards ease-out;
|
|
525
554
|
}
|
|
526
555
|
|
|
556
|
+
.theme-dark .ssp-particle {
|
|
557
|
+
color: var(--color-cyan, #38bdf8);
|
|
558
|
+
background: rgba(15, 23, 42, 0.9);
|
|
559
|
+
border-color: #38bdf8;
|
|
560
|
+
box-shadow: 0 0 10px rgba(56, 189, 248, 0.5);
|
|
561
|
+
}
|
|
562
|
+
|
|
527
563
|
@keyframes ssp-particle-float {
|
|
528
564
|
0% {
|
|
529
565
|
opacity: 1;
|
package/src/tools.ts
CHANGED
|
@@ -3,9 +3,12 @@ import type { ToolDefinition } from './types';
|
|
|
3
3
|
import { STEAM_CAPSULE_GENERATOR_TOOL } from './tool/steamCapsuleGenerator';
|
|
4
4
|
import { ITCHIO_GAME_TESTER_TOOL } from './tool/itchioGameTester';
|
|
5
5
|
import { SPRITE_SHEET_PACKER_TOOL } from './tool/spriteSheetPacker';
|
|
6
|
+
import { AUDIO_LOOP_POINT_FINDER_TOOL } from './tool/audioLoopPointFinder';
|
|
6
7
|
|
|
7
8
|
export const ALL_TOOLS: ToolDefinition[] = [
|
|
8
9
|
STEAM_CAPSULE_GENERATOR_TOOL,
|
|
9
10
|
ITCHIO_GAME_TESTER_TOOL,
|
|
10
11
|
SPRITE_SHEET_PACKER_TOOL,
|
|
12
|
+
AUDIO_LOOP_POINT_FINDER_TOOL,
|
|
11
13
|
];
|
|
14
|
+
|