1. 程式人生 > >使用 Swift 進行函式式訊號處理

使用 Swift 進行函式式訊號處理

作為一個和 Core Audio 打過很長時間交道的工程師,蘋果釋出 Swift 讓我感到興奮又疑惑。興奮是因為 Swift 是一個為效能打造的現代程式語言,但是我又不是非常確定函數語言程式設計是否可以應用到 “我的世界”。幸運的是,很多人已經探索和克服了這些問題,所以我決定將我從這些專案中學習到的東西應用到 Swift 程式語言中去。

訊號

訊號處理的基本當然是訊號。在 Swift 中,我可以這樣定義訊號:

public typealias Signal = Int -> SampleType

你可以把 Signal 類想象成一個離散時間函式,這個函式會返回一個時間點上的訊號值。在大多數訊號處理的教科書中,這個會被寫做 x[t]

, 這樣一來它就很符合我的世界觀了。

現在我們來定義一個給定頻率的正弦波:

public func sineWave(sampleRate: Int, frequency: ParameterType) -> Signal {
    let phi = frequency / ParameterType(sampleRate)
    return { i in
        return SampleType(sin(2.0 * ParameterType(i) * phi * ParameterType(M_PI)))
    }
}

sineWave 函式會返回一個 Signal

Signal 本身是一個將取樣點的索引對映為輸出樣點的函式。我將這些不需要“輸入”的訊號稱為訊號發生器,因為它們不需要任何其他的東西就能創造訊號。

但是我們正在討論訊號處理。那麼如何更改一個訊號呢?

任何關於訊號處理的高層面的討論,都不可能離開一個基礎,那就是如何控制增益 (或者音量):

public func scale(s: Signal, amplitude: ParameterType) -> Signal {
    return { i in
            return SampleType(s(i) * SampleType(amplitude))
    }
}

scale 函式接受一個名為 sSignal 作為輸入,然後返回一個施加了標量之後的新 Signal。每次呼叫這個經過 scale 後的訊號,返回的值都是對應的 s(i) 然後通過所提供的 amplitude 進行加成,來作為輸出。很容易對吧?但是很快這些構件就會變得混亂起來。來看看以下的例子:

public func mix(s1: Signal, s2: Signal) -> Signal {
    return { i in
        return s1(i) + s2(i)
    }
}

這讓我們能夠將兩個訊號混合成一個訊號。我們甚至可以混合任意多個訊號:

public func mix(signals: [Signal]) -> Signal {
    return { i in
        return signals.reduce(SampleType(0)) { $0 + $1(i) }
    }
}

這可以讓我們幹很多事情;但是一個 Signal 僅僅限於一個單一的音訊頻道,有些音效需要複雜的操作的組合同時發生才能做到。

處理 Block

我們如何才能以更靈活的方式在訊號和處理器之間建立聯絡,來讓訊號處理更接近於我們所想呢?有很多流行的環境,比如說 MaxPureData,這些環境會建立訊號處理的 “blocks”,並以此來創造強大的音效和演奏工具。

Faust 是一個為此設計出來的函數語言程式設計語言,它是一個用來編寫高度複雜 (而且高效能) 的訊號處理程式碼的強大工具。Faust 定義了一系列運算子來讓你建立 blocks (處理器),這和訊號流影象很相似。

類似地,我用同樣的方式建立了一個可以高效工作的環境。

使用我們之前定義的 Signal,我們可以基於這個概念進行擴充套件。

public protocol BlockType {
    typealias SignalType
    var inputCount: Int { get }
    var outputCount: Int { get }
    var process: [SignalType] -> [SignalType] { get }

    init(inputCount: Int, outputCount: Int, process: [SignalType] -> [SignalType])
}

一個 Block 有多個輸入,多個輸出,和一個 process 函式,這個函式將訊號從輸入集合轉換成輸出集合。Blocks 可以有零個或多個輸入,也可以有零個或多個輸出。

你可以用以下的方法來建立序列的 blocks。

public func serial<B: BlockType>(lhs: B, rhs: B) -> B {
    return B(inputCount: lhs.inputCount, outputCount: rhs.outputCount, process: { inputs in
        return rhs.process(lhs.process(inputs))
    })
}

這個函式將 lhs block 的輸出當做 rhs block 的輸入,然後返回結果。就好像在兩個 blocks 中間連起一根線一樣。當你想要並行地執行多個 blocks 的時候,事情就變得有意思起來:

public func parallel<B: BlockType>(lhs: B, rhs: B) -> B {
    let totalInputs = lhs.inputCount + rhs.inputCount
    let totalOutputs = lhs.outputCount + rhs.outputCount

    return B(inputCount: totalInputs, outputCount: totalOutputs, process: { inputs in
        var outputs: [B.SignalType] = []

        outputs += lhs.process(Array(inputs[0..<lhs.inputCount]))
        outputs += rhs.process(Array(inputs[lhs.inputCount..<lhs.inputCount+rhs.inputCount]))

        return outputs
    })
}

一組並行執行的 blocks 將輸入和輸出結合在一起,並建立了一個更大的 block。比如一對產生的正弦波的 Block 組合在一起可以建立一個 DTMF 音調,或者兩個單頻延遲的 Block 可以組成一個立體延遲 Block 等。這個概念在實踐中是非常強大的。

那麼混合器呢?我們如何從多個輸入得到一個單頻道的結果?我們可以用如下函式來將多個 block 合併在一起:

public func merge<B: BlockType where B.SignalType == Signal>(lhs: B, rhs: B) -> B {
    return B(inputCount: lhs.inputCount, outputCount: rhs.outputCount, process: { inputs in
        let leftOutputs = lhs.process(inputs)
        var rightInputs: [B.SignalType] = []

        let k = lhs.outputCount / rhs.inputCount
        for i in 0..<rhs.inputCount  {
            var inputsToSum: [B.SignalType] = []
            for j in 0..<k {
                inputsToSum.append(leftOutputs[i+(rhs.inputCount*j)])
            }
            let summed = inputsToSum.reduce(NullSignal) { mix($0, $1) }
            rightInputs.append(summed)
        }

        return rhs.process(rightInputs)
    })
}

從 Faust 借用一個慣例,輸入的混合是這樣進行的:右手邊 block 的輸入來自於左手邊對輸入取模後的輸出。舉個例子,將六個頻道的三個立體聲軌變成一個立體輸出的 block:輸出頻道 0,2,4 被混合 (比如相加) 進輸入頻道 0,然後輸出頻道 1,3,5 會被混合進輸入頻道 1。

同樣的,你可以用相反的方法將 block 的輸出分開。

public func split<B: BlockType>(lhs: B, rhs: B) -> B {
    return B(inputCount: lhs.inputCount, outputCount: rhs.outputCount, process: { inputs in
        let leftOutputs = lhs.process(inputs)
        var rightInputs: [B.SignalType] = []

        // 從 lhs 將頻道逐個複製輸入中
        let k = lhs.outputCount
        for i in 0..<rhs.inputCount {
            rightInputs.append(leftOutputs[i%k])
        }

        return rhs.process(rightInputs)
    })
}

對於輸出我們也使用一個類似的慣例,一個立體聲 block 作為三個立體聲 block 的輸入 (總共接受六個聲道),也就是說,頻道 0 作為輸入 0,2,4,而頻道 1 作為 1,3,5 的輸入。

我們當然不想被這些很長的函式束縛住手腳,所以我寫了這些運算子:

// 並行
public func |-<B: BlockType>(lhs: B, rhs: B) -> B

// 序列
public func --<B: BlockType>(lhs: B, rhs: B) -> B

// 分割
public func -<<B: BlockType>(lhs: B, rhs: B) -> B

// 合併
public func >-<B: BlockType where B.SignalType == Signal>(lhs: B, rhs: B) -> B

(我覺得“並行”運算子的定義並不是特別好,因為它看上去和幾何中的“垂直”尤其相似,但是現在就這樣,非常歡迎大家的意見)

現在有了這些運算子,你可以建立一些有趣的 blocks “圖”。比如說 DTMF 音調發生器:

let dtmfFrequencies = [
    ( 941.0, 1336.0 ),

    ( 697.0, 1209.0 ),
    ( 697.0, 1336.0 ),
    ( 697.0, 1477.0 ),

    ( 770.0, 1209.0 ),
    ( 770.0, 1336.0 ),
    ( 770.0, 1477.0 ),

    ( 852.0, 1209.0 ),
    ( 852.0, 1336.0 ),
    ( 852.0, 1477.0 ),
]

func dtmfTone(digit: Int, sampleRate: Int) -> Block {
    assert( digit < dtmfFrequencies.count )
    let (f1, f2) = dtmfFrequencies[digit]

    let f1Block = Block(inputCount: 0, outputCount: 1, process: { _ in [sineWave(sampleRate, f1)] })
    let f2Block = Block(inputCount: 0, outputCount: 1, process: { _ in [sineWave(sampleRate, f2)] })

    return ( f1Block |- f2Block ) >- Block(inputCount: 1, outputCount: 1, process: { return $0 })
}

dtmfTone 函式處理兩個並行的正弦發生器,然後將它們融合成一個 “單位元 block”,這個 block 只是將自己的輸入複製到輸出。記住這個函式的返回值本身就是一個 block,所以你可以在更大的系統中使用這個block。

可以看得出來這個想法蘊含了很多的潛力。通過建立可以使用更緊湊和容易理解的 DSL (domain specific language) 來描述複雜系統的環境,我們可以花更少的時間來思考單個 block 的細節,並輕易地把所有東西組合到一起。

實踐

如果我今天要開始做一個要求最高效能以及豐富功能的新專案,我會毫不猶豫的使用 Faust。如果你對函式式音訊程式設計感興趣的話,我極力推薦 Faust。

話雖如此,我一上提到想法的可行性很大程度上依賴於蘋果對編譯器的改進,編譯器需要具有能識別我們定義在 block 中的模式,並輸出更智慧的程式碼的能力。也就是說,蘋果需要像編譯 Haskell 一樣來編譯 Swift。在 Haskell 中函數語言程式設計模式會被壓縮成某一個目標 CPU 的向量運算。

說實話,我覺得 Swift 在蘋果的管理下是很好的,我們也會在將來看見我在以上呈現的想法會變得很常見,而且效能也會變得非常好。

未來

我會將這個“函式式 DPS”專案 保留在 GitHub 上面。你可以跟蹤進展並且做出貢獻。我計劃去研究更加複雜的 block,比如說那些需要 FFT 來計算輸出,或者需要 “儲存” 來執行 (比方說 FIR 濾鏡等等) 的block。

參考文獻

在寫這篇文章的過程當中,我研究過下列論文。如果你對這方面研究有興趣的話我建議閱讀它們。儘管還有很多好的資源,但因為我時間有限所以不能一一列舉,但是這些會是非常好的起點。

  • Thielemann, H. (2004). Audio Processing using Haskell.
  • Cheng, E., & Hudak, P. (2009). Audio Processing and Sound Synthesis in Haskell.