用 Lisp 演奏音乐:Racket 图形界面编程初探

编程的世界里,我们习惯于用代码构建各种奇妙的功能,但如果能用代码“演奏”出美妙的音乐,那岂不是更加有趣?今天,就让我们一起踏上这段奇妙的旅程,用 Lisp 的方言 Racket 来编写一个可以生成音调的图形界面程序,感受代码与音乐碰撞的魅力!

👋 初识 Racket

Racket 作为 Lisp 的一种方言,以其强大的跨平台 GUI 库而闻名。与用代码构建另一个计算器不同,我们将尝试构建一个可以生成音调的 GUI 界面。

image

在开始之前,我们需要先安装 Racket。好消息是,大多数 Linux 发行版的软件仓库中都包含 Racket,所以安装起来非常方便。安装完成后,我们就可以开始编写代码了。

#lang racket

(require racket/gui)

Racket 的一大优势是它拥有大量的内置库。在这里,我们将使用 racket/gui​ 库来构建我们的 GUI 界面。

; 主窗口
(define frame (new frame% [label "Bleep"]))

; 显示 GUI
(send frame show #t)

Racket 的 GUI 库是面向对象的。我们可以通过实例化 frame%​ 类来创建一个窗口。以百分号结尾的标识符是 Racket 中类的命名约定。通过调用窗口的 show​ 方法,我们可以将窗口显示出来。接下来,让我们在创建窗口和显示窗口之间添加一些其他的控件。

在 Racket 中,#t 是一个布尔值,表示 true。

🎚️ 滑动条与频率

首先,我们需要一个滑动条来让用户选择音调的频率。

(define slider (new slider% [label #f]
                            [min-value 20]
                            [max-value 20000]
                            [parent frame]
                            [init-value 440]
                            [style '(horizontal plain)]
                            [vert-margin 25]
                            [horiz-margin 10]))

这段代码创建了一个水平的滑动条,其取值范围为 20 到 20000 Hz,对应人类可听到的频率范围。我们将初始值设置为 440 Hz,也就是标准音高 A4 的频率。

然而,如果我们直接运行这段代码,会发现滑动条的初始位置几乎看不到变化:![线性刻度滑动条][]

这是因为 20 到 20000 的范围太大了,440 在这个范围内显得微不足道。为了解决这个问题,我们需要使用对数刻度来代替线性刻度。

通过参考 Stack Overflow 上的一个答案,我们可以将 JavaScript 代码移植到 Racket 中,实现对数刻度的滑动条:

; 滑动条使用的刻度
(define *min-position* 0)
(define *max-position* 2000)
; 频率范围
(define *min-frequency* 20)
(define *max-frequency* 20000)

; 频率的对数刻度(使中央 A [440] 大致位于中间)
; 改编自 https://stackoverflow.com/questions/846221/logarithmic-slider

(define min-freq (log *min-frequency*))
(define max-freq (log *max-frequency*))
(define frequency-scale (/ (- max-freq min-freq) (- *max-position* *min-position*)))
; 将滑块位置转换为频率
(define (position->frequency position)
  (inexact->exact (round (exp (+ min-freq (* frequency-scale (- position *min-position*)))))))
; 将频率转换为滑块位置
(define (frequency->position freq)
  (inexact->exact (round (/ (- (log freq) min-freq) (+ frequency-scale *min-position*)))))

这段代码定义了几个全局参数,并创建了两个函数:position->frequency​ 用于将滑动条上的位置转换为频率,frequency->position​ 用于将频率转换为滑动条上的位置。

在 Racket 语言中,->​ 符号通常用于表示从一个值到另一个值的转换关系,特别是在函数命名中。具体来说,在你的代码中:

  • position->frequency​ 表示将滑块位置(position​)转换为频率(frequency​)。
  • frequency->position​ 表示将频率转换为滑块位置。

这种命名风格帮助开发者快速理解函数的用途和它们之间的关系。Racket 语言鼓励使用清晰的命名来提高代码的可读性。

现在,让我们修改 slider%​ 的代码,使用 frequency->position​ 函数将 init-value​ 转换为使用对数刻度的滑动条位置:

(define slider (new slider% [label #f]
                            [min-value *min-position*]
                            [max-value *max-position*]
                            [parent frame]
                            [init-value (frequency->position 440)]
                            [style '(horizontal plain)]
                            [vert-margin 25]
                            [horiz-margin 10]))

🎹 音调控制面板

在滑动条下方,我们将添加一个文本框来显示当前频率,并添加按钮来将频率增加或减少一个八度。

(define frequency-pane (new horizontal-pane% [parent frame]
                                             [border 10]
                                             [alignment '(center center)]))
(define lower-button (new button% [parent frequency-pane]
                                  [label "<"]))
(define frequency-field (new text-field% [label #f]
                                         [parent frequency-pane]
                                         [init-value "440"]
                                         [min-width 64]
                                         [stretchable-width #f]))
(define frequency-label (new message% [parent frequency-pane] [label "Hz"]))
(define higher-button (new button% [parent frequency-pane]
                                   [label ">"]))

horizontal-pane%​ 是一个不可见的控件,用于辅助布局。至此,我们已经拥有了一个看起来不错的界面,但它还不能做任何事情。如果我们点击按钮或滑动滑动条,什么也不会发生。

为了让界面动起来,我们需要为控件添加回调函数。例如,我们可以为滑动条添加一个回调函数,每当滑动条移动时,该函数就会被调用。

; 将滑块链接到文本字段以显示频率
(define (adjust-frequency widget event)
  (send frequency-field set-value
    (~a (position->frequency (send widget get-value)))))
(define (adjust-slider entry event)  (define new-freq (string->number (send entry get-value)))
  (send slider set-value
    (frequency->position (if new-freq new-freq *min-frequency*))))

回调函数接受两个参数:第一个参数是调用它的对象的实例,第二个参数是事件类型。text-field%​ 需要一个字符串,因此我们必须使用 ~a​ 将 position->frequency​ 返回的数字转换为字符串。接下来,我们要做的就是将这些函数连接到控件上:

(define slider (new slider% [label #f]
                            ...
                            [callback adjust-frequency]
                            ...))
...
(define frequency-field (new text-field% [label #f]
                                         ...
                                         [callback adjust-slider]
                                         ...))

我们将按钮连接到名为 decrease-octave​ 和 increase-octave​ 的回调函数。八度是指“两个音高之间的音程,其频率是另一个音高的两倍”。

; 设置频率滑块和显示
(define (set-frequency freq)
  (send slider set-value (frequency->position freq))
  (send frequency-field set-value (~a freq)))

; 按钮将频率增加和减少一个八度
(define (adjust-octave modifier)
  (set-frequency (* (string->number (send frequency-field get-value)) modifier)))
(define (decrease-octave button event) (adjust-octave 0.5))
(define (increase-octave button event) (adjust-octave 2))

现在,如果我们滑动滑动条,文本框会相应更新。如果我们在文本框中输入一个数字,滑动条也会相应更新。### 🛡️ 自定义控件:更安全的输入

Racket 附带的控件非常基础,但我们可以扩展内置控件的类来创建自定义控件。让我们扩展 text-field%​ 类来创建一个新的 number-field%​ 类。这个类将有两个额外的初始化变量来指定 min-value​ 和 max-value​,并且只允许输入落在这个范围内的数字。

; 扩展 text-field% 类以在字段失去焦点时验证数据。
; 字段应仅包含允许范围内的数字。否则,设置为最小值。
(define number-field%
  (class text-field%
    ; 添加初始化变量以定义允许范围
    (init min-value max-value)
    (define min-allowed min-value)
    (define max-allowed max-value)
    (super-new)
    (define/override (on-focus on?)
      (unless on?
        (define current-value (string->number (send this get-value)))
        (unless (and current-value
                     (>= current-value min-allowed)
                     (<= current-value max-allowed))
          (send this set-value (~a min-allowed))
          ; 还重置滑块位置以确保它仍然与显示匹配
          (send slider set-value (string->number (send frequency-field get-value))))))))

然后,我们可以用 number-field%​ 替换 text-field%​。

(define frequency-field (new number-field% [label #f]
                                           [parent frequency-pane]
                                           [min-value *min-frequency*]
                                           [max-value *max-frequency*]                                           [callback adjust-slider]
                                           [init-value "440"]
                                           [min-width 64]
                                           [stretchable-width #f]))

让我们再次使用 number-field%​ 来创建一个字段,以毫秒为单位指定哔声的持续时间:

(define control-pane (new horizontal-pane% [parent frame]
                                           [border 25]
                                           [spacing 25]))
(define duration-pane (new horizontal-pane% [parent control-pane]))
(define duration-field (new number-field% [label "Duration "]
                                          [parent duration-pane]
                                          [min-value 1]
                                          [max-value 600000] ; 10 minutes
                                          [init-value "200"]
                                          [min-width 120]))

🎼 音符选择

频率是一个比较抽象的概念。让我们也让用户能够选择一个音符。我们可以将 A4-G4 的对应频率存储在一个哈希表中。

; 音符 -> 频率(中央 A-G [A4-G4])
; http://pages.mtu.edu/~suits/notefreqs.html
(define notes (hash "A" 440.00
                    "B" 493.88
                    "C" 261.63
                    "D" 293.66
                    "E" 329.63
                    "F" 349.23
                    "G" 292.00))

我们将为用户提供一个下拉菜单。每当从下拉菜单中选择一个音符时,我们将在哈希表中查找频率,并使用我们为八度按钮创建的 set-frequency​ 辅助函数来设置它。

; 将频率设置为特定音符
(define (set-note choice event)
  (set-frequency (hash-ref notes (send choice get-string-selection))))
(define note (new choice% [label "♪ "]
                          [choices '("A" "B" "C" "D" "E" "F" "G")]
                          [parent control-pane]
                          [callback set-note]))

🎧 让音乐响起

最后,让我们来制造一些噪音。

(require rsound)

; 使用 RSound 生成音调
; 明确设置 RSound 采样率,以防因平台/版本而异
(default-sample-rate 44100)
(define (generate-tone button event)
  (play (make-tone (string->number (send frequency-field get-value))
                   0.5
                   ; 以 44.1 kHz 的采样率表示的样本持续时间
                   (inexact->exact (* 44.1 (string->number (send duration-field get-value)))))))

我们将使用 Racket RSound 包来生成音调。这个包没有捆绑在 Racket 中,但你可以使用 Racket 附带的 raco​ 工具来安装它(raco pkg install rsound​)。将它连接到持续时间和音符选择器之间的按钮上,你就可以制造一些噪音了。

(define play-button (new button% [parent control-pane]
                                 [label "Play"]
                                 [callback generate-tone]))

🎉 结语

恭喜!我们已经成功地使用 Racket 构建了一个可以生成音调的图形界面程序。这段旅程不仅让我们体验了 Racket 图形界面编程的乐趣,更让我们感受到了代码与音乐碰撞的奇妙火花。

参考文献:

  • Lisp
    38 引用 • 13 回帖 • 1 关注
3 操作
linker 在 2024-09-13 22:07:09 更新了该帖
linker 在 2024-09-13 21:58:44 更新了该帖
linker 在 2024-09-13 21:56:40 更新了该帖

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...