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.
Files changed (213) hide show
  1. package/.claude/settings.local.json +77 -0
  2. package/.node-version +1 -0
  3. package/CLAUDE.md +435 -0
  4. package/README.md +404 -0
  5. package/TODO.md +655 -0
  6. package/dist/index.cjs +25108 -0
  7. package/dist/index.d.ts +1 -0
  8. package/dist/index.js +25085 -0
  9. package/docs/building-libretro-cores-arm-mac.md +237 -0
  10. package/docs/config-file-format.md +488 -0
  11. package/docs/cores-trd.md +425 -0
  12. package/docs/headless-hardware-rendering-trd.md +676 -0
  13. package/docs/libretro-cores-trd.md +997 -0
  14. package/docs/mupen64-software-rendering-trd.md +751 -0
  15. package/docs/n64-support-trd.md +306 -0
  16. package/docs/native-rendering-trd.md +540 -0
  17. package/docs/native-ui-rendering-trd.md +1195 -0
  18. package/docs/netplay-trd.md +665 -0
  19. package/docs/retroarch-netplay-docs.md +277 -0
  20. package/docs/save-state-format.md +666 -0
  21. package/eslint.config.js +111 -0
  22. package/icon/icon.png +0 -0
  23. package/icon/icon.pxd +0 -0
  24. package/package.json +63 -0
  25. package/pnpm-workspace.yaml +10 -0
  26. package/src/Emulator/consts.ts +14 -0
  27. package/src/Emulator/index.ts +2496 -0
  28. package/src/Emulator/saveState/index.ts +155 -0
  29. package/src/Emulator/screenshot/index.ts +160 -0
  30. package/src/Emulator/terminalDimensions/index.ts +79 -0
  31. package/src/Emulator/types.ts +83 -0
  32. package/src/cli/commands/consts.ts +10 -0
  33. package/src/cli/commands/index.ts +462 -0
  34. package/src/cli/parseArgs/consts.ts +17 -0
  35. package/src/cli/parseArgs/index.ts +457 -0
  36. package/src/cli/parseArgs/types.ts +61 -0
  37. package/src/cli/runEmulator/index.ts +406 -0
  38. package/src/cli/runEmulator/types.ts +7 -0
  39. package/src/consts.ts +19 -0
  40. package/src/core/button/consts.ts +35 -0
  41. package/src/core/button/index.ts +123 -0
  42. package/src/core/core.ts +300 -0
  43. package/src/core/index.ts +19 -0
  44. package/src/cores/libretro/api/index.ts +334 -0
  45. package/src/cores/libretro/api/types.ts +148 -0
  46. package/src/cores/libretro/callbacks/consts.ts +41 -0
  47. package/src/cores/libretro/callbacks/index.ts +456 -0
  48. package/src/cores/libretro/consts.ts +45 -0
  49. package/src/cores/libretro/coreOptions/consts.ts +36 -0
  50. package/src/cores/libretro/coreOptions/index.ts +222 -0
  51. package/src/cores/libretro/environment/consts.ts +118 -0
  52. package/src/cores/libretro/environment/index.ts +1095 -0
  53. package/src/cores/libretro/index.ts +937 -0
  54. package/src/cores/libretro/loader/index.ts +496 -0
  55. package/src/cores/libretro/pixelFormat/consts.ts +43 -0
  56. package/src/cores/libretro/pixelFormat/index.ts +397 -0
  57. package/src/cores/libretro/types.ts +339 -0
  58. package/src/frontend/AudioManager/index.ts +420 -0
  59. package/src/frontend/SettingsManager/index.ts +250 -0
  60. package/src/frontend/config/index.ts +608 -0
  61. package/src/frontend/config/tests.ts +354 -0
  62. package/src/frontend/config/types.ts +36 -0
  63. package/src/frontend/consts.ts +114 -0
  64. package/src/frontend/coreBuilder/index.ts +644 -0
  65. package/src/frontend/coreBuilder/types.ts +15 -0
  66. package/src/frontend/coreDownloader/index.ts +620 -0
  67. package/src/frontend/coreDownloader/types.ts +17 -0
  68. package/src/frontend/corePreferences/index.ts +69 -0
  69. package/src/frontend/coreRegistry/index.ts +276 -0
  70. package/src/frontend/directoryCache/index.ts +75 -0
  71. package/src/frontend/index.ts +79 -0
  72. package/src/frontend/notifications/consts.ts +14 -0
  73. package/src/frontend/notifications/index.ts +250 -0
  74. package/src/frontend/playlist/consts.ts +168 -0
  75. package/src/frontend/playlist/index.ts +899 -0
  76. package/src/frontend/playlist/labelFormatter/consts.ts +15 -0
  77. package/src/frontend/playlist/labelFormatter/index.ts +155 -0
  78. package/src/frontend/playlist/labelFormatter/tests.ts +153 -0
  79. package/src/frontend/playlist/reader/index.ts +559 -0
  80. package/src/frontend/playlist/sync/index.ts +511 -0
  81. package/src/frontend/playlist/systemLookup/index.ts +233 -0
  82. package/src/frontend/playlist/utils/index.ts +50 -0
  83. package/src/frontend/romScanner/consts.ts +348 -0
  84. package/src/frontend/romScanner/index.ts +1957 -0
  85. package/src/frontend/saveServices/consts.ts +2 -0
  86. package/src/frontend/saveServices/index.ts +313 -0
  87. package/src/frontend/serviceProvider/index.ts +108 -0
  88. package/src/frontend/serviceProvider/types.ts +13 -0
  89. package/src/index.ts +428 -0
  90. package/src/input/Controller/consts.ts +50 -0
  91. package/src/input/Controller/index.ts +81 -0
  92. package/src/input/GamepadManager/consts.ts +22 -0
  93. package/src/input/GamepadManager/index.ts +418 -0
  94. package/src/input/InputManager/consts.ts +86 -0
  95. package/src/input/InputManager/index.ts +593 -0
  96. package/src/input/InputMapper/consts.ts +33 -0
  97. package/src/input/InputMapper/index.ts +436 -0
  98. package/src/input/consts.ts +410 -0
  99. package/src/input/gamepadProfiles/index.ts +1100 -0
  100. package/src/input/index.ts +38 -0
  101. package/src/input/inputUtils/index.ts +31 -0
  102. package/src/netplay/FrameBuffer/consts.ts +2 -0
  103. package/src/netplay/FrameBuffer/index.ts +364 -0
  104. package/src/netplay/FrameBuffer/tests.ts +286 -0
  105. package/src/netplay/InputBuffer/consts.ts +7 -0
  106. package/src/netplay/InputBuffer/index.ts +347 -0
  107. package/src/netplay/InputBuffer/tests.ts +166 -0
  108. package/src/netplay/NetplayClient/index.ts +976 -0
  109. package/src/netplay/NetplayConnection/index.ts +536 -0
  110. package/src/netplay/NetplayDiscovery/consts.ts +41 -0
  111. package/src/netplay/NetplayDiscovery/index.ts +525 -0
  112. package/src/netplay/NetplayServer/index.ts +1407 -0
  113. package/src/netplay/SyncManager/index.ts +984 -0
  114. package/src/netplay/SyncManager/tests.ts +419 -0
  115. package/src/netplay/consts.ts +371 -0
  116. package/src/netplay/crc32/consts.ts +14 -0
  117. package/src/netplay/crc32/index.ts +97 -0
  118. package/src/netplay/crc32/tests.ts +40 -0
  119. package/src/netplay/index.ts +41 -0
  120. package/src/netplay/netplayLogger/consts.ts +30 -0
  121. package/src/netplay/netplayLogger/index.ts +345 -0
  122. package/src/netplay/protocol/consts.ts +86 -0
  123. package/src/netplay/protocol/index.ts +1280 -0
  124. package/src/netplay/protocol/tests.ts +606 -0
  125. package/src/netplay/protocol/types.ts +20 -0
  126. package/src/netplay/types.ts +395 -0
  127. package/src/rendering/KittyRenderer/index.ts +616 -0
  128. package/src/rendering/NativeRenderer/index.ts +279 -0
  129. package/src/rendering/NativeRenderer/tests.ts +133 -0
  130. package/src/rendering/TerminalRenderer/index.ts +770 -0
  131. package/src/rendering/consts.ts +401 -0
  132. package/src/rendering/fonts/CozetteVector.ttf +0 -0
  133. package/src/rendering/index.ts +26 -0
  134. package/src/rendering/nativeUi/NativeWindowManager/index.ts +158 -0
  135. package/src/rendering/nativeUi/NativeWindowManager/tests.ts +81 -0
  136. package/src/rendering/nativeUi/consts.ts +6 -0
  137. package/src/rendering/nativeUi/index.ts +20 -0
  138. package/src/rendering/postProcessing/consts.ts +38 -0
  139. package/src/rendering/postProcessing/index.ts +923 -0
  140. package/src/rendering/shared/ansi/consts.ts +12 -0
  141. package/src/rendering/shared/ansi/index.ts +104 -0
  142. package/src/rendering/shared/consts.ts +2 -0
  143. package/src/rendering/shared/fitToTerminal/index.ts +67 -0
  144. package/src/ui/AddRomsPrompt/consts.ts +13 -0
  145. package/src/ui/AddRomsPrompt/index.tsx +781 -0
  146. package/src/ui/App/consts.ts +2 -0
  147. package/src/ui/App/index.tsx +456 -0
  148. package/src/ui/AppCapabilities/index.tsx +67 -0
  149. package/src/ui/ConfigContext/index.tsx +56 -0
  150. package/src/ui/CoreManager/consts.ts +11 -0
  151. package/src/ui/CoreManager/index.tsx +779 -0
  152. package/src/ui/CoreSelector/consts.ts +2 -0
  153. package/src/ui/CoreSelector/index.tsx +251 -0
  154. package/src/ui/DialogContainer/index.tsx +42 -0
  155. package/src/ui/DialogOptionsList/index.tsx +61 -0
  156. package/src/ui/DuplicateCrcPrompt/consts.ts +5 -0
  157. package/src/ui/DuplicateCrcPrompt/index.tsx +146 -0
  158. package/src/ui/GamepadContext/consts.ts +15 -0
  159. package/src/ui/GamepadContext/index.tsx +295 -0
  160. package/src/ui/NativeDialog/index.tsx +120 -0
  161. package/src/ui/NetplayDisconnectedDialog/index.tsx +93 -0
  162. package/src/ui/NetplayPauseMenu/consts.ts +2 -0
  163. package/src/ui/NetplayPauseMenu/index.tsx +97 -0
  164. package/src/ui/RomBrowser/NetplayPanel/consts.ts +24 -0
  165. package/src/ui/RomBrowser/NetplayPanel/index.tsx +520 -0
  166. package/src/ui/RomBrowser/SettingsPanel/index.tsx +478 -0
  167. package/src/ui/RomBrowser/consts.ts +61 -0
  168. package/src/ui/RomBrowser/index.tsx +1164 -0
  169. package/src/ui/RomBrowser/settingsConfig/index.ts +320 -0
  170. package/src/ui/RomBrowser/types.ts +67 -0
  171. package/src/ui/SaveStateDialog/consts.ts +2 -0
  172. package/src/ui/SaveStateDialog/index.tsx +225 -0
  173. package/src/ui/WarningDialog/index.tsx +113 -0
  174. package/src/ui/consts.ts +27 -0
  175. package/src/ui/hooks/useClearTerminal/consts.ts +2 -0
  176. package/src/ui/hooks/useClearTerminal/index.ts +37 -0
  177. package/src/ui/hooks/useDialogNavigation/index.ts +99 -0
  178. package/src/ui/hooks/useGamepad/consts.ts +21 -0
  179. package/src/ui/hooks/useGamepad/index.ts +194 -0
  180. package/src/ui/index.ts +27 -0
  181. package/src/utils/buffer/consts.ts +17 -0
  182. package/src/utils/buffer/index.ts +129 -0
  183. package/src/utils/color/consts.ts +58 -0
  184. package/src/utils/color/index.ts +183 -0
  185. package/src/utils/compression/consts.ts +50 -0
  186. package/src/utils/compression/index.ts +101 -0
  187. package/src/utils/consts.ts +2 -0
  188. package/src/utils/crc32/consts.ts +22 -0
  189. package/src/utils/crc32/index.ts +83 -0
  190. package/src/utils/ensureDirectory/index.ts +10 -0
  191. package/src/utils/format/consts.ts +8 -0
  192. package/src/utils/format/index.ts +53 -0
  193. package/src/utils/getErrorMessage/index.ts +10 -0
  194. package/src/utils/index.ts +113 -0
  195. package/src/utils/ini/index.ts +200 -0
  196. package/src/utils/kitty/consts.ts +13 -0
  197. package/src/utils/kitty/index.ts +181 -0
  198. package/src/utils/logger/consts.ts +35 -0
  199. package/src/utils/logger/index.ts +217 -0
  200. package/src/utils/paths/consts.ts +18 -0
  201. package/src/utils/paths/index.ts +151 -0
  202. package/src/utils/png/consts.ts +34 -0
  203. package/src/utils/png/index.ts +131 -0
  204. package/src/utils/readJsonFile/index.ts +16 -0
  205. package/src/utils/rotateLogFile/index.ts +44 -0
  206. package/src/utils/safeClose/index.ts +10 -0
  207. package/src/utils/terminal/consts.ts +8 -0
  208. package/src/utils/terminal/index.ts +102 -0
  209. package/src/utils/thumbnailRenderer/consts.ts +2 -0
  210. package/src/utils/thumbnailRenderer/index.ts +147 -0
  211. package/src/utils/typedError/index.ts +26 -0
  212. package/tsconfig.json +31 -0
  213. 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
+ }