@jjlmoya/utils-games-development 1.50.0 → 1.52.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 (61) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +3 -1
  3. package/src/entries.ts +8 -1
  4. package/src/tests/locale_completeness.test.ts +2 -2
  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/saveFileEditor/bibliography.astro +6 -0
  37. package/src/tool/saveFileEditor/bibliography.ts +12 -0
  38. package/src/tool/saveFileEditor/component.astro +351 -0
  39. package/src/tool/saveFileEditor/entry.ts +28 -0
  40. package/src/tool/saveFileEditor/game-save-file-editor.css +250 -0
  41. package/src/tool/saveFileEditor/i18n/de.ts +182 -0
  42. package/src/tool/saveFileEditor/i18n/en.ts +182 -0
  43. package/src/tool/saveFileEditor/i18n/es.ts +182 -0
  44. package/src/tool/saveFileEditor/i18n/fr.ts +182 -0
  45. package/src/tool/saveFileEditor/i18n/id.ts +182 -0
  46. package/src/tool/saveFileEditor/i18n/it.ts +182 -0
  47. package/src/tool/saveFileEditor/i18n/ja.ts +182 -0
  48. package/src/tool/saveFileEditor/i18n/ko.ts +182 -0
  49. package/src/tool/saveFileEditor/i18n/nl.ts +182 -0
  50. package/src/tool/saveFileEditor/i18n/pl.ts +182 -0
  51. package/src/tool/saveFileEditor/i18n/pt.ts +182 -0
  52. package/src/tool/saveFileEditor/i18n/ru.ts +182 -0
  53. package/src/tool/saveFileEditor/i18n/sv.ts +182 -0
  54. package/src/tool/saveFileEditor/i18n/tr.ts +182 -0
  55. package/src/tool/saveFileEditor/i18n/zh.ts +182 -0
  56. package/src/tool/saveFileEditor/index.ts +11 -0
  57. package/src/tool/saveFileEditor/logic.test.ts +69 -0
  58. package/src/tool/saveFileEditor/logic.ts +139 -0
  59. package/src/tool/saveFileEditor/seo.astro +15 -0
  60. package/src/tool/saveFileEditor/ui.ts +24 -0
  61. package/src/tools.ts +5 -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
+ }
@@ -0,0 +1,6 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { bibliographyEntries } from './bibliography';
4
+ ---
5
+
6
+ <SharedBibliography links={bibliographyEntries} />
@@ -0,0 +1,12 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliographyEntries: BibliographyEntry[] = [
4
+ {
5
+ name: 'Game Save Data Architecture (Game Developer Article)',
6
+ url: 'https://www.gamedeveloper.com/programming/game-save-data-architecture',
7
+ },
8
+ {
9
+ name: 'RFC 4648 - The Base16, Base32, and Base64 Data Encodings',
10
+ url: 'https://datatracker.ietf.org/doc/html/rfc4648',
11
+ },
12
+ ];