Gopher2600/gui/sdlimgui/playscr_overlay.go
JetSetIlly 0288e3d968 simplified realtime audio mixer
realtime audio mixers, like SDL audio implementation, are expected to
self-regulate. this makes more sense and the Regulate() idea didn't work
as expected

SDL audio now repeats or drops short bursts of samples as they are
received, in order keep the queue inside a generous window. this seems
to work very well and far better than the previous efforts at regulation

there could maybe be more thought around how many samples should be
repeated or dropped such that it isn't audible

the difference between a realtime mixer and a regular mixer is now just
the SetSpec() function. I'll consider adding the SetSpec() function to
the regular mixer interface and dropping the realtime mixer altogether
2025-01-12 10:12:54 +00:00

638 lines
20 KiB
Go

// This file is part of Gopher2600.
//
// Gopher2600 is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Gopher2600 is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Gopher2600. If not, see <https://www.gnu.org/licenses/>.
package sdlimgui
import (
"fmt"
"runtime"
"strings"
"time"
"github.com/inkyblackness/imgui-go/v4"
"github.com/jetsetilly/gopher2600/coprocessor/developer/dwarf"
"github.com/jetsetilly/gopher2600/debugger/govern"
"github.com/jetsetilly/gopher2600/gui/fonts"
"github.com/jetsetilly/gopher2600/gui/sdlaudio"
"github.com/jetsetilly/gopher2600/hardware/riot/ports/plugging"
"github.com/jetsetilly/gopher2600/notifications"
)
type overlayLatch int
const (
overlayLatchPinned = -1
overlayLatchOff = 0
overlayLatchBrief = 30
overlayLatchShort = 60
overlayLatchLong = 90
)
func (ct *overlayLatch) forceExpire() {
*ct = overlayLatchOff
}
// reduces the duration value. returns false if count has expired. if the
// duration has been "pinned" then value will return true
func (ct *overlayLatch) tick() bool {
if *ct == overlayLatchOff {
return false
}
if *ct == overlayLatchPinned {
return true
}
*ct = *ct - 1
return true
}
// returns true if duration is not off or pinned
func (ct *overlayLatch) expired() bool {
return *ct != overlayLatchPinned && *ct == overlayLatchOff
}
type playscrOverlay struct {
img *SdlImgui
playscr *playScr
fps string
refreshRate string
renderAlert int
memStatsTicker *time.Ticker
memStats runtime.MemStats
// top-left corner of the overlay includes emulation state. if the
// "fpsOverlay" is active then these will be drawn alongside the FPS
// information
state govern.State
subState govern.SubState
stateLatch overlayLatch
// events are user-activated events and require immediate feedback
event notifications.Notice
eventLatch overlayLatch
// icons in the top-left corner of the overlay are drawn according to a
// priority. the iconQueue list the icons to be drawn in order
iconQueue []rune
// top-right corner of the overlay
cartridge notifications.Notice
cartridgeLatch overlayLatch
// bottom-left corner of the overlay
leftPort plugging.PeripheralID
leftPortLatch overlayLatch
// bottom-right corner of the overlay
rightPort plugging.PeripheralID
rightPortLatch overlayLatch
// visibility of icons is set from the preferences once per draw()
visibility float32
}
const overlayPadding = 10
func (ovly *playscrOverlay) set(v any, args ...any) {
switch n := v.(type) {
case plugging.PortID:
switch n {
case plugging.PortLeft:
ovly.leftPort = args[0].(plugging.PeripheralID)
ovly.leftPortLatch = overlayLatchShort
case plugging.PortRight:
ovly.rightPort = args[0].(plugging.PeripheralID)
ovly.rightPortLatch = overlayLatchShort
}
case notifications.Notice:
switch n {
case notifications.NotifySuperchargerSoundloadStarted:
ovly.cartridge = n
ovly.cartridgeLatch = overlayLatchPinned
case notifications.NotifySuperchargerSoundloadEnded:
ovly.cartridge = n
ovly.cartridgeLatch = overlayLatchShort
case notifications.NotifySuperchargerSoundloadRewind:
return
case notifications.NotifyPlusROMNetwork:
ovly.cartridge = n
ovly.cartridgeLatch = overlayLatchShort
case notifications.NotifyScreenshot:
ovly.event = n
ovly.eventLatch = overlayLatchShort
default:
return
}
}
}
func (ovly *playscrOverlay) draw() {
imgui.PushStyleColor(imgui.StyleColorWindowBg, ovly.img.cols.Transparent)
imgui.PushStyleColor(imgui.StyleColorBorder, ovly.img.cols.Transparent)
defer imgui.PopStyleColorV(2)
imgui.PushStyleVarVec2(imgui.StyleVarWindowPadding, imgui.Vec2{})
defer imgui.PopStyleVarV(1)
imgui.SetNextWindowPos(imgui.Vec2{X: 0, Y: 0})
winw, winh := ovly.img.plt.windowSize()
imgui.SetNextWindowSize(imgui.Vec2{X: winw, Y: winh})
imgui.BeginV("##playscrOverlay", nil, imgui.WindowFlagsAlwaysAutoResize|
imgui.WindowFlagsNoScrollbar|imgui.WindowFlagsNoTitleBar|
imgui.WindowFlagsNoDecoration|imgui.WindowFlagsNoSavedSettings|
imgui.WindowFlagsNoBringToFrontOnFocus)
defer imgui.End()
ovly.visibility = float32(ovly.img.prefs.notificationVisibility.Get().(float64))
ovly.drawTopLeft()
ovly.drawTopRight()
ovly.drawBottomLeft()
ovly.drawBottomRight()
}
func (ovly *playscrOverlay) updateRefreshRate() {
fps, refreshRate := ovly.img.dbg.VCS().TV.GetActualFPS()
if fps == 0 {
ovly.fps = "waiting"
} else {
ovly.fps = fmt.Sprintf("%03.2f fps", fps)
}
if refreshRate == 0 {
ovly.refreshRate = "waiting"
} else {
ovly.refreshRate = fmt.Sprintf("%03.2fhz", refreshRate)
}
}
// information in the top left corner of the overlay are about the emulation.
// eg. whether audio is mute, or the emulation is paused, etc. it is also used
// to display the FPS counter and other TV information
func (ovly *playscrOverlay) drawTopLeft() {
pos := imgui.CursorScreenPos()
pos.X += overlayPadding
pos.Y += overlayPadding
// by default only one icon is shown in the top left corner. however, if the
// FPS overlay is being used we use the space to draw smaller icons
var useIconQueue bool
// draw FPS information if it's enabled
if ovly.img.prefs.fpsDetail.Get().(bool) {
// it's easier if we put topleft of overlay in a window because the window
// will control the width and positioning automatically. if we don't then
// the horizntal rules will stretch the width of the screen and each new line of
// text in the fps detail will need to be repositioned for horizontal
// padding
imgui.SetNextWindowPos(pos)
imgui.BeginV("##fpsDetail", nil, imgui.WindowFlagsAlwaysAutoResize|
imgui.WindowFlagsNoScrollbar|imgui.WindowFlagsNoTitleBar|
imgui.WindowFlagsNoDecoration|imgui.WindowFlagsNoSavedSettings|
imgui.WindowFlagsNoBringToFrontOnFocus)
defer imgui.End()
ovly.updateRefreshRate()
select {
case <-ovly.memStatsTicker.C:
runtime.ReadMemStats(&ovly.memStats)
default:
}
imgui.Textf("Emulation: %s", ovly.fps)
r := imgui.CurrentIO().Framerate()
if r == 0.0 {
imgui.Text("Rendering: waiting")
} else {
imgui.Textf("Rendering: %03.2f fps", r)
}
imguiSeparator()
if coproc := ovly.img.cache.VCS.Mem.Cart.GetCoProc(); coproc != nil {
clk := float32(ovly.img.dbg.VCS().Env.Prefs.ARM.Clock.Get().(float64))
imgui.Text(fmt.Sprintf("%s Clock: %.0f Mhz", coproc.ProcessorID(), clk))
imguiSeparator()
}
imgui.Text(fmt.Sprintf("%.1fx scaling", ovly.playscr.scaling))
imgui.Text(fmt.Sprintf("%d total scanlines", ovly.playscr.scr.crit.frameInfo.TotalScanlines))
imguiSeparator()
// this construct (spacing followed by a same-line directive) is only
// necessary so that the extreme left pixel of the VBLANKtop icon is not
// chopped off. it's a very small detail but worth doing
imgui.Spacing()
imgui.SameLineV(0, 1)
vblankBounds := fmt.Sprintf("%c %d %c %d",
fonts.VBLANKtop,
ovly.playscr.scr.crit.frameInfo.VBLANKtop,
fonts.VBLANKbottom,
ovly.playscr.scr.crit.frameInfo.VBLANKbottom)
vblankBounds = strings.ReplaceAll(vblankBounds, "-1", "-")
imgui.Text(vblankBounds)
if ovly.playscr.scr.crit.frameInfo.VBLANKunstable {
imgui.SameLineV(0, 5)
imgui.Text(string(fonts.Bug))
}
if ovly.playscr.scr.crit.frameInfo.AtariSafe() {
imgui.SameLineV(0, 15)
imgui.Text(string(fonts.VBLANKatari))
}
imgui.Spacing()
if ovly.playscr.scr.crit.frameInfo.FromVSYNC {
imgui.Text(fmt.Sprintf("VSYNC %d+%d", ovly.playscr.scr.crit.frameInfo.VSYNCscanline,
ovly.playscr.scr.crit.frameInfo.VSYNCcount))
if ovly.playscr.scr.crit.frameInfo.VSYNCunstable {
imgui.SameLineV(0, 5)
imgui.Text(string(fonts.Bug))
}
} else {
imgui.Text(fmt.Sprintf("VSYNC %c", fonts.Bug))
}
imguiSeparator()
imgui.Text(ovly.img.screen.crit.frameInfo.Spec.ID)
imgui.SameLine()
imgui.Text(ovly.refreshRate)
if ovly.img.prefs.frameQueueMeterInOverlay.Get().(bool) {
imguiSeparator()
imgui.PushStyleColor(imgui.StyleColorText, ovly.img.cols.FrameQueueSlackActive)
for _ = range ovly.playscr.scr.frameQueueSlack {
imgui.Text(string(fonts.MeterSegment))
imgui.SameLineV(0, 0)
}
imgui.PushStyleColor(imgui.StyleColorText, ovly.img.cols.FrameQueueSlackInactive)
for _ = range ovly.playscr.scr.crit.frameQueueLen - ovly.playscr.scr.frameQueueSlack {
imgui.Text(string(fonts.MeterSegment))
imgui.SameLineV(0, 0)
}
imgui.Text("")
imgui.PopStyleColorV(2)
imgui.Spacing()
imgui.Textf("%2.2fms/frame", float32(ovly.img.plt.renderAvgTime.Nanoseconds())/1000000)
if ovly.img.plt.renderAlert {
ovly.renderAlert = 60
} else if ovly.renderAlert > 0 {
ovly.renderAlert--
}
if ovly.renderAlert > 0 {
imgui.SameLineV(0, 5)
imgui.Text(string(fonts.RenderTime))
}
}
if ovly.img.prefs.audioQueueMeterInOverlay.Get().(bool) {
// draw separator if there is no frame queue meter
if !ovly.img.prefs.frameQueueMeterInOverlay.Get().(bool) {
imguiSeparator()
} else {
imgui.Spacing()
}
if ovly.img.audio.QueuedBytes == 0 {
imgui.PushStyleColor(imgui.StyleColorText, ovly.img.cols.AudioQueueInactive)
imgui.Text(string(fonts.MeterSegment))
imgui.SameLineV(0, 0)
imgui.Text(string(fonts.MeterSegment))
imgui.SameLineV(0, 0)
imgui.Text(string(fonts.MeterSegment))
imgui.SameLineV(0, 0)
imgui.PopStyleColor()
} else if ovly.img.audio.QueuedBytes < sdlaudio.QueueOkay {
imgui.PushStyleColor(imgui.StyleColorText, ovly.img.cols.AudioQueueActive)
imgui.Text(string(fonts.MeterSegment))
imgui.SameLineV(0, 0)
imgui.PushStyleColor(imgui.StyleColorText, ovly.img.cols.AudioQueueInactive)
imgui.Text(string(fonts.MeterSegment))
imgui.SameLineV(0, 0)
imgui.Text(string(fonts.MeterSegment))
imgui.SameLineV(0, 0)
imgui.PopStyleColorV(2)
} else if ovly.img.audio.QueuedBytes < sdlaudio.QueueWarning {
imgui.PushStyleColor(imgui.StyleColorText, ovly.img.cols.AudioQueueActive)
imgui.Text(string(fonts.MeterSegment))
imgui.SameLineV(0, 0)
imgui.Text(string(fonts.MeterSegment))
imgui.SameLineV(0, 0)
imgui.PushStyleColor(imgui.StyleColorText, ovly.img.cols.AudioQueueInactive)
imgui.Text(string(fonts.MeterSegment))
imgui.SameLineV(0, 0)
imgui.PopStyleColorV(2)
} else {
imgui.PushStyleColor(imgui.StyleColorText, ovly.img.cols.AudioQueueActive)
imgui.Text(string(fonts.MeterSegment))
imgui.SameLineV(0, 0)
imgui.Text(string(fonts.MeterSegment))
imgui.SameLineV(0, 0)
imgui.Text(string(fonts.MeterSegment))
imgui.SameLineV(0, 0)
imgui.PopStyleColor()
}
imgui.Spacing()
if !ovly.img.prefs.audioMutePlaymode.Get().(bool) {
imgui.Textf("%dkb audio queue", ovly.img.audio.QueuedBytes/1024)
}
}
if ovly.img.prefs.memoryUsageInOverlay.Get().(bool) {
imguiSeparator()
imgui.Textf("Used = %v MB\n", ovly.memStats.Alloc/1048576)
imgui.Textf("Reserved = %v MB\n", ovly.memStats.Sys/1048576)
imgui.Textf("GC Sweeps = %v", ovly.memStats.NumGC)
imgui.Textf("GC CPU %% = %.2f%%", ovly.memStats.GCCPUFraction*100)
}
// create space in the window for any icons that we might want to draw.
// what's good about this is that it makes sure that the window is large
// enough from frame-to-frame. without this, there will be a visble
// delay when the window is resized
imgui.Spacing()
p := imgui.CursorScreenPos()
imgui.Text("")
imgui.SetCursorScreenPos(p)
// draw developer icon if BorrowSource() returns a non-nil value
ovly.img.dbg.CoProcDev.BorrowSource(func(src *dwarf.Source) {
if src != nil {
imgui.Text(string(fonts.Developer))
imgui.SameLine()
}
})
// we can draw multiple icons if required
useIconQueue = true
} else {
// we'll only be drawing one icon so we only need to set the cursor
// position once, so there's no need for a window as would be the case
// if fps detail was activated
imgui.SetCursorScreenPos(pos)
// FPS overlay is not active so we increase the font size for any icons
// that may be drawn hereafter in this window
imgui.PushFont(ovly.img.fonts.veryLargeFontAwesome)
defer imgui.PopFont()
// add visibility adjustment if there is no FPS overlay
imgui.PushStyleColor(imgui.StyleColorText, imgui.Vec4{X: ovly.visibility, Y: ovly.visibility, Z: ovly.visibility, W: ovly.visibility})
defer imgui.PopStyleColor()
}
// start a new icons queue
ovly.iconQueue = ovly.iconQueue[:0]
// mute is likely to be the icon visible the longest so has the lowest priority
if ovly.img.prefs.audioMutePlaymode.Get().(bool) && ovly.img.prefs.audioMuteNotification.Get().(bool) {
ovly.iconQueue = append(ovly.iconQueue, fonts.AudioMute)
}
// the real current state as set by the emulation is used to decide what
// state to use for the overlay icon
state := ovly.img.dbg.State()
subState := ovly.img.dbg.SubState()
switch state {
case govern.Paused:
// handling the pause state is the trickiest to get right. we want to
// prioritise the pause icon in some cases but not in others
switch ovly.state {
case govern.Rewinding:
// if the previous state was the rewinding state a pause icon will
// show if the pause sub-state is not normal or if the
// previous state latch has expired
if subState != govern.Normal || ovly.stateLatch.expired() {
ovly.state = state
ovly.subState = subState
ovly.stateLatch = overlayLatchPinned
}
default:
ovly.state = state
ovly.subState = subState
ovly.stateLatch = overlayLatchPinned
}
case govern.Running:
if state != ovly.state {
ovly.state = state
ovly.subState = subState
ovly.stateLatch = overlayLatchShort
}
case govern.Rewinding:
ovly.state = state
ovly.subState = subState
// refresh how the hold duration on every render frame that the
// rewinding state is seen. this is so that the duration of the rewind
// icon doesn't expire causing the pause icon to appear every so often
//
// (the way rewinding is implemented in the emulation means that the
// rewinding state is interspersed very quickly with the paused state.
// that works great for internal emulation purposes but requires careful
// handling for UI purposes)
ovly.stateLatch = overlayLatchBrief
}
// the state duration is ticked and the icon is shown unless the tick has
// expired (returns false)
if ovly.stateLatch.tick() {
switch ovly.state {
case govern.Paused:
switch ovly.subState {
case govern.PausedAtStart:
ovly.iconQueue = append(ovly.iconQueue, fonts.EmulationPausedAtStart)
case govern.PausedAtEnd:
ovly.iconQueue = append(ovly.iconQueue, fonts.EmulationPausedAtEnd)
default:
ovly.iconQueue = append(ovly.iconQueue, fonts.EmulationPause)
}
case govern.Running:
ovly.iconQueue = append(ovly.iconQueue, fonts.EmulationRun)
case govern.Rewinding:
switch ovly.subState {
case govern.RewindingBackwards:
ovly.iconQueue = append(ovly.iconQueue, fonts.EmulationRewindBack)
case govern.RewindingForwards:
ovly.iconQueue = append(ovly.iconQueue, fonts.EmulationRewindForward)
default:
}
}
}
// events have the highest priority. we can think of these as user activated
// events, such as the triggering of a screenshot. we therefore want to give
// the user confirmation feedback immediately over other icons
if ovly.eventLatch.tick() {
switch ovly.event {
case notifications.NotifyScreenshot:
ovly.iconQueue = append(ovly.iconQueue, fonts.Camera)
}
}
// draw only the last (ie. most important) icon unless the icon queue flag
// has been set
if !useIconQueue {
if len(ovly.iconQueue) > 0 {
imgui.Text(string(ovly.iconQueue[len(ovly.iconQueue)-1]))
}
return
}
// draw icons in order of priority
for _, i := range ovly.iconQueue {
imgui.Text(string(i))
imgui.SameLine()
}
return
}
// information in the top right of the overlay is about the cartridge. ie.
// information from the cartridge about what is happening. for example,
// supercharger tape activity, or PlusROM network activity, etc.
func (ovly *playscrOverlay) drawTopRight() {
if !ovly.cartridgeLatch.tick() {
return
}
var icon string
var secondaryIcon string
switch ovly.cartridge {
case notifications.NotifySuperchargerSoundloadStarted:
if ovly.img.prefs.superchargerNotifications.Get().(bool) {
icon = fmt.Sprintf("%c", fonts.Tape)
secondaryIcon = fmt.Sprintf("%c", fonts.TapePlay)
}
case notifications.NotifySuperchargerSoundloadEnded:
if ovly.img.prefs.superchargerNotifications.Get().(bool) {
icon = fmt.Sprintf("%c", fonts.Tape)
secondaryIcon = fmt.Sprintf("%c", fonts.TapeStop)
}
case notifications.NotifySuperchargerSoundloadRewind:
if ovly.img.prefs.superchargerNotifications.Get().(bool) {
icon = fmt.Sprintf("%c", fonts.Tape)
secondaryIcon = fmt.Sprintf("%c", fonts.TapeRewind)
}
case notifications.NotifyPlusROMNetwork:
if ovly.img.prefs.plusromNotifications.Get().(bool) {
icon = fmt.Sprintf("%c", fonts.Wifi)
}
default:
return
}
pos := imgui.WindowContentRegionMax()
pos.X -= ovly.img.fonts.gopher2600IconsSize + overlayPadding
pos.Y = 0
if secondaryIcon != "" {
pos.X -= ovly.img.fonts.largeFontAwesomeSize * 2
}
imgui.PushFont(ovly.img.fonts.gopher2600Icons)
defer imgui.PopFont()
imgui.PushStyleColor(imgui.StyleColorText, imgui.Vec4{X: ovly.visibility, Y: ovly.visibility, Z: ovly.visibility, W: ovly.visibility})
defer imgui.PopStyleColor()
imgui.SetCursorScreenPos(pos)
imgui.Text(icon)
if secondaryIcon != "" {
imgui.PushFont(ovly.img.fonts.largeFontAwesome)
defer imgui.PopFont()
imgui.SameLine()
pos = imgui.CursorScreenPos()
pos.Y += (ovly.img.fonts.gopher2600IconsSize - ovly.img.fonts.largeFontAwesomeSize) * 0.5
imgui.SetCursorScreenPos(pos)
imgui.Text(secondaryIcon)
}
}
func (ovly *playscrOverlay) drawBottomLeft() {
if !ovly.leftPortLatch.tick() {
return
}
if !ovly.img.prefs.controllerNotifcations.Get().(bool) {
return
}
pos := imgui.WindowContentRegionMax()
pos.X = overlayPadding
pos.Y -= ovly.img.fonts.gopher2600IconsSize + overlayPadding
imgui.SetCursorScreenPos(pos)
ovly.drawPeripheral(ovly.leftPort)
}
func (ovly *playscrOverlay) drawBottomRight() {
if !ovly.rightPortLatch.tick() {
return
}
if !ovly.img.prefs.controllerNotifcations.Get().(bool) {
return
}
pos := imgui.WindowContentRegionMax()
pos.X -= ovly.img.fonts.gopher2600IconsSize + overlayPadding
pos.Y -= ovly.img.fonts.gopher2600IconsSize + overlayPadding
imgui.SetCursorScreenPos(pos)
ovly.drawPeripheral(ovly.rightPort)
}
// drawPeripheral is used to draw the peripheral in the bottom left and bottom
// right corners of the overlay
func (ovly *playscrOverlay) drawPeripheral(peripID plugging.PeripheralID) {
imgui.PushFont(ovly.img.fonts.gopher2600Icons)
defer imgui.PopFont()
imgui.PushStyleColor(imgui.StyleColorText, imgui.Vec4{X: ovly.visibility, Y: ovly.visibility, Z: ovly.visibility, W: ovly.visibility})
defer imgui.PopStyleColor()
switch peripID {
case plugging.PeriphStick:
imgui.Text(fmt.Sprintf("%c", fonts.Stick))
case plugging.PeriphPaddles:
imgui.Text(fmt.Sprintf("%c", fonts.Paddle))
case plugging.PeriphKeypad:
imgui.Text(fmt.Sprintf("%c", fonts.Keypad))
case plugging.PeriphSavekey:
imgui.Text(fmt.Sprintf("%c", fonts.Savekey))
case plugging.PeriphGamepad:
imgui.Text(fmt.Sprintf("%c", fonts.Gamepad))
case plugging.PeriphAtariVox:
imgui.Text(fmt.Sprintf("%c", fonts.AtariVox))
}
}