├── .gitignore ├── LICENSE ├── README.md ├── catnip.go ├── cmd └── catnip-gtk │ ├── about.go │ ├── catnipgtk │ ├── config.go │ ├── config_appearance.go │ ├── config_input.go │ ├── config_visualizer.go │ └── session.go │ └── main.go ├── drawer.go ├── drawer_start.go ├── go.mod ├── go.sum ├── screenshot.png └── shell.nix /.gitignore: -------------------------------------------------------------------------------- 1 | cchat-gtk 2 | .direnv 3 | .envrc 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 diamondburned 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any purpose 4 | with or without fee is hereby granted, provided that the above copyright notice 5 | and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 8 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND 9 | FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 10 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS 11 | OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER 12 | TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF 13 | THIS SOFTWARE. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # catnip-gtk 2 | 3 | ![screenshot](screenshot.png) 4 | 5 | A [catnip][catnip] frontend as a Gtk3 widget. 6 | 7 | [catnip]: https://github.com/noriah/catnip 8 | 9 | ## Usage 10 | 11 | Check `./cmd/catnip-gtk`. 12 | -------------------------------------------------------------------------------- /catnip.go: -------------------------------------------------------------------------------- 1 | package catnip 2 | 3 | import ( 4 | "fmt" 5 | "image/color" 6 | "math" 7 | 8 | "github.com/diamondburned/gotk4/pkg/cairo" 9 | "github.com/diamondburned/gotk4/pkg/gtk/v3" 10 | "github.com/noriah/catnip/dsp/window" 11 | "github.com/noriah/catnip/input" 12 | "github.com/pkg/errors" 13 | ) 14 | 15 | // Config is the catnip config. 16 | type Config struct { 17 | // Backend is the backend name from list-backends 18 | Backend string 19 | // Device is the device name from list-devices 20 | Device string 21 | 22 | WindowFn window.Function // default CosSum, a0 = 0.50 23 | Scaling ScalingConfig 24 | SampleRate float64 25 | SampleSize int 26 | SmoothFactor float64 27 | MinimumClamp float64 // height before visible 28 | 29 | DrawOptions 30 | 31 | DrawStyle DrawStyle 32 | Monophonic bool 33 | } 34 | 35 | // DrawStyle is the style to draw the bars symmetrically. 36 | type DrawStyle uint8 37 | 38 | const ( 39 | // VerticalBars is drawing the bars vertically mirrored if stereo. 40 | DrawVerticalBars DrawStyle = iota 41 | // HorizontalBars is drawing the bars horizontally mirrored if stereo. 42 | DrawHorizontalBars 43 | // DrawLines draws the spectrum as lines. 44 | DrawLines 45 | ) 46 | 47 | // WrapExternalWindowFn wraps external (mostly gonum/dsp/window) functions to be 48 | // compatible with catnip's usage. The implementation will assume that the given 49 | // function modifies the given slice in its place, which is the case for most 50 | // gonum functions, but it might not always be the case. If the implementation 51 | // does not, then the caller should write their own function to copy. 52 | func WrapExternalWindowFn(fn func([]float64) []float64) window.Function { 53 | return func(buf []float64) { fn(buf) } 54 | } 55 | 56 | // DrawOptions is the option for Cairo draws. 57 | type DrawOptions struct { 58 | LineCap cairo.LineCap // default BUTT 59 | LineJoin cairo.LineJoin // default MITER 60 | AntiAlias cairo.Antialias 61 | 62 | FrameRate int 63 | 64 | Colors Colors 65 | Offsets DrawOffsets 66 | BarWidth float64 // not really pixels 67 | SpaceWidth float64 // not really pixels 68 | 69 | // ForceEven will round the width and height to be even. This will force 70 | // Cairo to always draw the bars sharply. 71 | ForceEven bool 72 | } 73 | 74 | func (opts DrawOptions) even(n int) int { 75 | if !opts.ForceEven { 76 | return n 77 | } 78 | return n - (n % 2) 79 | } 80 | 81 | func (opts DrawOptions) round(f float64) float64 { 82 | if !opts.ForceEven { 83 | return f 84 | } 85 | return math.Round(f) 86 | } 87 | 88 | // DrawOffsets controls the offset for the Drawer. 89 | type DrawOffsets struct { 90 | X, Y float64 91 | } 92 | 93 | // apply applies the draw offset. 94 | func (offset DrawOffsets) apply(x, y float64) (float64, float64) { 95 | return x + offset.X, y + offset.Y 96 | } 97 | 98 | // Colors is the color settings for the Drawer. 99 | type Colors struct { 100 | Foreground color.Color // use Gtk if nil 101 | Background color.Color // transparent if nil 102 | } 103 | 104 | // ScalingConfig is the scaling settings for the visualizer. 105 | type ScalingConfig struct { 106 | StaticScale float64 // 0 for dynamic scale 107 | SlowWindow float64 108 | FastWindow float64 109 | DumpPercent float64 110 | ResetDeviation float64 111 | } 112 | 113 | func NewConfig() Config { 114 | return Config{ 115 | Backend: "portaudio", 116 | Device: "", 117 | 118 | // Default to CosSum with WinVar = 0.50. 119 | WindowFn: func(buf []float64) { window.CosSum(buf, 0.50) }, 120 | 121 | SampleRate: 48000, 122 | SampleSize: 48000 / 30, // 30 resample/s 123 | SmoothFactor: 65.69, 124 | Monophonic: false, 125 | MinimumClamp: 1, 126 | 127 | DrawOptions: DrawOptions{ 128 | LineCap: cairo.LINE_CAP_BUTT, 129 | LineJoin: cairo.LINE_JOIN_MITER, 130 | AntiAlias: cairo.ANTIALIAS_DEFAULT, 131 | FrameRate: 60, // 60fps 132 | BarWidth: 10, 133 | SpaceWidth: 5, 134 | }, 135 | 136 | Scaling: ScalingConfig{ 137 | SlowWindow: 5, 138 | FastWindow: 5 * 0.2, 139 | DumpPercent: 0.75, 140 | ResetDeviation: 1.0, 141 | }, 142 | } 143 | } 144 | 145 | // InitBackend initializes a new input backend. 146 | func (c *Config) InitBackend() (input.Backend, error) { 147 | backend := input.FindBackend(c.Backend) 148 | if backend == nil { 149 | return nil, fmt.Errorf("backend not found: %q", c.Backend) 150 | } 151 | 152 | if err := backend.Init(); err != nil { 153 | return nil, errors.Wrap(err, "failed to initialize input backend") 154 | } 155 | 156 | return backend, nil 157 | } 158 | 159 | // InitDevice initializes an input device with the given initalized backend. 160 | func (c *Config) InitDevice(b input.Backend) (input.Device, error) { 161 | if c.Device == "" { 162 | def, err := b.DefaultDevice() 163 | if err != nil { 164 | return nil, errors.Wrap(err, "failed to get default device") 165 | } 166 | 167 | return def, nil 168 | } 169 | 170 | devices, err := b.Devices() 171 | if err != nil { 172 | return nil, errors.Wrap(err, "failed to get devices") 173 | } 174 | 175 | for idx := range devices { 176 | if devices[idx].String() == c.Device { 177 | return devices[idx], nil 178 | } 179 | } 180 | 181 | return nil, errors.Errorf("device %q not found; check list-devices", c.Device) 182 | } 183 | 184 | // Area is the area that Catnip draws onto. 185 | type Area struct { 186 | *gtk.DrawingArea 187 | *Drawer 188 | } 189 | 190 | // New creates a new Catnip DrawingArea from the given config. 191 | func New(cfg Config) *Area { 192 | draw := gtk.NewDrawingArea() 193 | return &Area{ 194 | DrawingArea: draw, 195 | Drawer: NewDrawer(draw, cfg), 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /cmd/catnip-gtk/about.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/diamondburned/gotk4/pkg/gtk/v3" 7 | ) 8 | 9 | var Version = "tip" 10 | 11 | var license = strings.TrimSpace(` 12 | Permission to use, copy, modify, and/or distribute this software for any purpose 13 | with or without fee is hereby granted, provided that the above copyright notice 14 | and this permission notice appear in all copies. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 17 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND 18 | FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 19 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS 20 | OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER 21 | TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF 22 | THIS SOFTWARE. 23 | `) 24 | 25 | func About() *gtk.AboutDialog { 26 | about := gtk.NewAboutDialog() 27 | about.SetModal(true) 28 | about.SetProgramName("catnip-gtk") 29 | about.SetVersion(Version) 30 | about.SetLicenseType(gtk.LicenseMITX11) 31 | about.SetLicense(license) 32 | about.SetAuthors([]string{ 33 | "diamondburned", 34 | "noriah reuland (code@noriah.dev)", 35 | }) 36 | 37 | return about 38 | } 39 | -------------------------------------------------------------------------------- /cmd/catnip-gtk/catnipgtk/config.go: -------------------------------------------------------------------------------- 1 | package catnipgtk 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/diamondburned/catnip-gtk" 9 | "github.com/diamondburned/gotk4-handy/pkg/handy" 10 | "github.com/diamondburned/gotk4/pkg/cairo" 11 | "github.com/diamondburned/gotk4/pkg/glib/v2" 12 | "github.com/pkg/errors" 13 | ) 14 | 15 | // UserConfigPath is the default path to the user's config file. 16 | var UserConfigPath = filepath.Join(glib.GetUserConfigDir(), "catnip-gtk", "config.json") 17 | 18 | type Config struct { 19 | Input Input 20 | Appearance Appearance 21 | Visualizer Visualizer 22 | WindowSize struct { 23 | Width int 24 | Height int 25 | } 26 | } 27 | 28 | // NewConfig creates a new default config. 29 | func NewConfig() (*Config, error) { 30 | cfg := Config{ 31 | Appearance: NewAppearance(), 32 | Visualizer: NewVisualizer(), 33 | } 34 | cfg.WindowSize.Width = 1000 35 | cfg.WindowSize.Height = 150 36 | 37 | input, err := NewInput() 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | cfg.Input = input 43 | 44 | return &cfg, nil 45 | } 46 | 47 | // ReadUserConfig reads the user's config file at the default user path. 48 | func ReadUserConfig() (*Config, error) { 49 | return ReadConfig(UserConfigPath) 50 | } 51 | 52 | // ReadConfig reads the config at the given path. 53 | func ReadConfig(path string) (*Config, error) { 54 | c, err := NewConfig() 55 | if err != nil { 56 | return c, errors.Wrap(err, "failed to make default config") 57 | } 58 | 59 | f, err := os.Open(path) 60 | if err != nil { 61 | return c, errors.Wrap(err, "failed to open config path") 62 | } 63 | defer f.Close() 64 | 65 | if err := json.NewDecoder(f).Decode(c); err != nil { 66 | return c, errors.Wrap(err, "failed to decode JSON") 67 | } 68 | 69 | return c, nil 70 | } 71 | 72 | func (cfg *Config) PreferencesWindow(apply func()) *handy.PreferencesWindow { 73 | // Refresh the input devices. 74 | cfg.Input.Update() 75 | 76 | input := cfg.Input.Page(apply) 77 | input.Show() 78 | 79 | appearance := cfg.Appearance.Page(apply) 80 | appearance.Show() 81 | 82 | visualizer := cfg.Visualizer.Page(apply) 83 | visualizer.Show() 84 | 85 | window := handy.NewPreferencesWindow() 86 | window.SetSearchEnabled(true) 87 | window.SetModal(false) 88 | window.Add(input) 89 | window.Add(appearance) 90 | window.Add(visualizer) 91 | 92 | return window 93 | } 94 | 95 | // Transform turns this config into a catnip config. 96 | func (cfg Config) Transform() catnip.Config { 97 | catnipCfg := catnip.Config{ 98 | Backend: cfg.Input.Backend, 99 | Device: cfg.Input.Device, 100 | Monophonic: !cfg.Input.DualChannel, 101 | WindowFn: cfg.Visualizer.WindowFn.AsFunction(), 102 | SampleRate: cfg.Visualizer.SampleRate, 103 | SampleSize: cfg.Visualizer.SampleSize, 104 | SmoothFactor: cfg.Visualizer.SmoothFactor, 105 | MinimumClamp: cfg.Appearance.MinimumClamp, 106 | DrawStyle: cfg.Appearance.DrawStyle, 107 | DrawOptions: catnip.DrawOptions{ 108 | LineCap: cfg.Appearance.LineCap.AsLineCap(), 109 | LineJoin: cairo.LINE_JOIN_MITER, 110 | FrameRate: cfg.Visualizer.FrameRate, 111 | BarWidth: cfg.Appearance.BarWidth, 112 | SpaceWidth: cfg.Appearance.SpaceWidth, 113 | AntiAlias: cfg.Appearance.AntiAlias.AsAntialias(), 114 | ForceEven: false, 115 | }, 116 | Scaling: catnip.ScalingConfig{ 117 | SlowWindow: 5, 118 | FastWindow: 4, 119 | DumpPercent: 0.75, 120 | ResetDeviation: 1.0, 121 | }, 122 | } 123 | 124 | if cfg.Appearance.ForegroundColor != nil { 125 | catnipCfg.DrawOptions.Colors.Foreground = cfg.Appearance.ForegroundColor 126 | } 127 | if cfg.Appearance.BackgroundColor != nil { 128 | catnipCfg.DrawOptions.Colors.Background = cfg.Appearance.BackgroundColor 129 | } 130 | 131 | return catnipCfg 132 | } 133 | 134 | func (cfg Config) Save(path string) error { 135 | if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil { 136 | return errors.Wrap(err, "failed to mkdir -p") 137 | } 138 | 139 | f, err := os.Create(path) 140 | if err != nil { 141 | return errors.Wrap(err, "failed to create config file") 142 | } 143 | defer f.Close() 144 | 145 | enc := json.NewEncoder(f) 146 | enc.SetIndent("", "\t") 147 | 148 | if err := enc.Encode(cfg); err != nil { 149 | return errors.Wrap(err, "failed to encode JSON") 150 | } 151 | 152 | return nil 153 | } 154 | -------------------------------------------------------------------------------- /cmd/catnip-gtk/catnipgtk/config_appearance.go: -------------------------------------------------------------------------------- 1 | package catnipgtk 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/diamondburned/catnip-gtk" 7 | "github.com/diamondburned/gotk4-handy/pkg/handy" 8 | "github.com/diamondburned/gotk4/pkg/cairo" 9 | "github.com/diamondburned/gotk4/pkg/gdk/v3" 10 | "github.com/diamondburned/gotk4/pkg/gtk/v3" 11 | ) 12 | 13 | type Appearance struct { 14 | LineCap LineCap 15 | 16 | ForegroundColor OptionalColor 17 | BackgroundColor OptionalColor 18 | 19 | BarWidth float64 20 | SpaceWidth float64 // gap width 21 | MinimumClamp float64 22 | AntiAlias AntiAlias 23 | 24 | DrawStyle catnip.DrawStyle 25 | 26 | CustomCSS string 27 | } 28 | 29 | func symmetryString(s catnip.DrawStyle) string { 30 | switch s { 31 | case catnip.DrawVerticalBars: 32 | return "Vertical Bars" 33 | case catnip.DrawHorizontalBars: 34 | return "Horizontal Bars" 35 | case catnip.DrawLines: 36 | return "Lines" 37 | default: 38 | return "" 39 | } 40 | } 41 | 42 | func NewAppearance() Appearance { 43 | return Appearance{ 44 | LineCap: CapButt, 45 | BarWidth: 4, 46 | SpaceWidth: 1, 47 | MinimumClamp: 1, 48 | AntiAlias: AntiAliasGood, 49 | } 50 | } 51 | 52 | func (ac *Appearance) Page(apply func()) *handy.PreferencesPage { 53 | lineCapCombo := gtk.NewComboBoxText() 54 | lineCapCombo.SetVAlign(gtk.AlignCenter) 55 | addCombo(lineCapCombo, CapButt, CapRound) 56 | lineCapCombo.SetActiveID(string(ac.LineCap)) 57 | lineCapCombo.Show() 58 | lineCapCombo.Connect("changed", func(lineCapCombo *gtk.ComboBoxText) { 59 | ac.LineCap = LineCap(lineCapCombo.ActiveID()) 60 | apply() 61 | }) 62 | 63 | lineCapRow := handy.NewActionRow() 64 | lineCapRow.Add(lineCapCombo) 65 | lineCapRow.SetActivatableWidget(lineCapCombo) 66 | lineCapRow.SetTitle("Bar Cap") 67 | lineCapRow.SetSubtitle("Whether to draw the bars squared or rounded.") 68 | lineCapRow.Show() 69 | 70 | barSpin := gtk.NewSpinButtonWithRange(1, 100, 1) 71 | barSpin.SetVAlign(gtk.AlignCenter) 72 | barSpin.SetDigits(1) 73 | barSpin.SetValue(ac.BarWidth) 74 | barSpin.Show() 75 | barSpin.Connect("value-changed", func(barSpin *gtk.SpinButton) { 76 | ac.BarWidth = barSpin.Value() 77 | apply() 78 | }) 79 | 80 | barRow := handy.NewActionRow() 81 | barRow.Add(barSpin) 82 | barRow.SetActivatableWidget(barSpin) 83 | barRow.SetTitle("Bar/Line Width") 84 | barRow.SetSubtitle("The thickness of the bar or line in arbitrary unit.") 85 | barRow.Show() 86 | 87 | spaceSpin := gtk.NewSpinButtonWithRange(0, 100, 1) 88 | spaceSpin.SetVAlign(gtk.AlignCenter) 89 | spaceSpin.SetDigits(3) 90 | spaceSpin.SetValue(ac.SpaceWidth) 91 | spaceSpin.Show() 92 | spaceSpin.Connect("value-changed", func(spaceSpin *gtk.SpinButton) { 93 | ac.SpaceWidth = spaceSpin.Value() 94 | apply() 95 | }) 96 | 97 | spaceRow := handy.NewActionRow() 98 | spaceRow.Add(spaceSpin) 99 | spaceRow.SetActivatableWidget(spaceSpin) 100 | spaceRow.SetTitle("Gap Width") 101 | spaceRow.SetSubtitle("The width of the gaps between bars in arbitrary unit.") 102 | spaceRow.Show() 103 | 104 | clampSpin := gtk.NewSpinButtonWithRange(0, 25, 1) 105 | clampSpin.SetVAlign(gtk.AlignCenter) 106 | clampSpin.SetValue(ac.MinimumClamp) 107 | clampSpin.Show() 108 | clampSpin.Connect("value-changed", func(clampSpin *gtk.SpinButton) { 109 | ac.MinimumClamp = clampSpin.Value() 110 | apply() 111 | }) 112 | 113 | clampRow := handy.NewActionRow() 114 | clampRow.Add(clampSpin) 115 | clampRow.SetActivatableWidget(clampSpin) 116 | clampRow.SetTitle("Clamp Height") 117 | clampRow.SetSubtitle("The value at which the bar or line should be clamped to 0.") 118 | clampRow.Show() 119 | 120 | aaCombo := gtk.NewComboBoxText() 121 | aaCombo.SetVAlign(gtk.AlignCenter) 122 | addCombo( 123 | aaCombo, 124 | AntiAliasNone, 125 | AntiAliasGrey, 126 | AntiAliasSubpixel, 127 | AntiAliasFast, 128 | AntiAliasGood, 129 | AntiAliasBest, 130 | ) 131 | aaCombo.SetActiveID(string(ac.AntiAlias)) 132 | aaCombo.Show() 133 | aaCombo.Connect("changed", func(aaCombo *gtk.ComboBoxText) { 134 | ac.AntiAlias = AntiAlias(aaCombo.ActiveID()) 135 | apply() 136 | }) 137 | 138 | aaRow := handy.NewActionRow() 139 | aaRow.Add(aaCombo) 140 | aaRow.SetActivatableWidget(aaCombo) 141 | aaRow.SetTitle("Anti-Aliasing") 142 | aaRow.SetSubtitle("The anti-alias mode to draw with.") 143 | aaRow.Show() 144 | 145 | styleCombo := gtk.NewComboBoxText() 146 | styleCombo.SetVAlign(gtk.AlignCenter) 147 | styleCombo.AppendText(symmetryString(catnip.DrawVerticalBars)) 148 | styleCombo.AppendText(symmetryString(catnip.DrawHorizontalBars)) 149 | styleCombo.AppendText(symmetryString(catnip.DrawLines)) 150 | styleCombo.SetActive(int(ac.DrawStyle)) 151 | styleCombo.Show() 152 | styleCombo.Connect("changed", func(symmCombo *gtk.ComboBoxText) { 153 | ac.DrawStyle = catnip.DrawStyle(symmCombo.Active()) 154 | apply() 155 | }) 156 | 157 | styleRow := handy.NewActionRow() 158 | styleRow.Add(styleCombo) 159 | styleRow.SetActivatableWidget(styleCombo) 160 | styleRow.SetTitle("DrawStyle") 161 | styleRow.SetSubtitle("Whether to mirror bars vertically or horizontally.") 162 | styleRow.Show() 163 | 164 | barGroup := handy.NewPreferencesGroup() 165 | barGroup.SetTitle("Bars") 166 | barGroup.Add(lineCapRow) 167 | barGroup.Add(barRow) 168 | barGroup.Add(spaceRow) 169 | barGroup.Add(clampRow) 170 | barGroup.Add(aaRow) 171 | barGroup.Add(styleRow) 172 | barGroup.Show() 173 | 174 | fgRow := newColorRow(&ac.ForegroundColor, true, apply) 175 | fgRow.SetTitle("Foreground Color") 176 | fgRow.SetSubtitle("The color of the visualizer bars.") 177 | fgRow.Show() 178 | 179 | bgRow := newColorRow(&ac.BackgroundColor, false, apply) 180 | bgRow.SetTitle("Background Color") 181 | bgRow.SetSubtitle("The color of the background window.") 182 | bgRow.Show() 183 | 184 | colorGroup := handy.NewPreferencesGroup() 185 | colorGroup.SetTitle("Colors") 186 | colorGroup.Add(fgRow) 187 | colorGroup.Add(bgRow) 188 | colorGroup.Show() 189 | 190 | cssText := gtk.NewTextView() 191 | cssText.SetBorderWidth(5) 192 | cssText.SetMonospace(true) 193 | cssText.SetAcceptsTab(true) 194 | cssText.Show() 195 | 196 | cssBuf := cssText.Buffer() 197 | cssBuf.SetText(ac.CustomCSS) 198 | cssBuf.Connect("changed", func(cssBuf *gtk.TextBuffer) { 199 | start, end := cssBuf.Bounds() 200 | ac.CustomCSS = cssBuf.Text(start, end, false) 201 | apply() 202 | }) 203 | 204 | textScroll := gtk.NewScrolledWindow(nil, nil) 205 | textScroll.SetPolicy(gtk.PolicyAutomatic, gtk.PolicyNever) 206 | textScroll.SetSizeRequest(-1, 300) 207 | textScroll.SetVExpand(true) 208 | textScroll.Add(cssText) 209 | textScroll.Show() 210 | 211 | cssGroup := handy.NewPreferencesGroup() 212 | cssGroup.SetTitle("Custom CSS") 213 | cssGroup.Add(textScroll) 214 | cssGroup.Show() 215 | 216 | page := handy.NewPreferencesPage() 217 | page.SetTitle("Appearance") 218 | page.SetIconName("applications-graphics-symbolic") 219 | page.Add(barGroup) 220 | page.Add(colorGroup) 221 | page.Add(cssGroup) 222 | 223 | return page 224 | } 225 | 226 | func addCombo(c *gtk.ComboBoxText, vs ...interface{}) { 227 | for _, v := range vs { 228 | s := fmt.Sprint(v) 229 | c.Append(s, s) 230 | } 231 | } 232 | 233 | func newColorRow(optc *OptionalColor, fg bool, apply func()) *handy.ActionRow { 234 | color := gtk.NewColorButton() 235 | color.SetVAlign(gtk.AlignCenter) 236 | color.SetUseAlpha(true) 237 | color.Show() 238 | color.Connect("color-set", func(interface{}) { // hack around lack of marshaler 239 | cacc := catnip.ColorFromGDK(color.RGBA()) 240 | *optc = &cacc 241 | apply() 242 | }) 243 | 244 | var defaultRGBA *gdk.RGBA 245 | if fg { 246 | style := color.StyleContext() 247 | defaultRGBA = style.Color(gtk.StateFlagNormal) 248 | } else { 249 | rgba := gdk.NewRGBA(0, 0, 0, 0) 250 | defaultRGBA = &rgba 251 | } 252 | 253 | var rgba *gdk.RGBA 254 | if colorValue := *optc; colorValue != nil { 255 | cacc := *colorValue 256 | value := gdk.NewRGBA(cacc[0], cacc[1], cacc[2], cacc[3]) 257 | rgba = &value 258 | } 259 | 260 | if rgba != nil { 261 | color.SetRGBA(rgba) 262 | } else { 263 | color.SetRGBA(defaultRGBA) 264 | } 265 | 266 | reset := gtk.NewButtonFromIconName("edit-undo-symbolic", int(gtk.IconSizeButton)) 267 | reset.SetRelief(gtk.ReliefNone) 268 | reset.SetVAlign(gtk.AlignCenter) 269 | reset.SetTooltipText("Revert") 270 | reset.Show() 271 | reset.Connect("destroy", func(reset *gtk.Button) { color.Destroy() }) // prevent leak 272 | reset.Connect("clicked", func(reset *gtk.Button) { 273 | *optc = nil 274 | color.SetRGBA(defaultRGBA) 275 | apply() 276 | }) 277 | 278 | row := handy.NewActionRow() 279 | row.AddPrefix(reset) 280 | row.Add(color) 281 | row.SetActivatableWidget(color) 282 | 283 | return row 284 | } 285 | 286 | type OptionalColor = *catnip.CairoColor 287 | 288 | type LineCap string 289 | 290 | const ( 291 | CapButt LineCap = "Butt" 292 | CapRound LineCap = "Round" 293 | ) 294 | 295 | func (lc LineCap) AsLineCap() cairo.LineCap { 296 | switch lc { 297 | case CapButt: 298 | return cairo.LINE_CAP_BUTT 299 | case CapRound: 300 | return cairo.LINE_CAP_ROUND 301 | default: 302 | return CapButt.AsLineCap() 303 | } 304 | } 305 | 306 | type AntiAlias string 307 | 308 | const ( 309 | AntiAliasNone AntiAlias = "None" 310 | AntiAliasGrey AntiAlias = "Grey" 311 | AntiAliasSubpixel AntiAlias = "Subpixel" 312 | AntiAliasFast AntiAlias = "Fast" 313 | AntiAliasGood AntiAlias = "Good" 314 | AntiAliasBest AntiAlias = "Best" 315 | ) 316 | 317 | func (aa AntiAlias) AsAntialias() cairo.Antialias { 318 | switch aa { 319 | case AntiAliasNone: 320 | return cairo.ANTIALIAS_NONE 321 | case AntiAliasGrey: 322 | return cairo.ANTIALIAS_GRAY 323 | case AntiAliasSubpixel: 324 | return cairo.ANTIALIAS_SUBPIXEL 325 | case AntiAliasFast: 326 | return cairo.ANTIALIAS_FAST 327 | case AntiAliasGood: 328 | return cairo.ANTIALIAS_GOOD 329 | case AntiAliasBest: 330 | return cairo.ANTIALIAS_BEST 331 | default: 332 | return cairo.ANTIALIAS_GOOD 333 | } 334 | } 335 | -------------------------------------------------------------------------------- /cmd/catnip-gtk/catnipgtk/config_input.go: -------------------------------------------------------------------------------- 1 | package catnipgtk 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/diamondburned/gotk4-handy/pkg/handy" 7 | "github.com/diamondburned/gotk4/pkg/gtk/v3" 8 | "github.com/noriah/catnip/input" 9 | ) 10 | 11 | type Input struct { 12 | Backend string 13 | Device string 14 | DualChannel bool // .Monophonic 15 | 16 | backends []input.NamedBackend 17 | devices map[string][]input.Device // first is always default 18 | } 19 | 20 | func NewInput() (Input, error) { 21 | if len(input.Backends) == 0 { 22 | return Input{}, errors.New("no input backends found") 23 | } 24 | 25 | ic := Input{ 26 | backends: make([]input.NamedBackend, 0, len(input.Backends)), 27 | devices: make(map[string][]input.Device, len(input.Backends)), 28 | } 29 | 30 | ic.Update() 31 | ic.Backend = ic.backends[0].Name 32 | ic.Device = ic.devices[ic.Backend][0].String() 33 | ic.DualChannel = true 34 | 35 | return ic, nil 36 | } 37 | 38 | // Update updates the list of input devices. 39 | func (ic *Input) Update() { 40 | for _, backend := range input.Backends { 41 | devices, _ := backend.Devices() 42 | defdevc, _ := backend.DefaultDevice() 43 | 44 | // Skip broken backends. 45 | if len(devices) == 0 && defdevc == nil { 46 | continue 47 | } 48 | 49 | ic.backends = append(ic.backends, backend) 50 | 51 | // Fallback to the first device if there is no default. 52 | if defdevc == nil { 53 | defdevc = devices[0] 54 | } 55 | 56 | ic.devices[backend.Name] = append([]input.Device{defdevc}, devices...) 57 | } 58 | } 59 | 60 | func (ic *Input) Page(apply func()) *handy.PreferencesPage { 61 | deviceCombo := gtk.NewComboBoxText() 62 | deviceCombo.SetVAlign(gtk.AlignCenter) 63 | deviceCombo.Show() 64 | 65 | backendCombo := gtk.NewComboBoxText() 66 | backendCombo.SetVAlign(gtk.AlignCenter) 67 | backendCombo.Show() 68 | 69 | addDeviceCombo(deviceCombo, ic.devices[ic.Backend]) 70 | if device := findDevice(ic.devices[ic.Backend], ic.Device); device != nil { 71 | deviceCombo.SetActiveID("__" + ic.Device) 72 | } else { 73 | deviceCombo.SetActive(0) 74 | ic.Device = "" 75 | } 76 | deviceComboCallback := deviceCombo.Connect("changed", func(deviceCombo *gtk.ComboBoxText) { 77 | if ix := deviceCombo.Active(); ix > 0 { 78 | ic.Device = ic.devices[ic.Backend][ix].String() 79 | } else { 80 | ic.Device = "" // default 81 | } 82 | 83 | apply() 84 | }) 85 | 86 | addBackendCombo(backendCombo, ic.backends) 87 | if backend := findBackend(ic.backends, ic.Backend); backend.Backend != nil { 88 | backendCombo.SetActiveID(ic.Backend) 89 | } else { 90 | backendCombo.SetActive(0) 91 | ic.Backend = input.Backends[0].Name 92 | } 93 | backendCombo.Connect("changed", func(backendCombo *gtk.ComboBoxText) { 94 | ic.Backend = backendCombo.ActiveText() 95 | ic.Device = "" 96 | 97 | deviceCombo.HandlerBlock(deviceComboCallback) 98 | defer deviceCombo.HandlerUnblock(deviceComboCallback) 99 | 100 | // Update the list of devices when we're changing backend. 101 | ic.Update() 102 | 103 | deviceCombo.RemoveAll() 104 | addDeviceCombo(deviceCombo, ic.devices[ic.Backend]) 105 | deviceCombo.SetActive(0) 106 | 107 | apply() 108 | }) 109 | 110 | backendRow := handy.NewActionRow() 111 | backendRow.SetTitle("Backend") 112 | backendRow.SetSubtitle("The backend to use for audio input.") 113 | backendRow.Add(backendCombo) 114 | backendRow.SetActivatableWidget(backendCombo) 115 | backendRow.Show() 116 | 117 | deviceRow := handy.NewActionRow() 118 | deviceRow.SetTitle("Device") 119 | deviceRow.SetSubtitle("The device to use for audio input.") 120 | deviceRow.Add(deviceCombo) 121 | deviceRow.SetActivatableWidget(deviceCombo) 122 | deviceRow.Show() 123 | 124 | dualCh := gtk.NewSwitch() 125 | dualCh.SetVAlign(gtk.AlignCenter) 126 | dualCh.SetActive(ic.DualChannel) 127 | dualCh.Show() 128 | dualCh.Connect("state-set", func(dualCh *gtk.Switch, state bool) { 129 | ic.DualChannel = state 130 | apply() 131 | }) 132 | 133 | dualChRow := handy.NewActionRow() 134 | dualChRow.Add(dualCh) 135 | dualChRow.SetActivatableWidget(dualCh) 136 | dualChRow.SetTitle("Dual Channels") 137 | dualChRow.SetSubtitle("If enabled, will draw two channels mirrored instead of one.") 138 | dualChRow.Show() 139 | 140 | group := handy.NewPreferencesGroup() 141 | group.SetTitle("Input") 142 | group.Add(backendRow) 143 | group.Add(deviceRow) 144 | group.Add(dualChRow) 145 | group.Show() 146 | 147 | page := handy.NewPreferencesPage() 148 | page.SetTitle("Audio") 149 | page.SetIconName("audio-card-symbolic") 150 | page.Add(group) 151 | 152 | return page 153 | } 154 | 155 | func addDeviceCombo(deviceCombo *gtk.ComboBoxText, devices []input.Device) { 156 | for i, device := range devices { 157 | if i == 0 { 158 | deviceCombo.Append("default", "Default") 159 | } else { 160 | name := device.String() 161 | deviceCombo.Append("__"+name, name) 162 | } 163 | } 164 | } 165 | 166 | func addBackendCombo(backendCombo *gtk.ComboBoxText, backends []input.NamedBackend) { 167 | for _, backend := range backends { 168 | backendCombo.Append(backend.Name, backend.Name) 169 | } 170 | } 171 | 172 | func findDevice(devices []input.Device, str string) input.Device { 173 | if str == "" { 174 | return nil 175 | } 176 | for _, device := range devices { 177 | if device.String() == str { 178 | return device 179 | } 180 | } 181 | return nil 182 | } 183 | 184 | func findBackend(backends []input.NamedBackend, str string) input.NamedBackend { 185 | if str == "" { 186 | return input.NamedBackend{} 187 | } 188 | for _, backend := range backends { 189 | if backend.Name == str { 190 | return backend 191 | } 192 | } 193 | return input.NamedBackend{} 194 | } 195 | 196 | func (ic *Input) InputBackend() input.Backend { 197 | return findBackend(ic.backends, ic.Backend).Backend 198 | } 199 | 200 | func (ic *Input) InputDevice() input.Device { 201 | return findDevice(ic.devices[ic.Backend], ic.Device) 202 | } 203 | -------------------------------------------------------------------------------- /cmd/catnip-gtk/catnipgtk/config_visualizer.go: -------------------------------------------------------------------------------- 1 | package catnipgtk 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/diamondburned/catnip-gtk" 7 | "github.com/diamondburned/gotk4-handy/pkg/handy" 8 | "github.com/diamondburned/gotk4/pkg/gtk/v3" 9 | "gonum.org/v1/gonum/dsp/window" 10 | 11 | catnipwindow "github.com/noriah/catnip/dsp/window" 12 | ) 13 | 14 | type Visualizer struct { 15 | SampleRate float64 16 | SampleSize int 17 | FrameRate int 18 | 19 | WindowFn WindowFn 20 | SmoothFactor float64 21 | 22 | ScaleSlowWindow float64 23 | ScaleFastWindow float64 24 | ScaleDumpPercent float64 25 | ScaleResetDeviation float64 26 | } 27 | 28 | func NewVisualizer() Visualizer { 29 | return Visualizer{ 30 | SampleRate: 44100, 31 | SampleSize: 1024, 32 | FrameRate: 60, 33 | 34 | SmoothFactor: 65.69, 35 | WindowFn: BlackmanHarris, 36 | 37 | ScaleSlowWindow: 5, 38 | ScaleFastWindow: 4, 39 | ScaleDumpPercent: 0.75, 40 | ScaleResetDeviation: 1.0, 41 | } 42 | } 43 | 44 | func (v *Visualizer) Page(apply func()) *handy.PreferencesPage { 45 | samplingGroup := handy.NewPreferencesGroup() 46 | 47 | updateSamplingLabel := func() { 48 | fₛ := v.SampleRate / float64(v.SampleSize) 49 | samplingGroup.SetTitle(fmt.Sprintf( 50 | "Sampling and Drawing (fₛ ≈ %.1f samples/s, latency ≈ %.1fms)", 51 | fₛ, 1000/fₛ, 52 | )) 53 | } 54 | updateSamplingLabel() 55 | 56 | sampleRateSpin := gtk.NewSpinButtonWithRange(4000, 192000, 4000) 57 | sampleRateSpin.SetVAlign(gtk.AlignCenter) 58 | sampleRateSpin.SetDigits(0) 59 | sampleRateSpin.SetValue(v.SampleRate) 60 | sampleRateSpin.Show() 61 | sampleRateSpin.Connect("value-changed", func(sampleRateSpin *gtk.SpinButton) { 62 | v.SampleRate = sampleRateSpin.Value() 63 | updateSamplingLabel() 64 | apply() 65 | }) 66 | 67 | sampleRateRow := handy.NewActionRow() 68 | sampleRateRow.Add(sampleRateSpin) 69 | sampleRateRow.SetActivatableWidget(sampleRateSpin) 70 | sampleRateRow.SetTitle("Sample Rate (Hz)") 71 | sampleRateRow.SetSubtitle("The sample rate to record; higher is more accurate.") 72 | sampleRateRow.Show() 73 | 74 | sampleSizeSpin := gtk.NewSpinButtonWithRange(1, 102400, 128) 75 | sampleSizeSpin.SetVAlign(gtk.AlignCenter) 76 | sampleSizeSpin.SetDigits(0) 77 | sampleSizeSpin.SetValue(float64(v.SampleSize)) 78 | sampleSizeSpin.Show() 79 | sampleSizeSpin.Connect("value-changed", func(sampleSizeSpin *gtk.SpinButton) { 80 | v.SampleSize = sampleSizeSpin.ValueAsInt() 81 | updateSamplingLabel() 82 | apply() 83 | }) 84 | 85 | sampleSizeRow := handy.NewActionRow() 86 | sampleSizeRow.Add(sampleSizeSpin) 87 | sampleSizeRow.SetActivatableWidget(sampleSizeSpin) 88 | sampleSizeRow.SetTitle("Sample Size") 89 | sampleSizeRow.SetSubtitle("The sample size to record; higher is more accurate but slower.") 90 | sampleSizeRow.Show() 91 | 92 | frameRateSpin := gtk.NewSpinButtonWithRange(5, 240, 5) 93 | frameRateSpin.SetVAlign(gtk.AlignCenter) 94 | frameRateSpin.SetDigits(0) 95 | frameRateSpin.SetValue(float64(v.FrameRate)) 96 | frameRateSpin.Show() 97 | frameRateSpin.Connect("value-changed", func(frameRateSpin *gtk.SpinButton) { 98 | v.FrameRate = frameRateSpin.ValueAsInt() 99 | apply() 100 | }) 101 | 102 | frameRateRow := handy.NewActionRow() 103 | frameRateRow.Add(frameRateSpin) 104 | frameRateRow.SetActivatableWidget(frameRateSpin) 105 | frameRateRow.SetTitle("Frame Rate (fps)") 106 | frameRateRow.SetSubtitle("The frame rate to draw in parallel with the sampling; " + 107 | "this affects the smoothing.") 108 | frameRateRow.Show() 109 | 110 | samplingGroup.Add(sampleRateRow) 111 | samplingGroup.Add(sampleSizeRow) 112 | samplingGroup.Add(frameRateRow) 113 | samplingGroup.Show() 114 | 115 | windowCombo := gtk.NewComboBoxText() 116 | windowCombo.SetVAlign(gtk.AlignCenter) 117 | windowCombo.Show() 118 | for _, windowFn := range windowFns { 119 | windowCombo.Append(string(windowFn), string(windowFn)) 120 | } 121 | windowCombo.SetActiveID(string(v.WindowFn)) 122 | windowCombo.Connect("changed", func(windowCombo *gtk.ComboBoxText) { 123 | v.WindowFn = WindowFn(windowCombo.ActiveID()) 124 | apply() 125 | }) 126 | 127 | windowRow := handy.NewActionRow() 128 | windowRow.Add(windowCombo) 129 | windowRow.SetActivatableWidget(windowCombo) 130 | windowRow.SetTitle("Window Function") 131 | windowRow.SetSubtitle("The window function to use for signal processing.") 132 | windowRow.Show() 133 | 134 | smoothFactorSpin := gtk.NewSpinButtonWithRange(0, 100, 2) 135 | smoothFactorSpin.SetVAlign(gtk.AlignCenter) 136 | smoothFactorSpin.SetDigits(2) 137 | smoothFactorSpin.SetValue(v.SmoothFactor) 138 | smoothFactorSpin.Show() 139 | smoothFactorSpin.Connect("value-changed", func(smoothFactorSpin *gtk.SpinButton) { 140 | v.SmoothFactor = smoothFactorSpin.Value() 141 | apply() 142 | }) 143 | 144 | smoothFactorRow := handy.NewActionRow() 145 | smoothFactorRow.Add(smoothFactorSpin) 146 | smoothFactorRow.SetActivatableWidget(smoothFactorSpin) 147 | smoothFactorRow.SetTitle("Smooth Factor") 148 | smoothFactorRow.SetSubtitle("The variable for smoothing; higher means smoother.") 149 | smoothFactorRow.Show() 150 | 151 | signalProcGroup := handy.NewPreferencesGroup() 152 | signalProcGroup.SetTitle("Signal Processing") 153 | signalProcGroup.Add(windowRow) 154 | signalProcGroup.Add(smoothFactorRow) 155 | signalProcGroup.Show() 156 | 157 | page := handy.NewPreferencesPage() 158 | page.SetTitle("Visualizer") 159 | page.SetIconName("preferences-desktop-display-symbolic") 160 | page.Add(samplingGroup) 161 | page.Add(signalProcGroup) 162 | 163 | return page 164 | } 165 | 166 | type WindowFn string 167 | 168 | const ( 169 | BartlettHann WindowFn = "Bartlett–Hann" 170 | Blackman WindowFn = "Blackman" 171 | BlackmanHarris WindowFn = "Blackman–Harris" 172 | BlackmanNuttall WindowFn = "Blackman–Nuttall" 173 | FlatTop WindowFn = "Flat Top" 174 | Hamming WindowFn = "Hamming" 175 | Hann WindowFn = "Hann" 176 | Lanczos WindowFn = "Lanczos" 177 | Nuttall WindowFn = "Nuttall" 178 | Rectangular WindowFn = "Rectangular" 179 | Sine WindowFn = "Sine" 180 | Triangular WindowFn = "Triangular" 181 | CosineSum WindowFn = "Cosine-Sum" 182 | PlanckTaper WindowFn = "Planck–Taper" 183 | ) 184 | 185 | var windowFns = []WindowFn{ 186 | BartlettHann, 187 | Blackman, 188 | BlackmanHarris, 189 | BlackmanNuttall, 190 | FlatTop, 191 | Hamming, 192 | Hann, 193 | Lanczos, 194 | Nuttall, 195 | Rectangular, 196 | Sine, 197 | Triangular, 198 | CosineSum, 199 | PlanckTaper, 200 | } 201 | 202 | func (wfn WindowFn) AsFunction() catnipwindow.Function { 203 | switch wfn { 204 | case BartlettHann: 205 | return catnip.WrapExternalWindowFn(window.BartlettHann) 206 | case Blackman: 207 | return catnip.WrapExternalWindowFn(window.Blackman) 208 | case BlackmanHarris: 209 | return catnip.WrapExternalWindowFn(window.BlackmanHarris) 210 | case BlackmanNuttall: 211 | return catnip.WrapExternalWindowFn(window.BlackmanNuttall) 212 | case FlatTop: 213 | return catnip.WrapExternalWindowFn(window.FlatTop) 214 | case Hamming: 215 | return catnip.WrapExternalWindowFn(window.Hamming) 216 | case Hann: 217 | return catnip.WrapExternalWindowFn(window.Hann) 218 | case Lanczos: 219 | return catnip.WrapExternalWindowFn(window.Lanczos) 220 | case Nuttall: 221 | return catnip.WrapExternalWindowFn(window.Nuttall) 222 | case Rectangular: 223 | return catnip.WrapExternalWindowFn(window.Rectangular) 224 | case Sine: 225 | return catnip.WrapExternalWindowFn(window.Sine) 226 | case Triangular: 227 | return catnip.WrapExternalWindowFn(window.Triangular) 228 | case CosineSum: 229 | return func(buf []float64) { catnipwindow.CosSum(buf, 0.5) } 230 | case PlanckTaper: 231 | return func(buf []float64) { catnipwindow.PlanckTaper(buf, 0.5) } 232 | default: 233 | return Blackman.AsFunction() 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /cmd/catnip-gtk/catnipgtk/session.go: -------------------------------------------------------------------------------- 1 | package catnipgtk 2 | 3 | import ( 4 | "fmt" 5 | "html" 6 | "log" 7 | 8 | "github.com/diamondburned/catnip-gtk" 9 | "github.com/diamondburned/gotk4/pkg/glib/v2" 10 | "github.com/diamondburned/gotk4/pkg/gtk/v3" 11 | ) 12 | 13 | type Session struct { 14 | gtk.Stack 15 | 16 | Error *gtk.Label 17 | 18 | Area *gtk.DrawingArea 19 | Drawer *catnip.Drawer 20 | 21 | css *gtk.CSSProvider 22 | config *Config 23 | saving glib.SourceHandle 24 | } 25 | 26 | func NewSession(cfg *Config) *Session { 27 | errLabel := gtk.NewLabel("") 28 | errLabel.Show() 29 | 30 | area := gtk.NewDrawingArea() 31 | area.Show() 32 | 33 | stack := gtk.NewStack() 34 | stack.AddNamed(area, "area") 35 | stack.AddNamed(errLabel, "error") 36 | stack.SetVisibleChildName("area") 37 | stack.Show() 38 | 39 | css := gtk.NewCSSProvider() 40 | 41 | session := &Session{ 42 | Stack: *stack, 43 | Error: errLabel, 44 | Area: area, 45 | 46 | config: cfg, 47 | css: css, 48 | } 49 | 50 | stack.Connect("realize", func(stack *gtk.Stack) { 51 | gtk.StyleContextAddProviderForScreen( 52 | stack.Screen(), css, 53 | uint(gtk.STYLE_PROVIDER_PRIORITY_USER), 54 | ) 55 | }) 56 | 57 | return session 58 | } 59 | 60 | func (s Session) Stop() { 61 | if s.Drawer != nil { 62 | s.Drawer.Stop() 63 | s.Drawer = nil 64 | } 65 | } 66 | 67 | func (s *Session) Reload() { 68 | if s.saving == 0 { 69 | s.saving = glib.TimeoutAdd(150, func() { 70 | s.reload() 71 | s.saving = 0 72 | }) 73 | } 74 | } 75 | 76 | func (s *Session) reload() { 77 | catnipCfg := s.config.Transform() 78 | 79 | if err := s.css.LoadFromData(s.config.Appearance.CustomCSS); err != nil { 80 | log.Println("CSS error:", err) 81 | } 82 | 83 | s.Stop() 84 | 85 | drawer := catnip.NewDrawer(s.Area, catnipCfg) 86 | drawer.SetBackend(s.config.Input.InputBackend()) 87 | drawer.SetDevice(s.config.Input.InputDevice()) 88 | 89 | s.Stack.SetVisibleChild(s.Area) 90 | s.Drawer = drawer 91 | 92 | go func() { 93 | if err := drawer.Start(); err != nil { 94 | log.Println("Error starting Drawer:", err) 95 | glib.IdleAdd(func() { 96 | // Ensure this drawer is still being displayed. 97 | if s.Drawer == drawer { 98 | s.Error.SetMarkup(errorText(err)) 99 | s.Stack.SetVisibleChild(s.Error) 100 | } 101 | }) 102 | } 103 | }() 104 | } 105 | 106 | func errorText(err error) string { 107 | return fmt.Sprintf( 108 | `Error: %s`, 109 | html.EscapeString(err.Error()), 110 | ) 111 | } 112 | -------------------------------------------------------------------------------- /cmd/catnip-gtk/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | 7 | "github.com/diamondburned/catnip-gtk/cmd/catnip-gtk/catnipgtk" 8 | "github.com/diamondburned/gotk4-handy/pkg/handy" 9 | "github.com/diamondburned/gotk4/pkg/core/glib" 10 | "github.com/diamondburned/gotk4/pkg/gdk/v3" 11 | "github.com/diamondburned/gotk4/pkg/gtk/v3" 12 | 13 | _ "github.com/noriah/catnip/input/ffmpeg" 14 | _ "github.com/noriah/catnip/input/parec" 15 | _ "github.com/noriah/catnip/input/portaudio" 16 | ) 17 | 18 | func main() { 19 | cfg, err := catnipgtk.ReadUserConfig() 20 | if err != nil { 21 | log.Fatalln("failed to read config:", err) 22 | } 23 | 24 | app := gtk.NewApplication("com.github.diamondburned.catnip-gtk", 0) 25 | app.ConnectActivate(func() { 26 | handy.Init() 27 | 28 | session := catnipgtk.NewSession(cfg) 29 | session.Reload() 30 | session.Show() 31 | 32 | evbox := gtk.NewEventBox() 33 | evbox.Add(session) 34 | evbox.Show() 35 | 36 | h := handy.NewWindowHandle() 37 | h.Add(evbox) 38 | h.Show() 39 | 40 | w := handy.NewApplicationWindow() 41 | w.SetApplication(app) 42 | w.SetDefaultSize(cfg.WindowSize.Width, cfg.WindowSize.Height) 43 | w.Add(h) 44 | w.Show() 45 | 46 | var resizeSave glib.SourceHandle 47 | w.Connect("size-allocate", func() { 48 | cfg.WindowSize.Width = w.AllocatedWidth() 49 | cfg.WindowSize.Height = w.AllocatedHeight() 50 | 51 | if resizeSave == 0 { 52 | resizeSave = glib.TimeoutSecondsAdd(1, func() { 53 | save(cfg) 54 | resizeSave = 0 55 | }) 56 | } 57 | }) 58 | 59 | wstyle := w.StyleContext() 60 | wstyle.AddClass("catnip") 61 | 62 | prefMenu := gtk.NewMenuItemWithLabel("Preferences") 63 | prefMenu.Show() 64 | prefMenu.Connect("activate", func(prefMenu *gtk.MenuItem) { 65 | cfgw := cfg.PreferencesWindow(session.Reload) 66 | cfgw.Connect("destroy", func(*handy.PreferencesWindow) { save(cfg) }) 67 | cfgw.Show() 68 | }) 69 | 70 | aboutMenu := gtk.NewMenuItemWithLabel("About") 71 | aboutMenu.Show() 72 | aboutMenu.Connect("activate", func(aboutMenu *gtk.MenuItem) { 73 | about := About() 74 | about.SetTransientFor(&w.Window) 75 | about.Show() 76 | }) 77 | 78 | quitMenu := gtk.NewMenuItemWithLabel("Quit") 79 | quitMenu.Show() 80 | quitMenu.Connect("activate", func(*gtk.MenuItem) { w.Destroy() }) 81 | 82 | menu := gtk.NewMenu() 83 | menu.Append(prefMenu) 84 | menu.Append(aboutMenu) 85 | menu.Append(quitMenu) 86 | 87 | evbox.Connect("button-press-event", func(evbox *gtk.EventBox, ev *gdk.Event) { 88 | if b := ev.AsButton(); b.Button() == gdk.BUTTON_SECONDARY { 89 | menu.PopupAtPointer(ev) 90 | } 91 | }) 92 | }) 93 | 94 | os.Exit(app.Run(os.Args)) 95 | } 96 | 97 | func save(cfg *catnipgtk.Config) { 98 | if catnipgtk.UserConfigPath == "" { 99 | return 100 | } 101 | 102 | if err := cfg.Save(catnipgtk.UserConfigPath); err != nil { 103 | log.Println("failed to save config:", err) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /drawer.go: -------------------------------------------------------------------------------- 1 | package catnip 2 | 3 | import ( 4 | "context" 5 | "image/color" 6 | "math" 7 | "sync" 8 | 9 | "github.com/diamondburned/gotk4/pkg/cairo" 10 | "github.com/diamondburned/gotk4/pkg/core/glib" 11 | "github.com/diamondburned/gotk4/pkg/gdk/v3" 12 | "github.com/diamondburned/gotk4/pkg/gtk/v3" 13 | "github.com/noriah/catnip/dsp" 14 | "github.com/noriah/catnip/fft" 15 | "github.com/noriah/catnip/input" 16 | 17 | catniputil "github.com/noriah/catnip/util" 18 | ) 19 | 20 | type CairoColor [4]float64 21 | 22 | func ColorFromGDK(rgba *gdk.RGBA) CairoColor { 23 | if rgba == nil { 24 | return CairoColor{} 25 | } 26 | return CairoColor{ 27 | rgba.Red(), 28 | rgba.Green(), 29 | rgba.Blue(), 30 | rgba.Alpha(), 31 | } 32 | } 33 | 34 | func (cc CairoColor) RGBA() (r, g, b, a uint32) { 35 | r = uint32(cc[0] * 0xFFFF) 36 | g = uint32(cc[1] * 0xFFFF) 37 | b = uint32(cc[2] * 0xFFFF) 38 | a = uint32(cc[3] * 0xFFFF) 39 | return 40 | } 41 | 42 | // DrawQueuer is a custom widget interface that allows draw queueing. 43 | type DrawQueuer interface { 44 | QueueDraw() 45 | } 46 | 47 | var _ DrawQueuer = (*gtk.Widget)(nil) 48 | 49 | // Drawer is the separated drawer state without any widget. 50 | type Drawer struct { 51 | parent *gtk.Widget 52 | handle []glib.SignalHandle 53 | 54 | cfg Config 55 | ctx context.Context 56 | cancel context.CancelFunc 57 | 58 | fg CairoColor 59 | bg CairoColor 60 | 61 | // total bar + space width 62 | binWidth float64 63 | // channels; 1 if monophonic 64 | channels int 65 | 66 | backend input.Backend 67 | device input.Device 68 | inputCfg input.SessionConfig 69 | 70 | fftPlans []*fft.Plan 71 | fftBuf []complex128 72 | spectrum dsp.Spectrum 73 | 74 | slowWindow *catniputil.MovingWindow 75 | fastWindow *catniputil.MovingWindow 76 | 77 | background struct { 78 | surface *cairo.Surface 79 | width float64 80 | height float64 81 | } 82 | 83 | shared struct { 84 | sync.Mutex 85 | 86 | // Input buffers. 87 | readBuf [][]input.Sample 88 | writeBuf [][]input.Sample 89 | 90 | // Output bars. 91 | barBufs [][]input.Sample 92 | 93 | cairoWidth float64 94 | barWidth float64 95 | barCount int 96 | scale float64 97 | peak float64 98 | quiet int 99 | 100 | paused bool 101 | } 102 | } 103 | 104 | const ( 105 | quietThreshold = 25 106 | peakThreshold = 0.001 107 | ) 108 | 109 | // NewDrawer creates a separated drawer state. The given drawQueuer will be 110 | // called every redrawn frame. 111 | func NewDrawer(widget gtk.Widgetter, cfg Config) *Drawer { 112 | ctx, cancel := context.WithCancel(context.Background()) 113 | 114 | d := &Drawer{ 115 | parent: gtk.BaseWidget(widget), 116 | cfg: cfg, 117 | ctx: ctx, 118 | cancel: cancel, 119 | 120 | fg: getColor(cfg.Colors.Foreground, nil, CairoColor{0, 0, 0, 1}), 121 | bg: getColor(cfg.Colors.Background, nil, CairoColor{0, 0, 0, 0}), 122 | 123 | channels: 2, 124 | // Weird Cairo tricks require multiplication and division by 2. Unsure 125 | // why. 126 | binWidth: cfg.BarWidth + (cfg.SpaceWidth * 2), 127 | } 128 | 129 | if cfg.Monophonic { 130 | d.channels = 1 131 | } 132 | 133 | w := gtk.BaseWidget(widget) 134 | 135 | d.handle = []glib.SignalHandle{ 136 | w.Connect("draw", d.Draw), 137 | w.Connect("destroy", d.Stop), 138 | w.ConnectStyleUpdated(func() { 139 | // Invalidate the background. 140 | d.background.surface = nil 141 | 142 | styleCtx := w.StyleContext() 143 | transparent := gdk.NewRGBA(0, 0, 0, 0) 144 | 145 | d.fg = getColor(d.cfg.Colors.Foreground, styleCtx.Color(gtk.StateFlagNormal), d.fg) 146 | d.bg = getColor(d.cfg.Colors.Background, &transparent, d.bg) 147 | }), 148 | } 149 | 150 | return d 151 | } 152 | 153 | // getColor gets the color from the given c Color interface. If c is nil, then 154 | // the color is taken from the given gdk.RGBA instead. 155 | func getColor(c color.Color, rgba *gdk.RGBA, fallback CairoColor) (cairoC CairoColor) { 156 | if c != nil { 157 | switch c := c.(type) { 158 | case CairoColor: 159 | return c 160 | case *CairoColor: 161 | return *c 162 | } 163 | 164 | r, g, b, a := c.RGBA() 165 | 166 | cairoC[0] = float64(r) / 0xFFFF 167 | cairoC[1] = float64(g) / 0xFFFF 168 | cairoC[2] = float64(b) / 0xFFFF 169 | cairoC[3] = float64(a) / 0xFFFF 170 | 171 | return 172 | } 173 | 174 | if rgba != nil { 175 | return ColorFromGDK(rgba) 176 | } 177 | 178 | return fallback 179 | } 180 | 181 | // SetPaused will silent all inputs if true. 182 | func (d *Drawer) SetPaused(paused bool) { 183 | d.shared.Lock() 184 | d.shared.paused = paused 185 | d.shared.Unlock() 186 | } 187 | 188 | // AllocatedSizeGetter is any widget that can be obtained dimensions of. This is 189 | // used for the Draw method. 190 | type AllocatedSizeGetter interface { 191 | GetAllocatedWidth() int 192 | GetAllocatedHeight() int 193 | } 194 | 195 | // SetBackend overrides the given Backend in the config. 196 | func (d *Drawer) SetBackend(backend input.Backend) { 197 | d.backend = backend 198 | } 199 | 200 | // SetDevice overrides the given Device in the config. 201 | func (d *Drawer) SetDevice(device input.Device) { 202 | d.device = device 203 | } 204 | 205 | // Stop signals the event loop to stop. It does not block. 206 | func (d *Drawer) Stop() { 207 | d.cancel() 208 | for _, handle := range d.handle { 209 | d.parent.HandlerDisconnect(handle) 210 | } 211 | } 212 | 213 | // Draw is bound to the draw signal. Although Draw won't crash if Drawer is not 214 | // started yet, the drawn result is undefined. 215 | func (d *Drawer) Draw(widget gtk.Widgetter, cr *cairo.Context) { 216 | w := gtk.BaseWidget(widget) 217 | 218 | alloc := w.Allocation() 219 | width := float64(d.cfg.even(alloc.Width())) 220 | height := float64(d.cfg.even(alloc.Height())) 221 | 222 | cr.SetAntialias(d.cfg.AntiAlias) 223 | cr.SetLineWidth(d.cfg.BarWidth) 224 | cr.SetLineJoin(d.cfg.LineJoin) 225 | cr.SetLineCap(d.cfg.LineCap) 226 | 227 | cr.SetSourceRGBA(d.bg[0], d.bg[1], d.bg[2], d.bg[3]) 228 | cr.Paint() 229 | 230 | if d.background.surface == nil || d.background.width != width || d.background.height != height { 231 | // Render the background onto the surface and use that as the source 232 | // surface for our context. 233 | surface := cr.GetTarget().CreateSimilar(cairo.CONTENT_COLOR_ALPHA, int(width), int(height)) 234 | 235 | cr := cairo.Create(surface) 236 | 237 | // Draw the user-requested line color. 238 | cr.SetSourceRGBA(d.fg[0], d.fg[1], d.fg[2], d.fg[3]) 239 | cr.Paint() 240 | 241 | // Draw the CSS background. 242 | gtk.RenderBackground(w.StyleContext(), cairo.Create(surface), 0, 0, width, height) 243 | 244 | d.background.width = width 245 | d.background.height = height 246 | d.background.surface = surface 247 | } 248 | 249 | cr.SetSourceSurface(d.background.surface, 0, 0) 250 | 251 | d.shared.Lock() 252 | defer d.shared.Unlock() 253 | 254 | d.shared.cairoWidth = width 255 | 256 | switch d.cfg.DrawStyle { 257 | case DrawVerticalBars: 258 | d.drawVertically(width, height, cr) 259 | case DrawHorizontalBars: 260 | d.drawHorizontally(width, height, cr) 261 | case DrawLines: 262 | d.drawLines(width, height, cr) 263 | } 264 | } 265 | 266 | func (d *Drawer) drawVertically(width, height float64, cr *cairo.Context) { 267 | bins := d.shared.barBufs 268 | center := (height - d.cfg.MinimumClamp) / 2 269 | scale := center / d.shared.scale 270 | 271 | if center < 0 { 272 | center = 0 273 | } 274 | 275 | // Round up the width so we don't draw a partial bar. 276 | xColMax := math.Round(width/d.binWidth) * d.binWidth 277 | 278 | // Calculate the starting position so it's in the middle. 279 | xCol := d.binWidth/2 + (width-xColMax)/2 280 | 281 | lBins := bins[0] 282 | rBins := bins[1%len(bins)] 283 | 284 | for xBin := 0; xBin < d.shared.barCount && xCol < xColMax; xBin++ { 285 | lStop := calculateBar(lBins[xBin]*scale, center, d.cfg.MinimumClamp) 286 | rStop := calculateBar(rBins[xBin]*scale, center, d.cfg.MinimumClamp) 287 | 288 | if !math.IsNaN(lStop) && !math.IsNaN(rStop) { 289 | d.drawBar(cr, xCol, lStop, height-rStop) 290 | } else if d.cfg.MinimumClamp > 0 { 291 | d.drawBar(cr, xCol, center, center+d.cfg.MinimumClamp) 292 | } 293 | 294 | xCol += d.binWidth 295 | } 296 | } 297 | 298 | func (d *Drawer) drawHorizontally(width, height float64, cr *cairo.Context) { 299 | bins := d.shared.barBufs 300 | scale := height / d.shared.scale 301 | 302 | delta := 1 303 | 304 | // Round up the width so we don't draw a partial bar. 305 | xColMax := math.Round(width/d.binWidth) * d.binWidth 306 | 307 | xBin := 0 308 | xCol := (d.binWidth)/2 + (width-xColMax)/2 309 | 310 | for _, chBins := range bins { 311 | for xBin < d.shared.barCount && xBin >= 0 && xCol < xColMax { 312 | stop := calculateBar(chBins[xBin]*scale, height, d.cfg.MinimumClamp) 313 | 314 | // Don't draw if stop is NaN for some reason. 315 | if !math.IsNaN(stop) { 316 | d.drawBar(cr, xCol, height, stop) 317 | } 318 | 319 | xCol += d.binWidth 320 | xBin += delta 321 | } 322 | 323 | delta = -delta 324 | xBin += delta // ensure xBin is not out of bounds first. 325 | } 326 | } 327 | 328 | func (d *Drawer) drawBar(cr *cairo.Context, xCol, to, from float64) { 329 | cr.MoveTo(d.cfg.Offsets.apply(xCol, d.cfg.round(from))) 330 | cr.LineTo(d.cfg.Offsets.apply(xCol, d.cfg.round(to))) 331 | cr.Stroke() 332 | } 333 | 334 | func calculateBar(value, height, clamp float64) float64 { 335 | bar := math.Max(math.Min(value, height), clamp) - clamp 336 | // Rescale the lost value. 337 | bar += bar * (clamp / height) 338 | 339 | return height - bar 340 | } 341 | 342 | func (d *Drawer) drawLines(width, height float64, cr *cairo.Context) { 343 | bins := d.shared.barBufs 344 | ceil := calculateBar(0, height, d.cfg.MinimumClamp) 345 | scale := height / d.shared.scale 346 | 347 | // Override the bar buffer with the scaled values. I'm unsure why this is 348 | // needed instead of doing it all in one loop. 349 | for _, ch := range bins { 350 | for bar := 0; bar < d.shared.barCount; bar++ { 351 | v := calculateBar(ch[bar]*scale, height, d.cfg.MinimumClamp) 352 | if math.IsNaN(v) { 353 | v = ceil 354 | } 355 | 356 | ch[bar] = v 357 | } 358 | } 359 | 360 | // Flip this to iterate backwards and draw the other channel. 361 | delta := +1 362 | 363 | x := 0.0 364 | // Recalculate the bin width to be equally distributed throughout the width 365 | // without any gaps on either end. Ignore the last bar (-1-1) because it 366 | // peaks up for some reason. 367 | barCount := math.Min( 368 | math.Round(width/d.binWidth), 369 | float64((d.shared.barCount-2)*d.channels), 370 | ) 371 | binWidth := width / barCount 372 | 373 | var bar int 374 | first := true 375 | 376 | for _, ch := range bins { 377 | // If we're iterating backwards, then check the lower bound, or 378 | // if we're iterating forwards, then check the upper bound. 379 | // Ignore the last bar for the same reason above. 380 | for bar >= 0 && bar < d.shared.barCount-1 { 381 | y := ch[bar] 382 | 383 | if first { 384 | // First. 385 | cr.MoveTo(x, y) 386 | first = false 387 | } else if next := bar + delta; next >= 0 && next < len(ch) { 388 | // Average out the middle Y point with the next one for 389 | // smoothing. 390 | quadCurve(cr, x, y, x+(binWidth)/2, (y+ch[next])/2) 391 | } else { 392 | // Ignore the last point's value and just use the ceiling. 393 | cr.LineTo(x, y) 394 | } 395 | 396 | x += binWidth 397 | bar += delta 398 | } 399 | 400 | delta = -delta 401 | bar += delta 402 | } 403 | 404 | // Commit the line. 405 | cr.Stroke() 406 | } 407 | 408 | // quadCurve draws a quadratic bezier curve into the given Cairo context. 409 | func quadCurve(t *cairo.Context, p1x, p1y, p2x, p2y float64) { 410 | p0x, p0y := t.GetCurrentPoint() 411 | 412 | // https://stackoverflow.com/a/55034115 413 | cp1x := p0x + ((2.0 / 3.0) * (p1x - p0x)) 414 | cp1y := p0y + ((2.0 / 3.0) * (p1y - p0y)) 415 | 416 | cp2x := p2x + ((2.0 / 3.0) * (p1x - p2x)) 417 | cp2y := p2y + ((2.0 / 3.0) * (p1y - p2y)) 418 | 419 | t.CurveTo(cp1x, cp1y, cp2x, cp2y, p2x, p2y) 420 | } 421 | -------------------------------------------------------------------------------- /drawer_start.go: -------------------------------------------------------------------------------- 1 | package catnip 2 | 3 | import ( 4 | "math" 5 | 6 | "github.com/diamondburned/gotk4/pkg/core/glib" 7 | "github.com/noriah/catnip/dsp" 8 | "github.com/noriah/catnip/fft" 9 | "github.com/noriah/catnip/input" 10 | "github.com/pkg/errors" 11 | 12 | catniputil "github.com/noriah/catnip/util" 13 | ) 14 | 15 | // Start starts the area. This function blocks permanently until the audio loop 16 | // is dead, so it should be called inside a goroutine. This function should not 17 | // be called more than once, else it will panic. 18 | // 19 | // The loop will automatically close when the DrawingArea is destroyed. 20 | func (d *Drawer) Start() (err error) { 21 | if d.shared.barBufs != nil { 22 | // Panic is reasonable, as calling Start() multiple times (in multiple 23 | // goroutines) may cause undefined behaviors. 24 | panic("BUG: catnip.Area is already started.") 25 | } 26 | 27 | if d.backend == nil { 28 | d.backend, err = d.cfg.InitBackend() 29 | if err != nil { 30 | return errors.Wrap(err, "failed to initialize input backend") 31 | } 32 | } 33 | defer d.backend.Close() 34 | 35 | if d.device == nil { 36 | d.device, err = d.cfg.InitDevice(d.backend) 37 | if err != nil { 38 | return err 39 | } 40 | } 41 | 42 | d.shared.scale = d.cfg.Scaling.StaticScale 43 | if d.shared.scale == 0 { 44 | var ( 45 | slowMax = int(d.cfg.Scaling.SlowWindow*d.cfg.SampleRate) / d.cfg.SampleSize * 2 46 | fastMax = int(d.cfg.Scaling.FastWindow*d.cfg.SampleRate) / d.cfg.SampleSize * 2 47 | windowData = make([]float64, slowMax+fastMax) 48 | ) 49 | 50 | d.slowWindow = &catniputil.MovingWindow{ 51 | Data: windowData[0:slowMax], 52 | Capacity: slowMax, 53 | } 54 | 55 | d.fastWindow = &catniputil.MovingWindow{ 56 | Data: windowData[slowMax : slowMax+fastMax], 57 | Capacity: fastMax, 58 | } 59 | } 60 | 61 | d.spectrum = dsp.Spectrum{ 62 | SampleRate: d.cfg.SampleRate, 63 | SampleSize: d.cfg.SampleSize, 64 | Bins: make([]dsp.Bin, d.cfg.SampleSize), 65 | } 66 | d.spectrum.SetSmoothing(d.cfg.SmoothFactor / 100) 67 | 68 | sessionConfig := input.SessionConfig{ 69 | Device: d.device, 70 | FrameSize: int(d.channels), 71 | SampleSize: d.cfg.SampleSize, 72 | SampleRate: d.cfg.SampleRate, 73 | } 74 | 75 | // Allocate buffers. 76 | d.reallocBarBufs() 77 | d.reallocFFTBufs() 78 | d.reallocSpectrumOldValues() 79 | d.shared.readBuf = input.MakeBuffers(sessionConfig) 80 | d.shared.writeBuf = input.MakeBuffers(sessionConfig) 81 | 82 | // Initialize the FFT plans. 83 | d.fftPlans = make([]*fft.Plan, d.channels) 84 | for idx := range d.fftPlans { 85 | plan := fft.Plan{ 86 | Input: d.shared.readBuf[idx], 87 | Output: d.fftBuf, 88 | } 89 | plan.Init() 90 | d.fftPlans[idx] = &plan 91 | } 92 | 93 | // Signal the backend to start listening to the microphone. 94 | session, err := d.backend.Start(sessionConfig) 95 | if err != nil { 96 | return errors.Wrap(err, "failed to start the input backend") 97 | } 98 | 99 | // Free up the device. 100 | d.device = nil 101 | 102 | // Periodically queue redraw. Note that this is never a perfect rounding: 103 | // inputting 60Hz will trigger a redraw every 16ms, which is 62.5Hz. 104 | ms := 1000 / uint(d.cfg.DrawOptions.FrameRate) 105 | timerHandle := glib.TimeoutAddPriority(ms, glib.PriorityDefault, func() bool { 106 | if d.processBars() { 107 | d.parent.QueueDraw() 108 | } 109 | return true 110 | }) 111 | 112 | defer glib.SourceRemove(timerHandle) 113 | 114 | // Write to writeBuf, and we can copy from write to read (see Process). 115 | if err := session.Start(d.ctx, d.shared.writeBuf, d); err != nil { 116 | return errors.Wrap(err, "failed to start input session") 117 | } 118 | 119 | return nil 120 | } 121 | 122 | // Process processes the internal read buffer and analyzes its spectrum. 123 | func (d *Drawer) Process() { 124 | d.shared.Lock() 125 | defer d.shared.Unlock() 126 | 127 | if d.shared.paused { 128 | writeZeroBuf(d.shared.readBuf) 129 | } else { 130 | input.CopyBuffers(d.shared.readBuf, d.shared.writeBuf) 131 | } 132 | } 133 | 134 | func (d *Drawer) processBars() bool { 135 | d.shared.Lock() 136 | defer d.shared.Unlock() 137 | 138 | d.shared.peak = 0 139 | 140 | if d.shared.cairoWidth != d.shared.barWidth { 141 | d.shared.barWidth = d.shared.cairoWidth 142 | d.shared.barCount = d.spectrum.Recalculate(d.bars(d.shared.barWidth)) 143 | } 144 | 145 | for idx, buf := range d.shared.barBufs { 146 | d.cfg.WindowFn(d.shared.readBuf[idx]) 147 | d.fftPlans[idx].Execute() // process from readBuf into buf 148 | 149 | for bIdx := range buf[:d.shared.barCount] { 150 | v := d.spectrum.ProcessBin(idx, bIdx, d.fftBuf) 151 | buf[bIdx] = v 152 | 153 | if d.shared.peak < v { 154 | d.shared.peak = v 155 | } 156 | } 157 | } 158 | 159 | if d.slowWindow != nil { 160 | fastMean, _ := d.fastWindow.Update(d.shared.peak) 161 | slowMean, slowStddev := d.slowWindow.Update(d.shared.peak) 162 | 163 | if length := d.slowWindow.Len(); length >= d.fastWindow.Cap() { 164 | if math.Abs(fastMean-slowMean) > (d.cfg.Scaling.ResetDeviation * slowStddev) { 165 | count := int(float64(length) * d.cfg.Scaling.DumpPercent) 166 | slowMean, slowStddev = d.slowWindow.Drop(count) 167 | } 168 | } 169 | 170 | d.shared.scale = 1 171 | if t := slowMean + (1.5 * slowStddev); t > 1.0 { 172 | d.shared.scale = t 173 | } 174 | } 175 | 176 | // Draw if peak is over the threshold. 177 | if d.shared.peak > peakThreshold { 178 | d.shared.quiet = 0 179 | return true 180 | } 181 | 182 | // If we're not over the threshold, then draw until we're quiet for a while. 183 | if d.shared.quiet < quietThreshold { 184 | d.shared.quiet++ 185 | return true 186 | } 187 | 188 | return false 189 | } 190 | 191 | var zeroSamples = make([]input.Sample, 512) 192 | 193 | func writeZeroBuf(buf [][]input.Sample) { 194 | for i := range buf { 195 | // Copy zeroSamples into buf[i] in 512-byte chunks. Go's copy() supports 196 | // SIMD on most CPUs, so this should be faster than a traditional loop. 197 | for n := 0; n < len(buf[i]); n += copy(buf[i][n:], zeroSamples) { 198 | } 199 | } 200 | } 201 | 202 | func (d *Drawer) reallocBarBufs() { 203 | d.shared.barBufs = allocBarBufs(d.cfg.SampleSize, d.channels) 204 | } 205 | 206 | func (d *Drawer) reallocSpectrumOldValues() { 207 | d.spectrum.OldValues = allocBarBufs(d.cfg.SampleSize, d.channels) 208 | } 209 | 210 | func allocBarBufs(sampleSize, channels int) [][]float64 { 211 | // Allocate a large slice with one large backing array. 212 | fullBuf := make([]float64, channels*sampleSize) 213 | 214 | // Allocate smaller slice views. 215 | barBufs := make([][]float64, channels) 216 | 217 | for idx := range barBufs { 218 | start := idx * sampleSize 219 | end := (idx + 1) * sampleSize 220 | 221 | barBufs[idx] = fullBuf[start:end] 222 | } 223 | 224 | return barBufs 225 | } 226 | 227 | func (d *Drawer) reallocFFTBufs() { 228 | d.fftBuf = make([]complex128, d.cfg.SampleSize/2+1) 229 | } 230 | 231 | // bars calculates the number of bars. It is thread-safe. 232 | func (d *Drawer) bars(width float64) int { 233 | var bars = float64(width) / d.binWidth 234 | 235 | if !d.cfg.Monophonic && d.cfg.DrawStyle != DrawHorizontalBars { 236 | bars /= float64(d.channels) 237 | } 238 | 239 | return int(math.Ceil(bars)) 240 | } 241 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/diamondburned/catnip-gtk 2 | 3 | go 1.15 4 | 5 | require ( 6 | github.com/diamondburned/gotk4-handy/pkg v0.0.0-20220124073946-27c04eb9ee61 7 | github.com/diamondburned/gotk4/pkg v0.0.0-20220209175856-9ee6939dde9a 8 | github.com/noriah/catnip v1.0.1-0.20210829202027-9aee3a5d53af 9 | github.com/pkg/errors v0.9.1 10 | gonum.org/v1/gonum v0.8.1 11 | ) 12 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw= 2 | github.com/diamondburned/gotk4-handy/pkg v0.0.0-20220124073946-27c04eb9ee61 h1:xRg8kfB1NzDy1fGSlw0X+BQvTGUE4NJCuPwotDhRD1g= 3 | github.com/diamondburned/gotk4-handy/pkg v0.0.0-20220124073946-27c04eb9ee61/go.mod h1:f4XObchC663zeteE14nGTmfoVkb/aTW1H1TFQYeCyqs= 4 | github.com/diamondburned/gotk4/pkg v0.0.0-20220122222342-2b51f7b628af/go.mod h1:dJ2gfR0gvBsGg4IteP8aMBq/U5Q9boDw0DP7kAjXTwM= 5 | github.com/diamondburned/gotk4/pkg v0.0.0-20220209175856-9ee6939dde9a h1:CPXOniulNpdtNq4lcRKJNZbn+z2W2rhO0HIHmumo7/Q= 6 | github.com/diamondburned/gotk4/pkg v0.0.0-20220209175856-9ee6939dde9a/go.mod h1:dJ2gfR0gvBsGg4IteP8aMBq/U5Q9boDw0DP7kAjXTwM= 7 | github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= 8 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= 9 | github.com/integrii/flaggy v1.4.4/go.mod h1:tnTxHeTJbah0gQ6/K0RW0J7fMUBk9MCF5blhm43LNpI= 10 | github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= 11 | github.com/lawl/pulseaudio v0.0.0-20200802093727-ab0735955fd0 h1:JrvOwrr1teFiqsp0EQxgEPJsm0pet+YLTL+HdYmnMx0= 12 | github.com/lawl/pulseaudio v0.0.0-20200802093727-ab0735955fd0/go.mod h1:9h36x4KH7r2V8DOCKoPMt87IXZ++X90y8D5nnuwq290= 13 | github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 14 | github.com/noriah/catnip v1.0.1-0.20210829202027-9aee3a5d53af h1:1d+nnIF5xlH5VNNp2vcFDbWCik9lEPsHZ27PDOTCWwM= 15 | github.com/noriah/catnip v1.0.1-0.20210829202027-9aee3a5d53af/go.mod h1:gUeDcGBGa68XNOiCn6tkpHcnkMmF4CfMyAQkhEe6SOc= 16 | github.com/nsf/termbox-go v0.0.0-20200418040025-38ba6e5628f1/go.mod h1:IuKpRQcYE1Tfu+oAQqaLisqDeXgjyyltCfsaoYN18NQ= 17 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 18 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 19 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 20 | go4.org/unsafe/assume-no-moving-gc v0.0.0-20201222180813-1025295fd063 h1:1tk03FUNpulq2cuWpXZWj649rwJpk0d20rxWiopKRmc= 21 | go4.org/unsafe/assume-no-moving-gc v0.0.0-20201222180813-1025295fd063/go.mod h1:FftLjUGFEDu5k8lt0ddY+HcrH/qU/0qk+H8j9/nTl3E= 22 | golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 23 | golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 24 | golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2 h1:y102fOLFqhV41b+4GPiJoa0k/x+pJcEi2/HB1Y5T6fU= 25 | golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 26 | golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs= 27 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= 28 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 29 | golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 30 | golang.org/x/tools v0.0.0-20190206041539-40960b6deb8e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 31 | gonum.org/v1/gonum v0.0.0-20180816165407-929014505bf4/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo= 32 | gonum.org/v1/gonum v0.8.1 h1:wGtP3yGpc5mCLOLeTeBdjeui9oZSz5De0eOjMLC/QuQ= 33 | gonum.org/v1/gonum v0.8.1/go.mod h1:oe/vMfY3deqTw+1EZJhuvEW2iwGF1bW9wwu7XCu0+v0= 34 | gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0 h1:OE9mWmgKkjJyEmDAAtGMPjXu+YNeGvK9VTSHY6+Qihc= 35 | gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw= 36 | gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZBvf6cAIx93T+c/OS2HFAYskSZc= 37 | rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= 38 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diamondburned/catnip-gtk/92350c410e39595703e0191314d15ea3f4975806/screenshot.png -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | { unstable ? import {} }: 2 | 3 | let go = unstable.go.overrideAttrs (old: { 4 | version = "1.17.6"; 5 | src = builtins.fetchurl { 6 | url = "https://go.dev/dl/go1.17.6.src.tar.gz"; 7 | sha256 = "sha256:1j288zwnws3p2iv7r938c89706hmi1nmwd8r5gzw3w31zzrvphad"; 8 | }; 9 | doCheck = false; 10 | patches = [ 11 | # cmd/go/internal/work: concurrent ccompile routines 12 | (builtins.fetchurl "https://github.com/diamondburned/go/commit/4e07fa9fe4e905d89c725baed404ae43e03eb08e.patch") 13 | # cmd/cgo: concurrent file generation 14 | (builtins.fetchurl "https://github.com/diamondburned/go/commit/432db23601eeb941cf2ae3a539a62e6f7c11ed06.patch") 15 | ]; 16 | }); 17 | 18 | in unstable.stdenv.mkDerivation rec { 19 | name = "catnip-gtk"; 20 | version = "0.0.1"; 21 | 22 | CGO_ENABLED = "1"; 23 | 24 | buildInputs = with unstable; [ 25 | gtk3 26 | glib 27 | gtk-layer-shell 28 | gdk-pixbuf 29 | gobject-introspection 30 | libhandy 31 | gtk-layer-shell 32 | fftw 33 | portaudio 34 | ]; 35 | 36 | nativeBuildInputs = [ go unstable.pkgconfig ]; 37 | } 38 | --------------------------------------------------------------------------------