├── .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 | 
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 |
--------------------------------------------------------------------------------