@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.
Files changed (37) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +2 -1
  3. package/src/entries.ts +5 -1
  4. package/src/tests/locale_completeness.test.ts +1 -1
  5. package/src/tests/tool_validation.test.ts +2 -2
  6. package/src/tool/audioLoopPointFinder/audio-loop-point-finder.css +322 -0
  7. package/src/tool/audioLoopPointFinder/audio-player.ts +84 -0
  8. package/src/tool/audioLoopPointFinder/bibliography.astro +6 -0
  9. package/src/tool/audioLoopPointFinder/bibliography.ts +16 -0
  10. package/src/tool/audioLoopPointFinder/client.ts +262 -0
  11. package/src/tool/audioLoopPointFinder/component.astro +147 -0
  12. package/src/tool/audioLoopPointFinder/dom-utils.ts +55 -0
  13. package/src/tool/audioLoopPointFinder/entry.ts +27 -0
  14. package/src/tool/audioLoopPointFinder/i18n/de.ts +211 -0
  15. package/src/tool/audioLoopPointFinder/i18n/en.ts +211 -0
  16. package/src/tool/audioLoopPointFinder/i18n/es.ts +211 -0
  17. package/src/tool/audioLoopPointFinder/i18n/fr.ts +211 -0
  18. package/src/tool/audioLoopPointFinder/i18n/id.ts +211 -0
  19. package/src/tool/audioLoopPointFinder/i18n/it.ts +211 -0
  20. package/src/tool/audioLoopPointFinder/i18n/ja.ts +211 -0
  21. package/src/tool/audioLoopPointFinder/i18n/ko.ts +211 -0
  22. package/src/tool/audioLoopPointFinder/i18n/nl.ts +211 -0
  23. package/src/tool/audioLoopPointFinder/i18n/pl.ts +211 -0
  24. package/src/tool/audioLoopPointFinder/i18n/pt.ts +211 -0
  25. package/src/tool/audioLoopPointFinder/i18n/ru.ts +211 -0
  26. package/src/tool/audioLoopPointFinder/i18n/sv.ts +211 -0
  27. package/src/tool/audioLoopPointFinder/i18n/tr.ts +211 -0
  28. package/src/tool/audioLoopPointFinder/i18n/zh.ts +211 -0
  29. package/src/tool/audioLoopPointFinder/index.ts +11 -0
  30. package/src/tool/audioLoopPointFinder/logic.test.ts +72 -0
  31. package/src/tool/audioLoopPointFinder/logic.ts +214 -0
  32. package/src/tool/audioLoopPointFinder/metadata-parser.ts +94 -0
  33. package/src/tool/audioLoopPointFinder/seo.astro +15 -0
  34. package/src/tool/audioLoopPointFinder/ui.ts +42 -0
  35. package/src/tool/audioLoopPointFinder/waveform-renderer.ts +53 -0
  36. package/src/tool/spriteSheetPacker/sprite-sheet-packer.css +55 -19
  37. 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 #1e293b;
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: #0f172a;
444
+ background: rgba(148, 163, 184, 0.12);
440
445
  padding: 0.6rem 0.85rem;
441
- border-bottom: 1px solid #1e293b;
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(56, 189, 248, 0.12);
452
- border: 1px solid rgba(56, 189, 248, 0.3);
453
- color: var(--color-cyan, #38bdf8);
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: #070a12;
474
- color: var(--color-cyan, #38bdf8);
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-cyan, #38bdf8);
519
- background: rgba(15, 23, 42, 0.9);
520
- border: 1px solid #38bdf8;
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(56, 189, 248, 0.5);
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
+