WWDC Quick Look 💓 By SwiftGGTeam
Swan's Quest, Chapter 3: The notable scroll

Swan's Quest, Chapter 3: The notable scroll

观看原视频

Highlight

第三章要求玩家把 Swan 卷轴上的节奏转换成 PitchNoteProtocol 和固定间隔的 Timer,再用 ToneOutput 播放不同长度的音符。

核心内容

第二章只处理一种音符长度,固定间隔的 Timer 足够完成挑战。第三章回到 lizard 的小屋后,Swan 的新卷轴要求玩家演奏有节奏差异的旋律:四分音符、二分音符、附点二分音符都可能出现。原来的循环只会按同一个节拍推进,遇到长音就失去表达能力。

Apple 先把问题拆成两个模型。Pitch 负责告诉 ToneOutput 要播放哪个频率,NoteProtocol 负责描述一个音符对应的 tone 和长度。长度用四分音符的倍数表达,所以二分音符返回 2.0,八分音符返回 0.5

难点在计时器。逐字稿明确提醒,ToneOutput 会持续发声,直到被要求停止。这个 session 的做法是把 Timer 固定在最短支持音符的间隔上,再把较长音符拆成多次 pitch 指令。这样循环仍然保持简单,却能表达不同长度的音符。

详细内容

用 Pitch 描述频率

01:50Music.swift 里预留了协议让玩家实现。第一步是把卷轴上的每个音高映射成频率,供 ToneOutput 使用。

// Example Pitch implementation

public enum Pitch: Double, PitchProtocol {
    case a4 = 440.0

    var frequency: Double {
        return self.rawValue
    }
}

关键点:

  • Pitch 遵守 PitchProtocol,用 Double raw value 保存频率。
  • .a4 对应 440.0,逐字稿要求玩家为 Swan 卷轴中的每个 pitch 增加 case。
  • frequency 直接返回 raw value,后面的 Tone 会读取这个频率。

用 NoteProtocol 描述音符长度

02:08)音高解决后,还要表达一个音符持续多久。官方片段把音符拆成两个属性:播放用的 tone,以及相对四分音符的 length

// Music.swift

public protocol NoteProtocol {

    /// Play this Note through a ToneOutput
    var tone: Tone { get }

    /// The duration of this Note as a multiple of quarter notes,
    /// e.g., a half note is 2.0, an eighth note is 0.5
    var length: Float { get }
}

关键点:

  • tone 是送给 ToneOutput 的声音对象。
  • length 用四分音符做基准,二分音符是 2.0,八分音符是 0.5
  • 这个协议让同一个旋律数组同时携带 pitch 和 rhythm 信息。

官方给出的 note 实现先覆盖了最小形态:一个四分音符 case,把 pitch 转成 Tone,再返回固定长度。

// Example Note implementation

public enum Note: NoteProtocol {
    case quarter(pitch: Pitch)

    var tone: Tone {
        switch self {
        case .quarter(let pitch):
            return Tone(pitch: pitch.frequency, volume: 0.3)
        }
    }

    var length: Float {
        switch self {
        case .quarter(_):
            return 1.0
        }
    }
}

关键点:

  • quarter(pitch:) 把一个 pitch 包成四分音符。
  • tone 用 pitch 的 frequency 创建 Tone,音量在片段里设为 0.3
  • length 对四分音符返回 1.0,玩家需要继续为卷轴里的其他长度增加 case。

固定 Timer 间隔

03:18)原来的 Timer 不会自动理解 note length。解决办法是先告诉协议最短支持音符有多长,再用这个值计算 timer interval。演讲者以 120 BPM 为例,四分音符长度是 500 毫秒。

// Music.swift

public protocol NoteProtocol {

    /// Play this Note through a ToneOutput
    var tone: Tone { get }

    /// The duration of this Note as a multiple of quarter notes,
    /// e.g., a half note is 2.0, an eighth note is 0.5
    var length: Float { get }

    /// Length of the smallest Note supported
    static var shortestSupportedNoteLength: Float { get }
}

关键点:

  • shortestSupportedNoteLength 是类型级别属性,所有 note 共享同一个最短单位。
  • 逐字稿中的 Swan 卷轴以四分音符作为最短单位,所以示例选择 quarter note。
  • Timer 后续按这个最短单位触发,长音通过重复 pitch 指令表达。

把长音符拆成 pitch 指令

04:15)长音符需要被拆成一组 pitch。四分音符发出一个指令,二分音符发出两个指令,附点二分音符发出三个指令。官方把这一步命名为 subdivide()

// Music.swift

public protocol NoteProtocol {
    associatedtype PitchType: PitchProtocol

    /// Play this Note through a ToneOutput
    var tone: Tone { get }

    /// The duration of this Note as a multiple of quarter notes,
    /// e.g., a half note is 2.0, an eighth note is 0.5
    var length: Float { get }

    /// Length of the smallest Note supported
    static var shortestSupportedNoteLength: Float { get }

    /// Subdivide into a series pitches, according to the shortest
    /// supported note
    func subdivide() -> [PitchType]
}

关键点:

  • associatedtype PitchType 把 note 协议和具体 pitch 类型连接起来。
  • subdivide() 返回 pitch 数组,数组长度由 note length 和最短支持长度决定。
  • timer 循环不再直接消费 note,而是消费已经展开的 pitch。

完整播放循环

04:30)最后一步是在播放前把所有 notes 展开成 pitches,再让固定间隔的 Timer 逐个播放。

// Play more than one tone redux

let toneOutput = ToneOutput()
let notes = [Note.quarter(pitch: .a4), .half(pitch: .a4), .quarter(pitch: .a4)]
var pitches = [Pitch]()
for note in notes {
    pitches.append(contentsOf: note.subdivide())
}
var index = 0

let interval = TimeInterval(Note.shortestSupportedNoteLength * 0.5)
Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { timer in
    guard index < pitches.count else {
        timer.invalidate()
        owner.endPerformance()
        return
    }
    toneOutput.play(tone: Tone(pitch: pitches[index].frequency, volume: 0.3))
    index += 1
}

关键点:

  • notes 保留原始乐谱结构,pitches 是播放前的展开结果。
  • for note in notes 把每个 note 的 subdivide() 结果追加进同一个数组。
  • interval 使用 shortestSupportedNoteLength * 0.5,对应逐字稿里的 120 BPM 示例。
  • guard index < pitches.count 到末尾后停止 timer,并结束表演。
  • toneOutput.play 每次按当前 pitch 的 frequency 发声,循环靠 index += 1 前进。

核心启发

  • 做什么:做一个节奏练习 playground。为什么值得做:这场 session 已经把四分音符、二分音符和附点二分音符的长度关系讲清楚。怎么开始:用 PitchProtocolNoteProtocol 定义少量音高与长度,再用 ToneOutput 播放用户排列出的 notes。
  • 做什么:做一个乐谱校验器。为什么值得做:Swan 的挑战要求玩家把卷轴上的 “Ode to Joy” 转成可播放的代码。怎么开始:把用户输入的 note 数组先交给 subdivide() 展开,再检查展开后的 pitch 数量是否符合预期节拍。
  • 做什么:给旋律加低音声部。为什么值得做:side quest 明确提示可以用多个 timers 叠加 tones,给主旋律下面铺 bass chords。怎么开始:保留主旋律 timer,再为 bass notes 准备第二组 pitches 和第二个 timer,使用同一个 tempo 基准。
  • 做什么:做一个播放调试输出。为什么值得做:官方完整片段用 index 逐个推进 pitches,很适合暴露当前播放位置。怎么开始:在播放循环旁记录当前 index、pitch frequency 和剩余 pitch 数,让学习者看到 subdivision 后的真实播放序列。

关联 Session

评论

GitHub Issues · utterances