emoemu 0.1.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/.claude/settings.local.json +77 -0
- package/.node-version +1 -0
- package/CLAUDE.md +435 -0
- package/README.md +404 -0
- package/TODO.md +655 -0
- package/dist/index.cjs +25108 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +25085 -0
- package/docs/building-libretro-cores-arm-mac.md +237 -0
- package/docs/config-file-format.md +488 -0
- package/docs/cores-trd.md +425 -0
- package/docs/headless-hardware-rendering-trd.md +676 -0
- package/docs/libretro-cores-trd.md +997 -0
- package/docs/mupen64-software-rendering-trd.md +751 -0
- package/docs/n64-support-trd.md +306 -0
- package/docs/native-rendering-trd.md +540 -0
- package/docs/native-ui-rendering-trd.md +1195 -0
- package/docs/netplay-trd.md +665 -0
- package/docs/retroarch-netplay-docs.md +277 -0
- package/docs/save-state-format.md +666 -0
- package/eslint.config.js +111 -0
- package/icon/icon.png +0 -0
- package/icon/icon.pxd +0 -0
- package/package.json +63 -0
- package/pnpm-workspace.yaml +10 -0
- package/src/Emulator/consts.ts +14 -0
- package/src/Emulator/index.ts +2496 -0
- package/src/Emulator/saveState/index.ts +155 -0
- package/src/Emulator/screenshot/index.ts +160 -0
- package/src/Emulator/terminalDimensions/index.ts +79 -0
- package/src/Emulator/types.ts +83 -0
- package/src/cli/commands/consts.ts +10 -0
- package/src/cli/commands/index.ts +462 -0
- package/src/cli/parseArgs/consts.ts +17 -0
- package/src/cli/parseArgs/index.ts +457 -0
- package/src/cli/parseArgs/types.ts +61 -0
- package/src/cli/runEmulator/index.ts +406 -0
- package/src/cli/runEmulator/types.ts +7 -0
- package/src/consts.ts +19 -0
- package/src/core/button/consts.ts +35 -0
- package/src/core/button/index.ts +123 -0
- package/src/core/core.ts +300 -0
- package/src/core/index.ts +19 -0
- package/src/cores/libretro/api/index.ts +334 -0
- package/src/cores/libretro/api/types.ts +148 -0
- package/src/cores/libretro/callbacks/consts.ts +41 -0
- package/src/cores/libretro/callbacks/index.ts +456 -0
- package/src/cores/libretro/consts.ts +45 -0
- package/src/cores/libretro/coreOptions/consts.ts +36 -0
- package/src/cores/libretro/coreOptions/index.ts +222 -0
- package/src/cores/libretro/environment/consts.ts +118 -0
- package/src/cores/libretro/environment/index.ts +1095 -0
- package/src/cores/libretro/index.ts +937 -0
- package/src/cores/libretro/loader/index.ts +496 -0
- package/src/cores/libretro/pixelFormat/consts.ts +43 -0
- package/src/cores/libretro/pixelFormat/index.ts +397 -0
- package/src/cores/libretro/types.ts +339 -0
- package/src/frontend/AudioManager/index.ts +420 -0
- package/src/frontend/SettingsManager/index.ts +250 -0
- package/src/frontend/config/index.ts +608 -0
- package/src/frontend/config/tests.ts +354 -0
- package/src/frontend/config/types.ts +36 -0
- package/src/frontend/consts.ts +114 -0
- package/src/frontend/coreBuilder/index.ts +644 -0
- package/src/frontend/coreBuilder/types.ts +15 -0
- package/src/frontend/coreDownloader/index.ts +620 -0
- package/src/frontend/coreDownloader/types.ts +17 -0
- package/src/frontend/corePreferences/index.ts +69 -0
- package/src/frontend/coreRegistry/index.ts +276 -0
- package/src/frontend/directoryCache/index.ts +75 -0
- package/src/frontend/index.ts +79 -0
- package/src/frontend/notifications/consts.ts +14 -0
- package/src/frontend/notifications/index.ts +250 -0
- package/src/frontend/playlist/consts.ts +168 -0
- package/src/frontend/playlist/index.ts +899 -0
- package/src/frontend/playlist/labelFormatter/consts.ts +15 -0
- package/src/frontend/playlist/labelFormatter/index.ts +155 -0
- package/src/frontend/playlist/labelFormatter/tests.ts +153 -0
- package/src/frontend/playlist/reader/index.ts +559 -0
- package/src/frontend/playlist/sync/index.ts +511 -0
- package/src/frontend/playlist/systemLookup/index.ts +233 -0
- package/src/frontend/playlist/utils/index.ts +50 -0
- package/src/frontend/romScanner/consts.ts +348 -0
- package/src/frontend/romScanner/index.ts +1957 -0
- package/src/frontend/saveServices/consts.ts +2 -0
- package/src/frontend/saveServices/index.ts +313 -0
- package/src/frontend/serviceProvider/index.ts +108 -0
- package/src/frontend/serviceProvider/types.ts +13 -0
- package/src/index.ts +428 -0
- package/src/input/Controller/consts.ts +50 -0
- package/src/input/Controller/index.ts +81 -0
- package/src/input/GamepadManager/consts.ts +22 -0
- package/src/input/GamepadManager/index.ts +418 -0
- package/src/input/InputManager/consts.ts +86 -0
- package/src/input/InputManager/index.ts +593 -0
- package/src/input/InputMapper/consts.ts +33 -0
- package/src/input/InputMapper/index.ts +436 -0
- package/src/input/consts.ts +410 -0
- package/src/input/gamepadProfiles/index.ts +1100 -0
- package/src/input/index.ts +38 -0
- package/src/input/inputUtils/index.ts +31 -0
- package/src/netplay/FrameBuffer/consts.ts +2 -0
- package/src/netplay/FrameBuffer/index.ts +364 -0
- package/src/netplay/FrameBuffer/tests.ts +286 -0
- package/src/netplay/InputBuffer/consts.ts +7 -0
- package/src/netplay/InputBuffer/index.ts +347 -0
- package/src/netplay/InputBuffer/tests.ts +166 -0
- package/src/netplay/NetplayClient/index.ts +976 -0
- package/src/netplay/NetplayConnection/index.ts +536 -0
- package/src/netplay/NetplayDiscovery/consts.ts +41 -0
- package/src/netplay/NetplayDiscovery/index.ts +525 -0
- package/src/netplay/NetplayServer/index.ts +1407 -0
- package/src/netplay/SyncManager/index.ts +984 -0
- package/src/netplay/SyncManager/tests.ts +419 -0
- package/src/netplay/consts.ts +371 -0
- package/src/netplay/crc32/consts.ts +14 -0
- package/src/netplay/crc32/index.ts +97 -0
- package/src/netplay/crc32/tests.ts +40 -0
- package/src/netplay/index.ts +41 -0
- package/src/netplay/netplayLogger/consts.ts +30 -0
- package/src/netplay/netplayLogger/index.ts +345 -0
- package/src/netplay/protocol/consts.ts +86 -0
- package/src/netplay/protocol/index.ts +1280 -0
- package/src/netplay/protocol/tests.ts +606 -0
- package/src/netplay/protocol/types.ts +20 -0
- package/src/netplay/types.ts +395 -0
- package/src/rendering/KittyRenderer/index.ts +616 -0
- package/src/rendering/NativeRenderer/index.ts +279 -0
- package/src/rendering/NativeRenderer/tests.ts +133 -0
- package/src/rendering/TerminalRenderer/index.ts +770 -0
- package/src/rendering/consts.ts +401 -0
- package/src/rendering/fonts/CozetteVector.ttf +0 -0
- package/src/rendering/index.ts +26 -0
- package/src/rendering/nativeUi/NativeWindowManager/index.ts +158 -0
- package/src/rendering/nativeUi/NativeWindowManager/tests.ts +81 -0
- package/src/rendering/nativeUi/consts.ts +6 -0
- package/src/rendering/nativeUi/index.ts +20 -0
- package/src/rendering/postProcessing/consts.ts +38 -0
- package/src/rendering/postProcessing/index.ts +923 -0
- package/src/rendering/shared/ansi/consts.ts +12 -0
- package/src/rendering/shared/ansi/index.ts +104 -0
- package/src/rendering/shared/consts.ts +2 -0
- package/src/rendering/shared/fitToTerminal/index.ts +67 -0
- package/src/ui/AddRomsPrompt/consts.ts +13 -0
- package/src/ui/AddRomsPrompt/index.tsx +781 -0
- package/src/ui/App/consts.ts +2 -0
- package/src/ui/App/index.tsx +456 -0
- package/src/ui/AppCapabilities/index.tsx +67 -0
- package/src/ui/ConfigContext/index.tsx +56 -0
- package/src/ui/CoreManager/consts.ts +11 -0
- package/src/ui/CoreManager/index.tsx +779 -0
- package/src/ui/CoreSelector/consts.ts +2 -0
- package/src/ui/CoreSelector/index.tsx +251 -0
- package/src/ui/DialogContainer/index.tsx +42 -0
- package/src/ui/DialogOptionsList/index.tsx +61 -0
- package/src/ui/DuplicateCrcPrompt/consts.ts +5 -0
- package/src/ui/DuplicateCrcPrompt/index.tsx +146 -0
- package/src/ui/GamepadContext/consts.ts +15 -0
- package/src/ui/GamepadContext/index.tsx +295 -0
- package/src/ui/NativeDialog/index.tsx +120 -0
- package/src/ui/NetplayDisconnectedDialog/index.tsx +93 -0
- package/src/ui/NetplayPauseMenu/consts.ts +2 -0
- package/src/ui/NetplayPauseMenu/index.tsx +97 -0
- package/src/ui/RomBrowser/NetplayPanel/consts.ts +24 -0
- package/src/ui/RomBrowser/NetplayPanel/index.tsx +520 -0
- package/src/ui/RomBrowser/SettingsPanel/index.tsx +478 -0
- package/src/ui/RomBrowser/consts.ts +61 -0
- package/src/ui/RomBrowser/index.tsx +1164 -0
- package/src/ui/RomBrowser/settingsConfig/index.ts +320 -0
- package/src/ui/RomBrowser/types.ts +67 -0
- package/src/ui/SaveStateDialog/consts.ts +2 -0
- package/src/ui/SaveStateDialog/index.tsx +225 -0
- package/src/ui/WarningDialog/index.tsx +113 -0
- package/src/ui/consts.ts +27 -0
- package/src/ui/hooks/useClearTerminal/consts.ts +2 -0
- package/src/ui/hooks/useClearTerminal/index.ts +37 -0
- package/src/ui/hooks/useDialogNavigation/index.ts +99 -0
- package/src/ui/hooks/useGamepad/consts.ts +21 -0
- package/src/ui/hooks/useGamepad/index.ts +194 -0
- package/src/ui/index.ts +27 -0
- package/src/utils/buffer/consts.ts +17 -0
- package/src/utils/buffer/index.ts +129 -0
- package/src/utils/color/consts.ts +58 -0
- package/src/utils/color/index.ts +183 -0
- package/src/utils/compression/consts.ts +50 -0
- package/src/utils/compression/index.ts +101 -0
- package/src/utils/consts.ts +2 -0
- package/src/utils/crc32/consts.ts +22 -0
- package/src/utils/crc32/index.ts +83 -0
- package/src/utils/ensureDirectory/index.ts +10 -0
- package/src/utils/format/consts.ts +8 -0
- package/src/utils/format/index.ts +53 -0
- package/src/utils/getErrorMessage/index.ts +10 -0
- package/src/utils/index.ts +113 -0
- package/src/utils/ini/index.ts +200 -0
- package/src/utils/kitty/consts.ts +13 -0
- package/src/utils/kitty/index.ts +181 -0
- package/src/utils/logger/consts.ts +35 -0
- package/src/utils/logger/index.ts +217 -0
- package/src/utils/paths/consts.ts +18 -0
- package/src/utils/paths/index.ts +151 -0
- package/src/utils/png/consts.ts +34 -0
- package/src/utils/png/index.ts +131 -0
- package/src/utils/readJsonFile/index.ts +16 -0
- package/src/utils/rotateLogFile/index.ts +44 -0
- package/src/utils/safeClose/index.ts +10 -0
- package/src/utils/terminal/consts.ts +8 -0
- package/src/utils/terminal/index.ts +102 -0
- package/src/utils/thumbnailRenderer/consts.ts +2 -0
- package/src/utils/thumbnailRenderer/index.ts +147 -0
- package/src/utils/typedError/index.ts +26 -0
- package/tsconfig.json +31 -0
- package/vitest.config.ts +13 -0
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audio Manager
|
|
3
|
+
*
|
|
4
|
+
* Handles audio output for emulator cores using RtAudio.
|
|
5
|
+
* This is a shared component that works with any core that produces audio samples.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { clamp } from 'remeda';
|
|
9
|
+
import pkg from 'audify';
|
|
10
|
+
const { RtAudio, RtAudioFormat } = pkg;
|
|
11
|
+
|
|
12
|
+
import type { AudioConfig } from '../../core/core';
|
|
13
|
+
import { logger } from '../../utils/logger';
|
|
14
|
+
import { getErrorMessage } from '../../utils/getErrorMessage';
|
|
15
|
+
import {
|
|
16
|
+
MAX_AUDIO_QUEUED_FRAMES,
|
|
17
|
+
AUDIO_FRAME_DURATION_SEC,
|
|
18
|
+
AUDIO_STEREO_CHANNELS,
|
|
19
|
+
BYTES_PER_INT16_SAMPLE,
|
|
20
|
+
AUDIO_RING_BUFFER_FRAMES,
|
|
21
|
+
INT16_MAX_VALUE,
|
|
22
|
+
SAMPLE_RATE_44100,
|
|
23
|
+
SAMPLE_RATE_48000,
|
|
24
|
+
FLOAT_COMPARE_EPSILON,
|
|
25
|
+
STEREO_NEXT_RIGHT_OFFSET,
|
|
26
|
+
BYTES_PER_STEREO_SAMPLE,
|
|
27
|
+
RTAUDIO_RECOVERABLE_ERROR_THRESHOLD,
|
|
28
|
+
AUDIO_RECOVERY_DELAY_MS,
|
|
29
|
+
} from '..';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Audio manager for emulator audio output
|
|
33
|
+
*/
|
|
34
|
+
export class AudioManager {
|
|
35
|
+
private rtAudio: InstanceType<typeof RtAudio> | null = null;
|
|
36
|
+
private config: AudioConfig;
|
|
37
|
+
private enabled: boolean = true;
|
|
38
|
+
private running: boolean = false;
|
|
39
|
+
|
|
40
|
+
// Sample rate tracking for resampling
|
|
41
|
+
private sourceSampleRate: number;
|
|
42
|
+
private outputSampleRate: number;
|
|
43
|
+
private resampleRatio: number = 1.0;
|
|
44
|
+
|
|
45
|
+
// Ring buffer for sample accumulation
|
|
46
|
+
private ringBuffer: Float32Array;
|
|
47
|
+
private ringBufferSize: number;
|
|
48
|
+
private ringWritePos: number = 0;
|
|
49
|
+
private ringReadPos: number = 0;
|
|
50
|
+
private ringCount: number = 0;
|
|
51
|
+
|
|
52
|
+
// Output buffer for RtAudio
|
|
53
|
+
private outputBuffer: Buffer;
|
|
54
|
+
private frameSize: number;
|
|
55
|
+
|
|
56
|
+
// Flow control
|
|
57
|
+
private framesWritten: number = 0;
|
|
58
|
+
private framesPlayed: number = 0;
|
|
59
|
+
private maxQueuedFrames: number = MAX_AUDIO_QUEUED_FRAMES;
|
|
60
|
+
|
|
61
|
+
// Error recovery
|
|
62
|
+
private isRecovering: boolean = false;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Create an audio manager for a core's audio output.
|
|
66
|
+
*
|
|
67
|
+
* @param config Audio configuration from the core
|
|
68
|
+
* @param enabled Whether audio is enabled (default: true)
|
|
69
|
+
*/
|
|
70
|
+
constructor(config: AudioConfig, enabled: boolean = true) {
|
|
71
|
+
this.config = config;
|
|
72
|
+
this.enabled = enabled;
|
|
73
|
+
|
|
74
|
+
// Track source sample rate for resampling
|
|
75
|
+
this.sourceSampleRate = config.sampleRate;
|
|
76
|
+
this.outputSampleRate = config.sampleRate;
|
|
77
|
+
|
|
78
|
+
// Frame size for audio buffer (~10ms at sample rate for low latency)
|
|
79
|
+
this.frameSize = Math.floor(config.sampleRate * AUDIO_FRAME_DURATION_SEC);
|
|
80
|
+
|
|
81
|
+
// Buffer size in bytes (16-bit stereo = 4 bytes per sample frame)
|
|
82
|
+
const frameBytes = this.frameSize * AUDIO_STEREO_CHANNELS * BYTES_PER_INT16_SAMPLE; // frameSize * 2 channels * 2 bytes
|
|
83
|
+
this.outputBuffer = Buffer.alloc(frameBytes);
|
|
84
|
+
|
|
85
|
+
// Fixed-size ring buffer for sample accumulation (prevents unbounded growth)
|
|
86
|
+
// Size: enough for ~100ms of audio (10 frames worth at 10ms each)
|
|
87
|
+
this.ringBufferSize = this.frameSize * AUDIO_RING_BUFFER_FRAMES;
|
|
88
|
+
this.ringBuffer = new Float32Array(this.ringBufferSize);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Start audio output
|
|
93
|
+
*/
|
|
94
|
+
start(): void {
|
|
95
|
+
if (!this.enabled || this.running) {return;}
|
|
96
|
+
|
|
97
|
+
// Try the core's native sample rate first, then fall back to common rates
|
|
98
|
+
const ratesToTry = [this.sourceSampleRate];
|
|
99
|
+
if (this.sourceSampleRate !== SAMPLE_RATE_44100) {ratesToTry.push(SAMPLE_RATE_44100);}
|
|
100
|
+
if (this.sourceSampleRate !== SAMPLE_RATE_48000) {ratesToTry.push(SAMPLE_RATE_48000);}
|
|
101
|
+
|
|
102
|
+
for (const rate of ratesToTry) {
|
|
103
|
+
try {
|
|
104
|
+
this.outputSampleRate = rate;
|
|
105
|
+
this.config.sampleRate = rate;
|
|
106
|
+
this.frameSize = Math.floor(rate * AUDIO_FRAME_DURATION_SEC);
|
|
107
|
+
this.ringBufferSize = this.frameSize * AUDIO_RING_BUFFER_FRAMES;
|
|
108
|
+
this.ringBuffer = new Float32Array(this.ringBufferSize);
|
|
109
|
+
const frameBytes = this.frameSize * AUDIO_STEREO_CHANNELS * BYTES_PER_INT16_SAMPLE;
|
|
110
|
+
this.outputBuffer = Buffer.alloc(frameBytes);
|
|
111
|
+
|
|
112
|
+
// Calculate resample ratio (source / output)
|
|
113
|
+
this.resampleRatio = this.sourceSampleRate / this.outputSampleRate;
|
|
114
|
+
|
|
115
|
+
this.createAudio();
|
|
116
|
+
this.running = true;
|
|
117
|
+
return;
|
|
118
|
+
} catch {
|
|
119
|
+
// Try next sample rate
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// All sample rates failed - disable audio gracefully
|
|
124
|
+
logger.error('Audio initialization failed for all sample rates. Continuing without audio...', 'Audio');
|
|
125
|
+
this.enabled = false;
|
|
126
|
+
this.rtAudio = null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Stop audio output
|
|
131
|
+
*/
|
|
132
|
+
stop(): void {
|
|
133
|
+
if (!this.running) {return;}
|
|
134
|
+
this.running = false;
|
|
135
|
+
|
|
136
|
+
if (this.rtAudio) {
|
|
137
|
+
try {
|
|
138
|
+
if (this.rtAudio.isStreamRunning()) {
|
|
139
|
+
this.rtAudio.stop();
|
|
140
|
+
}
|
|
141
|
+
if (this.rtAudio.isStreamOpen()) {
|
|
142
|
+
this.rtAudio.closeStream();
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
// Ignore cleanup errors
|
|
146
|
+
}
|
|
147
|
+
this.rtAudio = null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Push audio samples from the core.
|
|
153
|
+
* Call this from the core's audio callback.
|
|
154
|
+
*
|
|
155
|
+
* @param samples Float32Array of audio samples (mono or stereo depending on config)
|
|
156
|
+
*/
|
|
157
|
+
pushSamples(samples: Float32Array): void {
|
|
158
|
+
if (!this.rtAudio || !this.enabled || !this.running) {return;}
|
|
159
|
+
|
|
160
|
+
// If no resampling needed, add directly to ring buffer
|
|
161
|
+
if (Math.abs(this.resampleRatio - 1.0) < FLOAT_COMPARE_EPSILON) {
|
|
162
|
+
this.addSamplesToRingBuffer(samples);
|
|
163
|
+
} else {
|
|
164
|
+
// Resample using linear interpolation
|
|
165
|
+
this.resampleAndAddToRingBuffer(samples);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Write complete frames to RtAudio's queue
|
|
169
|
+
this.tryWriteFrames();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Add samples directly to ring buffer (no resampling)
|
|
174
|
+
*/
|
|
175
|
+
private addSamplesToRingBuffer(samples: Float32Array): void {
|
|
176
|
+
for (let i = 0; i < samples.length; i++) {
|
|
177
|
+
if (this.ringCount >= this.ringBufferSize) {
|
|
178
|
+
this.ringReadPos = (this.ringReadPos + 1) % this.ringBufferSize;
|
|
179
|
+
this.ringCount--;
|
|
180
|
+
}
|
|
181
|
+
this.ringBuffer[this.ringWritePos] = samples[i];
|
|
182
|
+
this.ringWritePos = (this.ringWritePos + 1) % this.ringBufferSize;
|
|
183
|
+
this.ringCount++;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Resample stereo audio using linear interpolation and add to ring buffer
|
|
189
|
+
*/
|
|
190
|
+
private resampleAndAddToRingBuffer(samples: Float32Array): void {
|
|
191
|
+
// Process stereo pairs (samples are interleaved L,R,L,R,...)
|
|
192
|
+
const numFrames = samples.length / 2;
|
|
193
|
+
|
|
194
|
+
// Generate output samples based on resample ratio
|
|
195
|
+
// ratio < 1 means we're upsampling (generating more samples)
|
|
196
|
+
// ratio > 1 means we're downsampling (generating fewer samples)
|
|
197
|
+
let srcPos = 0;
|
|
198
|
+
|
|
199
|
+
while (srcPos < numFrames - 1) {
|
|
200
|
+
const srcIdx = Math.floor(srcPos) * 2;
|
|
201
|
+
const frac = srcPos - Math.floor(srcPos);
|
|
202
|
+
|
|
203
|
+
// Get current and next stereo samples
|
|
204
|
+
const l0 = samples[srcIdx];
|
|
205
|
+
const r0 = samples[srcIdx + 1];
|
|
206
|
+
const l1 = samples[srcIdx + AUDIO_STEREO_CHANNELS] ?? l0;
|
|
207
|
+
const r1 = samples[srcIdx + STEREO_NEXT_RIGHT_OFFSET] ?? r0;
|
|
208
|
+
|
|
209
|
+
// Linear interpolation
|
|
210
|
+
const outL = l0 + (l1 - l0) * frac;
|
|
211
|
+
const outR = r0 + (r1 - r0) * frac;
|
|
212
|
+
|
|
213
|
+
// Add to ring buffer
|
|
214
|
+
if (this.ringCount >= this.ringBufferSize - 1) {
|
|
215
|
+
this.ringReadPos = (this.ringReadPos + 2) % this.ringBufferSize;
|
|
216
|
+
this.ringCount -= 2;
|
|
217
|
+
}
|
|
218
|
+
this.ringBuffer[this.ringWritePos] = outL;
|
|
219
|
+
this.ringWritePos = (this.ringWritePos + 1) % this.ringBufferSize;
|
|
220
|
+
this.ringBuffer[this.ringWritePos] = outR;
|
|
221
|
+
this.ringWritePos = (this.ringWritePos + 1) % this.ringBufferSize;
|
|
222
|
+
this.ringCount += 2;
|
|
223
|
+
|
|
224
|
+
// Advance source position by resample ratio
|
|
225
|
+
srcPos += this.resampleRatio;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Check if audio is enabled
|
|
231
|
+
*/
|
|
232
|
+
isEnabled(): boolean {
|
|
233
|
+
return this.enabled;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Set audio enabled state
|
|
238
|
+
*/
|
|
239
|
+
setEnabled(enabled: boolean): void {
|
|
240
|
+
this.enabled = enabled;
|
|
241
|
+
if (!enabled && this.running) {
|
|
242
|
+
this.stop();
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Get the sample rate
|
|
248
|
+
*/
|
|
249
|
+
getSampleRate(): number {
|
|
250
|
+
return this.config.sampleRate;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Create or recreate the RtAudio instance
|
|
255
|
+
*/
|
|
256
|
+
private createAudio(): void {
|
|
257
|
+
if (this.rtAudio) {
|
|
258
|
+
try {
|
|
259
|
+
if (this.rtAudio.isStreamRunning()) {
|
|
260
|
+
this.rtAudio.stop();
|
|
261
|
+
}
|
|
262
|
+
if (this.rtAudio.isStreamOpen()) {
|
|
263
|
+
this.rtAudio.closeStream();
|
|
264
|
+
}
|
|
265
|
+
} catch {
|
|
266
|
+
// Ignore cleanup errors
|
|
267
|
+
}
|
|
268
|
+
this.rtAudio = null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
this.rtAudio = new RtAudio();
|
|
273
|
+
} catch (err) {
|
|
274
|
+
throw new Error(`Failed to create RtAudio: ${getErrorMessage(err)}`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Frame output callback - called when a frame finishes playing
|
|
278
|
+
const onFramePlayed = () => {
|
|
279
|
+
this.framesPlayed++;
|
|
280
|
+
// Opportunistically write more frames when playback creates room
|
|
281
|
+
this.tryWriteFrames();
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
// Error callback for graceful error recovery
|
|
285
|
+
const onAudioError = (type: number, msg: string) => {
|
|
286
|
+
// Ignore DEBUG_WARNING level (type 1) - these are informational messages about
|
|
287
|
+
// internal RtAudio state (e.g., "no open stream to close") that aren't actionable.
|
|
288
|
+
// Check this FIRST before any other conditions to ensure we never log these.
|
|
289
|
+
if (type === 1) {return;}
|
|
290
|
+
|
|
291
|
+
// Don't process errors if we're shutting down
|
|
292
|
+
if (!this.running) {return;}
|
|
293
|
+
|
|
294
|
+
// Log error for debugging
|
|
295
|
+
const errorTypes = [
|
|
296
|
+
'WARNING',
|
|
297
|
+
'DEBUG_WARNING',
|
|
298
|
+
'UNSPECIFIED',
|
|
299
|
+
'NO_DEVICES_FOUND',
|
|
300
|
+
'INVALID_DEVICE',
|
|
301
|
+
'MEMORY_ERROR',
|
|
302
|
+
'INVALID_PARAMETER',
|
|
303
|
+
'INVALID_USE',
|
|
304
|
+
'DRIVER_ERROR',
|
|
305
|
+
'SYSTEM_ERROR',
|
|
306
|
+
'THREAD_ERROR',
|
|
307
|
+
];
|
|
308
|
+
const typeName = errorTypes[type] || `UNKNOWN(${type})`;
|
|
309
|
+
logger.error(`Audio error [${typeName}]: ${msg}`, 'Audio');
|
|
310
|
+
|
|
311
|
+
// Attempt recovery for recoverable errors
|
|
312
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- running can change asynchronously
|
|
313
|
+
if (!this.isRecovering && this.running && type >= RTAUDIO_RECOVERABLE_ERROR_THRESHOLD) {
|
|
314
|
+
this.isRecovering = true;
|
|
315
|
+
setTimeout(() => {
|
|
316
|
+
if (this.running) {
|
|
317
|
+
try {
|
|
318
|
+
this.createAudio();
|
|
319
|
+
} catch {
|
|
320
|
+
// If recreation fails, disable audio
|
|
321
|
+
this.enabled = false;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
this.isRecovering = false;
|
|
325
|
+
}, AUDIO_RECOVERY_DELAY_MS);
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
// Open output-only stream (stereo for proper speaker output)
|
|
330
|
+
try {
|
|
331
|
+
this.rtAudio.openStream(
|
|
332
|
+
{
|
|
333
|
+
deviceId: this.rtAudio.getDefaultOutputDevice(),
|
|
334
|
+
nChannels: 2, // Always output stereo
|
|
335
|
+
firstChannel: 0,
|
|
336
|
+
},
|
|
337
|
+
null, // No input
|
|
338
|
+
RtAudioFormat.RTAUDIO_SINT16,
|
|
339
|
+
this.config.sampleRate,
|
|
340
|
+
this.frameSize,
|
|
341
|
+
'emoemu',
|
|
342
|
+
null, // No input callback
|
|
343
|
+
onFramePlayed, // Frame output callback for flow control
|
|
344
|
+
0 as unknown as undefined, // Default flags - runtime expects number, types expect undefined
|
|
345
|
+
onAudioError // Error callback for graceful recovery
|
|
346
|
+
);
|
|
347
|
+
} catch (err) {
|
|
348
|
+
// openStream failed - null out rtAudio so next retry doesn't try to close non-existent stream
|
|
349
|
+
this.rtAudio = null;
|
|
350
|
+
throw err;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
this.rtAudio.start();
|
|
354
|
+
|
|
355
|
+
// Reset state on audio recreation
|
|
356
|
+
this.ringWritePos = 0;
|
|
357
|
+
this.ringReadPos = 0;
|
|
358
|
+
this.ringCount = 0;
|
|
359
|
+
this.framesWritten = 0;
|
|
360
|
+
this.framesPlayed = 0;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Write a single frame to RtAudio from ring buffer
|
|
365
|
+
*/
|
|
366
|
+
private writeFrame(): boolean {
|
|
367
|
+
if (!this.rtAudio || this.ringCount < this.frameSize) {return false;}
|
|
368
|
+
|
|
369
|
+
// Flow control: don't queue too many frames ahead
|
|
370
|
+
const queuedFrames = this.framesWritten - this.framesPlayed;
|
|
371
|
+
if (queuedFrames >= this.maxQueuedFrames) {
|
|
372
|
+
return false; // Wait for playback to catch up
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Convert float samples to int16 stereo in output buffer
|
|
376
|
+
// For mono input, duplicate to both channels
|
|
377
|
+
// For stereo input, samples are already interleaved
|
|
378
|
+
if (this.config.channels === 1) {
|
|
379
|
+
// Mono: duplicate to both channels
|
|
380
|
+
for (let i = 0; i < this.frameSize; i++) {
|
|
381
|
+
const sample = clamp(this.ringBuffer[this.ringReadPos], { min: -1, max: 1 });
|
|
382
|
+
const int16 = (sample * INT16_MAX_VALUE) | 0;
|
|
383
|
+
const offset = i * BYTES_PER_STEREO_SAMPLE; // 4 bytes per stereo sample
|
|
384
|
+
this.outputBuffer.writeInt16LE(int16, offset); // Left
|
|
385
|
+
this.outputBuffer.writeInt16LE(int16, offset + BYTES_PER_INT16_SAMPLE); // Right
|
|
386
|
+
this.ringReadPos = (this.ringReadPos + 1) % this.ringBufferSize;
|
|
387
|
+
}
|
|
388
|
+
this.ringCount -= this.frameSize;
|
|
389
|
+
} else {
|
|
390
|
+
// Stereo: samples are interleaved L,R,L,R,...
|
|
391
|
+
const stereoFrameSize = this.frameSize * AUDIO_STEREO_CHANNELS;
|
|
392
|
+
for (let i = 0; i < this.frameSize; i++) {
|
|
393
|
+
const leftSample = clamp(this.ringBuffer[this.ringReadPos], { min: -1, max: 1 });
|
|
394
|
+
this.ringReadPos = (this.ringReadPos + 1) % this.ringBufferSize;
|
|
395
|
+
const rightSample = clamp(this.ringBuffer[this.ringReadPos], { min: -1, max: 1 });
|
|
396
|
+
this.ringReadPos = (this.ringReadPos + 1) % this.ringBufferSize;
|
|
397
|
+
|
|
398
|
+
const offset = i * BYTES_PER_STEREO_SAMPLE;
|
|
399
|
+
this.outputBuffer.writeInt16LE((leftSample * INT16_MAX_VALUE) | 0, offset);
|
|
400
|
+
this.outputBuffer.writeInt16LE((rightSample * INT16_MAX_VALUE) | 0, offset + BYTES_PER_INT16_SAMPLE);
|
|
401
|
+
}
|
|
402
|
+
this.ringCount -= stereoFrameSize;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
this.rtAudio.write(this.outputBuffer);
|
|
406
|
+
this.framesWritten++;
|
|
407
|
+
return true;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Try to write all available frames to RtAudio's queue
|
|
412
|
+
*/
|
|
413
|
+
private tryWriteFrames(): void {
|
|
414
|
+
const samplesPerFrame =
|
|
415
|
+
this.config.channels === 1 ? this.frameSize : this.frameSize * 2;
|
|
416
|
+
while (this.ringCount >= samplesPerFrame && this.writeFrame()) {
|
|
417
|
+
// Keep writing until buffer is drained or queue is full
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized Settings Manager
|
|
3
|
+
*
|
|
4
|
+
* Provides a single source of truth for runtime settings that need to:
|
|
5
|
+
* 1. Be toggled during gameplay (e.g., M key for audio mute)
|
|
6
|
+
* 2. Be displayed/edited in the settings UI
|
|
7
|
+
* 3. Persist to the config file
|
|
8
|
+
* 4. Apply changes immediately (e.g., stop/start audio)
|
|
9
|
+
*
|
|
10
|
+
* This replaces the scattered one-off code for each setting with a consistent pattern.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Config, PostProcessingMode } from '../config';
|
|
14
|
+
import { updateConfigValue } from '../config';
|
|
15
|
+
import { logger } from '../../utils/logger';
|
|
16
|
+
import { getErrorMessage } from '../../utils/getErrorMessage';
|
|
17
|
+
|
|
18
|
+
/** Render mode for the emulator display */
|
|
19
|
+
export type RenderMode = 'native' | 'kitty' | 'terminal' | 'ascii' | 'emoji';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Runtime settings that can be toggled during gameplay and synced with config.
|
|
23
|
+
* These are the "live" settings that affect current emulator behavior.
|
|
24
|
+
*/
|
|
25
|
+
export interface RuntimeSettings {
|
|
26
|
+
/** Whether audio is currently muted */
|
|
27
|
+
audioMuted: boolean;
|
|
28
|
+
/** Current render mode (null = auto, use system-specific default) */
|
|
29
|
+
renderMode: RenderMode | null;
|
|
30
|
+
/** Current post-processing mode */
|
|
31
|
+
postProcessingMode: PostProcessingMode;
|
|
32
|
+
/** Whether to show the status bar */
|
|
33
|
+
showStatusBar: boolean;
|
|
34
|
+
/** Whether gamepad input is enabled */
|
|
35
|
+
gamepadEnabled: boolean;
|
|
36
|
+
/** Frame limit value (0=off, or FPS limit like 30, 60) */
|
|
37
|
+
frameLimit: number;
|
|
38
|
+
/** Menu scale factor (null = auto-detect) */
|
|
39
|
+
menuScaleFactor: number | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Keys of RuntimeSettings that are boolean (for toggle) */
|
|
43
|
+
type BooleanSettingKey = { [K in keyof RuntimeSettings]: RuntimeSettings[K] extends boolean ? K : never }[keyof RuntimeSettings];
|
|
44
|
+
|
|
45
|
+
/** Callback type for setting change listeners */
|
|
46
|
+
type SettingChangeCallback<T> = (newValue: T, oldValue: T) => void;
|
|
47
|
+
|
|
48
|
+
/** Maps runtime setting keys to their config key equivalents */
|
|
49
|
+
const SETTING_TO_CONFIG_KEY: Record<keyof RuntimeSettings, keyof Config> = {
|
|
50
|
+
audioMuted: 'audio_mute_enable',
|
|
51
|
+
renderMode: 'video_driver',
|
|
52
|
+
postProcessingMode: 'video_postprocessing_mode',
|
|
53
|
+
showStatusBar: 'fps_show_enable',
|
|
54
|
+
gamepadEnabled: 'input_joypad_enable',
|
|
55
|
+
frameLimit: 'video_frame_limit',
|
|
56
|
+
menuScaleFactor: 'menu_scale_factor',
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Centralized manager for runtime settings.
|
|
61
|
+
*
|
|
62
|
+
* Usage:
|
|
63
|
+
* ```typescript
|
|
64
|
+
* const settings = new SettingsManager(config, configPath);
|
|
65
|
+
*
|
|
66
|
+
* // Register listener for changes (e.g., to stop/start audio)
|
|
67
|
+
* settings.onChange('audioMuted', (muted) => {
|
|
68
|
+
* if (muted) stopAudio();
|
|
69
|
+
* else startAudio();
|
|
70
|
+
* });
|
|
71
|
+
*
|
|
72
|
+
* // Toggle a setting (persists to config and notifies listeners)
|
|
73
|
+
* settings.set('audioMuted', !settings.get('audioMuted'));
|
|
74
|
+
*
|
|
75
|
+
* // Cycle through options
|
|
76
|
+
* settings.cycle('renderMode', ['kitty', 'terminal', 'ascii', 'emoji']);
|
|
77
|
+
* ```
|
|
78
|
+
*/
|
|
79
|
+
export class SettingsManager {
|
|
80
|
+
private settings: RuntimeSettings;
|
|
81
|
+
private config: Config;
|
|
82
|
+
private configPath?: string;
|
|
83
|
+
private listeners: Map<keyof RuntimeSettings, Set<SettingChangeCallback<unknown>>>;
|
|
84
|
+
|
|
85
|
+
constructor(config: Config, configPath?: string) {
|
|
86
|
+
this.config = config;
|
|
87
|
+
this.configPath = configPath;
|
|
88
|
+
this.listeners = new Map();
|
|
89
|
+
|
|
90
|
+
// Initialize runtime settings from config
|
|
91
|
+
this.settings = {
|
|
92
|
+
audioMuted: config.audio_mute_enable,
|
|
93
|
+
renderMode: config.video_driver,
|
|
94
|
+
postProcessingMode: config.video_postprocessing_mode,
|
|
95
|
+
showStatusBar: config.fps_show_enable,
|
|
96
|
+
gamepadEnabled: config.input_joypad_enable,
|
|
97
|
+
frameLimit: config.video_frame_limit,
|
|
98
|
+
menuScaleFactor: config.menu_scale_factor,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Get the current value of a setting.
|
|
104
|
+
*/
|
|
105
|
+
get<K extends keyof RuntimeSettings>(key: K): RuntimeSettings[K] {
|
|
106
|
+
return this.settings[key];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Set a setting value, persist to config, and notify listeners.
|
|
111
|
+
*/
|
|
112
|
+
set<K extends keyof RuntimeSettings>(key: K, value: RuntimeSettings[K]): void {
|
|
113
|
+
const oldValue = this.settings[key];
|
|
114
|
+
if (oldValue === value) {
|
|
115
|
+
return; // No change
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Update runtime state
|
|
119
|
+
this.settings[key] = value;
|
|
120
|
+
|
|
121
|
+
// Persist to config file
|
|
122
|
+
this.persistToConfig(key, value);
|
|
123
|
+
|
|
124
|
+
// Notify listeners
|
|
125
|
+
this.notifyListeners(key, value, oldValue);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Toggle a boolean setting.
|
|
130
|
+
*/
|
|
131
|
+
toggle(key: BooleanSettingKey): void {
|
|
132
|
+
this.set(key, !this.settings[key]);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Cycle through a list of values for a setting.
|
|
137
|
+
* Returns the new value.
|
|
138
|
+
*/
|
|
139
|
+
cycle<K extends keyof RuntimeSettings>(
|
|
140
|
+
key: K,
|
|
141
|
+
values: RuntimeSettings[K][]
|
|
142
|
+
): RuntimeSettings[K] {
|
|
143
|
+
const current = this.settings[key];
|
|
144
|
+
const currentIndex = values.indexOf(current);
|
|
145
|
+
const nextIndex = (currentIndex + 1) % values.length;
|
|
146
|
+
const nextValue = values[nextIndex];
|
|
147
|
+
this.set(key, nextValue);
|
|
148
|
+
return nextValue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Register a callback to be called when a setting changes.
|
|
153
|
+
* Returns an unsubscribe function.
|
|
154
|
+
*/
|
|
155
|
+
onChange<K extends keyof RuntimeSettings>(
|
|
156
|
+
key: K,
|
|
157
|
+
callback: SettingChangeCallback<RuntimeSettings[K]>
|
|
158
|
+
): () => void {
|
|
159
|
+
if (!this.listeners.has(key)) {
|
|
160
|
+
this.listeners.set(key, new Set());
|
|
161
|
+
}
|
|
162
|
+
const callbacks = this.listeners.get(key)!;
|
|
163
|
+
callbacks.add(callback as SettingChangeCallback<unknown>);
|
|
164
|
+
|
|
165
|
+
// Return unsubscribe function
|
|
166
|
+
return () => {
|
|
167
|
+
callbacks.delete(callback as SettingChangeCallback<unknown>);
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Get all current settings (for passing to components that need the full state).
|
|
173
|
+
*/
|
|
174
|
+
getAll(): Readonly<RuntimeSettings> {
|
|
175
|
+
return { ...this.settings };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Reload settings from config (e.g., after config file changes externally).
|
|
180
|
+
*/
|
|
181
|
+
reloadFromConfig(config: Config): void {
|
|
182
|
+
this.config = config;
|
|
183
|
+
|
|
184
|
+
// Update each setting, triggering listeners if values changed
|
|
185
|
+
this.set('audioMuted', config.audio_mute_enable);
|
|
186
|
+
this.set('renderMode', config.video_driver);
|
|
187
|
+
this.set('postProcessingMode', config.video_postprocessing_mode);
|
|
188
|
+
this.set('showStatusBar', config.fps_show_enable);
|
|
189
|
+
this.set('gamepadEnabled', config.input_joypad_enable);
|
|
190
|
+
this.set('frameLimit', config.video_frame_limit);
|
|
191
|
+
this.set('menuScaleFactor', config.menu_scale_factor);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Get the underlying config object (for settings not managed by this class).
|
|
196
|
+
*/
|
|
197
|
+
getConfig(): Config {
|
|
198
|
+
return this.config;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Get the config file path.
|
|
203
|
+
*/
|
|
204
|
+
getConfigPath(): string | undefined {
|
|
205
|
+
return this.configPath;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Persist a setting value to the config file.
|
|
210
|
+
*/
|
|
211
|
+
private persistToConfig<K extends keyof RuntimeSettings>(
|
|
212
|
+
key: K,
|
|
213
|
+
value: RuntimeSettings[K]
|
|
214
|
+
): void {
|
|
215
|
+
const configKey = SETTING_TO_CONFIG_KEY[key];
|
|
216
|
+
|
|
217
|
+
// Update the in-memory config object
|
|
218
|
+
// TypeScript can't verify the relationship between RuntimeSettings and Config keys
|
|
219
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
220
|
+
(this.config as any)[configKey] = value;
|
|
221
|
+
|
|
222
|
+
// Persist to file
|
|
223
|
+
try {
|
|
224
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
|
225
|
+
updateConfigValue(configKey, value as any, this.configPath);
|
|
226
|
+
} catch {
|
|
227
|
+
// Silently ignore config save errors during gameplay
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Notify all listeners for a setting that it has changed.
|
|
233
|
+
*/
|
|
234
|
+
private notifyListeners<K extends keyof RuntimeSettings>(
|
|
235
|
+
key: K,
|
|
236
|
+
newValue: RuntimeSettings[K],
|
|
237
|
+
oldValue: RuntimeSettings[K]
|
|
238
|
+
): void {
|
|
239
|
+
const callbacks = this.listeners.get(key);
|
|
240
|
+
if (callbacks) {
|
|
241
|
+
for (const callback of callbacks) {
|
|
242
|
+
try {
|
|
243
|
+
callback(newValue, oldValue);
|
|
244
|
+
} catch (err) {
|
|
245
|
+
logger.error(`Error in settings change listener for '${key}': ${getErrorMessage(err)}`, 'Settings');
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|