OXYGENHUB
# 2980b7b6d7cd enhanced-x16-emulator / src / debug_ui / debug_ui_scope.h
10.6 KiB · Objective-C · last changed chore(): initial commit 25 days ago source blame history raw
1
// Commander X16 Emulator — shared oscilloscope / meter helpers for the audio
2
// debugger panels (PSG, YM2151, PCM).
3
//
4
// Header-only, like debug_ui_widgets.h, so it needs no CMake entry.
5
//
6
// Everything draws through ImDrawList (no ImPlot dependency). The traces are
7
// decimated to at most one column per pixel, so the cost is bounded by the
8
// widget's on-screen width rather than by the sample count: a 2048-sample
9
// window in a 200 px wide scope costs 200 columns, not 2048 line segments.
10
#ifndef DEBUG_UI_SCOPE_H
11
#define DEBUG_UI_SCOPE_H
12
13
#include "imgui.h"
14
#include <stdint.h>
15
#include <stddef.h>
16
#include <stdio.h>
17
18
// DEBUGIsPaused() — audio panels hold their display while the machine is
19
// stopped (see dbgui_audio_hold below).
20
#include "debug_ui_bridge.h"
21
#include "debug_ui_settings.h"
22
23
// Vertical gain sentinel: scale the trace to the window's own peak each frame.
24
#define DBGUI_SCOPE_AUTO_GAIN 0.0f
25
26
// Find a rising zero crossing so a periodic waveform stays still instead of
27
// sliding across the widget. Searches the part of `samples` that precedes the
28
// displayed window, newest crossing first, and returns the index the window
29
// should start at. Falls back to the newest `window` samples when the signal
30
// never crosses (DC, silence, or a period longer than the search range).
31
static inline int
32
dbgui_scope_trigger_offset(const int16_t *samples, int count, int window)
33
{
34
    int latest = count - window; // no trigger: show the newest `window` samples
35
    if (latest <= 0) {
36
        return 0;
37
    }
38
    for (int i = latest; i > 0; --i) {
39
        if (samples[i - 1] < 0 && samples[i] >= 0) {
40
            return i;
41
        }
42
    }
43
    return latest;
44
}
45
46
// Draw one oscilloscope trace.
47
//
48
//   samples/count : sample history, oldest first (as returned by *_scope_read)
49
//   size          : widget size in pixels; 0 in either axis fills the available
50
//                   width / uses a default height
51
//   window        : how many of the newest samples to display (clamped to count)
52
//   gain          : pixels per unit / 32768.0; DBGUI_SCOPE_AUTO_GAIN auto-scales
53
//   trigger       : align to a rising zero crossing
54
//   overlay       : optional text drawn in the top-left corner
55
//
56
// Returns the peak magnitude (0..32768) of the displayed window, which callers
57
// can reuse for a level readout without rescanning.
58
static inline int
59
dbgui_scope(const char *id, const int16_t *samples, int count, ImVec2 size,
60
            ImU32 color, int window, float gain, bool trigger, const char *overlay)
61
{
62
    if (size.x <= 0.0f) {
63
        size.x = ImGui::GetContentRegionAvail().x;
64
    }
65
    if (size.x < 16.0f) {
66
        size.x = 16.0f;
67
    }
68
    if (size.y <= 0.0f) {
69
        size.y = ImGui::GetTextLineHeight() * 4.0f;
70
    }
71
72
    ImGui::PushID(id);
73
    ImGui::InvisibleButton("##scope", size);
74
    const ImVec2 p0 = ImGui::GetItemRectMin();
75
    const ImVec2 p1 = ImGui::GetItemRectMax();
76
    ImGui::PopID();
77
78
    ImDrawList *dl = ImGui::GetWindowDrawList();
79
    dl->AddRectFilled(p0, p1, IM_COL32(12, 14, 18, 255));
80
81
    // Grid: 4 columns / 4 rows, with a brighter centre line for the zero axis.
82
    const ImU32 grid = IM_COL32(52, 58, 68, 255);
83
    const ImU32 axis = IM_COL32(90, 100, 115, 255);
84
    for (int i = 1; i < 4; ++i) {
85
        float x = p0.x + (p1.x - p0.x) * (float)i / 4.0f;
86
        float y = p0.y + (p1.y - p0.y) * (float)i / 4.0f;
87
        dl->AddLine(ImVec2(x, p0.y), ImVec2(x, p1.y), grid);
88
        dl->AddLine(ImVec2(p0.x, y), ImVec2(p1.x, y), (i == 2) ? axis : grid);
89
    }
90
    dl->AddRect(p0, p1, IM_COL32(70, 78, 90, 255));
91
92
    if (samples == NULL || count <= 0) {
93
        return 0;
94
    }
95
    if (window <= 0 || window > count) {
96
        window = count;
97
    }
98
99
    const int start = trigger ? dbgui_scope_trigger_offset(samples, count, window)
100
                              : (count - window);
101
102
    // Peak of the displayed window: drives auto-gain and the return value.
103
    int peak = 0;
104
    for (int i = 0; i < window; ++i) {
105
        int v = samples[start + i];
106
        if (v < 0) {
107
            v = -v;
108
        }
109
        if (v > peak) {
110
            peak = v;
111
        }
112
    }
113
114
    const float half = (p1.y - p0.y) * 0.5f - 1.0f;
115
    const float mid  = (p0.y + p1.y) * 0.5f;
116
    float       scale;
117
    if (gain > 0.0f) {
118
        scale = half * gain;
119
    } else {
120
        // Auto: fill the widget, but keep near-silence flat instead of
121
        // amplifying dither into a full-height mess.
122
        scale = (peak > 64) ? (half / (float)peak) : (half / 32768.0f);
123
    }
124
125
    dl->PushClipRect(p0, p1, true);
126
127
    const int columns = (int)(p1.x - p0.x);
128
    if (window <= columns) {
129
        // Fewer samples than pixels: draw the actual points.
130
        for (int i = 1; i < window; ++i) {
131
            float xa = p0.x + (p1.x - p0.x) * (float)(i - 1) / (float)(window - 1);
132
            float xb = p0.x + (p1.x - p0.x) * (float)i / (float)(window - 1);
133
            float ya = mid - (float)samples[start + i - 1] * scale;
134
            float yb = mid - (float)samples[start + i] * scale;
135
            dl->AddLine(ImVec2(xa, ya), ImVec2(xb, yb), color, 1.0f);
136
        }
137
    } else {
138
        // More samples than pixels: one min/max bar per column, the usual
139
        // waveform-envelope look, and bounded by the widget width.
140
        for (int c = 0; c < columns; ++c) {
141
            int lo = (int)((int64_t)c * window / columns);
142
            int hi = (int)((int64_t)(c + 1) * window / columns);
143
            if (hi <= lo) {
144
                hi = lo + 1;
145
            }
146
            int vmin = 32767, vmax = -32768;
147
            for (int i = lo; i < hi && i < window; ++i) {
148
                int v = samples[start + i];
149
                if (v < vmin) {
150
                    vmin = v;
151
                }
152
                if (v > vmax) {
153
                    vmax = v;
154
                }
155
            }
156
            float x  = p0.x + (float)c + 0.5f;
157
            float ya = mid - (float)vmax * scale;
158
            float yb = mid - (float)vmin * scale;
159
            if (yb - ya < 1.0f) {
160
                yb = ya + 1.0f;
161
            }
162
            dl->AddLine(ImVec2(x, ya), ImVec2(x, yb), color, 1.0f);
163
        }
164
    }
165
166
    dl->PopClipRect();
167
168
    if (overlay && overlay[0]) {
169
        dl->AddText(ImVec2(p0.x + 4.0f, p0.y + 2.0f), IM_COL32(190, 198, 210, 220), overlay);
170
    }
171
    return peak;
172
}
173
174
// A horizontal peak-level bar. `frac` is 0..1 (values above 1 are clamped and
175
// shown in red so clipping is obvious).
176
static inline void
177
dbgui_level_meter(const char *id, float frac, ImVec2 size, ImU32 color)
178
{
179
    if (size.x <= 0.0f) {
180
        size.x = 80.0f;
181
    }
182
    if (size.y <= 0.0f) {
183
        size.y = ImGui::GetTextLineHeight();
184
    }
185
186
    bool clipped = frac > 1.0f;
187
    if (clipped) {
188
        frac = 1.0f;
189
    }
190
    if (frac < 0.0f) {
191
        frac = 0.0f;
192
    }
193
194
    ImGui::PushID(id);
195
    ImGui::InvisibleButton("##meter", size);
196
    const ImVec2 p0 = ImGui::GetItemRectMin();
197
    const ImVec2 p1 = ImGui::GetItemRectMax();
198
    ImGui::PopID();
199
200
    ImDrawList *dl = ImGui::GetWindowDrawList();
201
    dl->AddRectFilled(p0, p1, IM_COL32(24, 26, 30, 255));
202
    if (frac > 0.0f) {
203
        ImVec2 fill(p0.x + (p1.x - p0.x) * frac, p1.y);
204
        dl->AddRectFilled(p0, fill, clipped ? IM_COL32(220, 80, 70, 255) : color);
205
    }
206
    dl->AddRect(p0, p1, IM_COL32(70, 78, 90, 255));
207
}
208
209
// Shared "Scope" tab controls: time base, vertical gain, trigger and freeze.
210
// Keeping them here means the three panels present identical controls, and each
211
// panel only has to own the state.
212
struct DbgScopeControls {
213
    int   window  = 512;   // samples displayed
214
    float gain    = DBGUI_SCOPE_AUTO_GAIN;
215
    bool  trigger = true;
216
    bool  freeze  = false;
217
};
218
219
// Draw the control row. `max_window` is the ring depth (PSG_SCOPE_SAMPLES etc).
220
static inline void
221
dbgui_scope_controls(DbgScopeControls *c, int max_window)
222
{
223
    ImGui::SetNextItemWidth(140.0f);
224
    ImGui::SliderInt("Window", &c->window, 32, max_window, "%d smp");
225
    ImGui::SameLine();
226
227
    bool autoscale = (c->gain <= 0.0f);
228
    if (ImGui::Checkbox("Auto gain", &autoscale)) {
229
        c->gain = autoscale ? DBGUI_SCOPE_AUTO_GAIN : 1.0f;
230
    }
231
    if (!autoscale) {
232
        ImGui::SameLine();
233
        ImGui::SetNextItemWidth(120.0f);
234
        ImGui::SliderFloat("Gain", &c->gain, 0.05f, 16.0f, "%.2fx",
235
                           ImGuiSliderFlags_Logarithmic);
236
    }
237
    ImGui::SameLine();
238
    ImGui::Checkbox("Trigger", &c->trigger);
239
    ImGui::SetItemTooltip("Align the trace to a rising zero crossing so a steady "
240
                          "tone stands still.");
241
    ImGui::SameLine();
242
    ImGui::Checkbox("Freeze", &c->freeze);
243
    ImGui::SetItemTooltip("Stop refreshing the traces. Capture also stops, since "
244
                          "it is only armed while the debugger reads it.");
245
}
246
247
// Should an audio panel keep showing its last captured state instead of
248
// re-reading?
249
//
250
// Pausing the machine silences audio: nothing is rendered, peak meters read
251
// back zero and the scope rings stop filling, so a panel that re-reads while
252
// paused wipes out exactly the state the user paused to look at. Holding the
253
// last live capture keeps the display meaningful, and is what you want from a
254
// debugger — the values shown are the ones in effect at the moment of the
255
// pause.
256
static inline bool
257
dbgui_audio_hold(void)
258
{
259
    return DEBUGIsPaused() && debug_ui_settings().audio_hold_on_pause;
260
}
261
262
// Note next to the controls explaining that the display is held. Call after the
263
// control row when dbgui_audio_hold() is true.
264
static inline void
265
dbgui_audio_hold_note(void)
266
{
267
    ImGui::SameLine();
268
    ImGui::TextColored(ImVec4(0.95f, 0.70f, 0.30f, 1.0f), "held (paused)");
269
    ImGui::SetItemTooltip("The machine is paused, so audio is silent and nothing new is\n"
270
                          "captured. These are the values from the moment it stopped.");
271
}
272
273
// Note next to the controls for traces computed from chip state rather than
274
// captured. Scope capture is self-arming and nothing renders audio while
275
// paused, so a scope first opened at a breakpoint has an empty ring; the sound
276
// chips' own state still describes what they are about to emit, so the panels
277
// project that instead of showing a dead display.
278
static inline void
279
dbgui_audio_predicted_note(const char *what)
280
{
281
    ImGui::SameLine();
282
    ImGui::TextColored(ImVec4(0.70f, 0.80f, 1.00f, 1.0f), "projected");
283
    ImGui::SetItemTooltip("Paused with no captured audio, so these traces are computed from\n"
284
                          "%s - what it would emit next, using the same\n"
285
                          "renderer live audio uses.\n\n"
286
                          "It is a projection, not a recording: change the chip state and it\n"
287
                          "updates immediately. Real captured audio replaces it on resume.",
288
                          what);
289
}
290
291
#endif // DEBUG_UI_SCOPE_H