Runner in the High

技術のことをかくこころみ

abema/go-mp4で複数ストリームが含まれるmdatから音声のサンプルのみを取り出す

去年書いた以下の記事はmp4/m4aといいつつ、実はファイルの中に動画のストリームが入っているケースを考慮できていない。

izumisy.work

その記事を書いた時点では「どうやらストリームが複数ある場合にmdatは数サンプルごとのチャンクの詰め合わせで構成されているっぽい」ということしか分かっていなかった。軽く調べても簡単にわかりそうな感じでもなかったので、とりあえず音声のストリームだけがmdatに含まれる前提で話を進めることにしていたが、実際的なユースケースではmp4には動画と音声がセットで含まれているケースのほうが多い。

というわけで、そのようなケースでどのようにmdatを読み取ればいいのか改めて調べてみたので備忘録的に残しておく。

サンプルとチャンク

日本語の参考資料としては以下のWikiが最も参考になる。

github.com

これ以外にもstsc, stco, stszあたりの関係性は以下のstackoverflowが参考になった。

stackoverflow.com

これらの記事によると、複数ストリームから構成されるmp4ファイルのmdatは、複数のサンプルを持つチャンクと呼ばれる単位をひとつの塊としているらしい。

それぞれのストリームはmdat中での自分のストリームに該当するチャンクのオフセットの値を保持しているため、mdatを前から順番にサンプルサイズで舐めていったとしてもオフセット値が正しくなければ、中途半端なサンプルだったり別のストリームのサンプルを取り出してしまうことになる。従って、複数のストリームが存在するmp4ファイルにおいてはストリームのサンプルサイズだけではなくチャンクのオフセットを知ることが重要になる。オフセットが分からなければサンプルサイズが分かっても意味がない。

abema/go-mp4を使う例

abema/go-mp4は実は複数ストリームが存在しているケースを扱うのに非常に便利で、パッケージから提供されるTrackという構造体を見るだけで大体のことは完結させられる。

type Track struct {
    TrackID   uint32
    Timescale uint32
    Duration  uint64
    Codec     Codec
    Encrypted bool
    EditList  EditList
    Samples   Samples
    Chunks    Chunks
    AVC       *AVCDecConfigInfo
    MP4A      *MP4AInfo
}

ここにあるChunksというのが、まさにmdatにおけるストリームのチャンク情報そのものである。

type Chunks []*Chunk

type Chunk struct {
    DataOffset      uint32 // mdatにおけるchunkのオフセット
    SamplesPerChunk uint32 // chunkに含まれるサンプル数
}

この二つの情報が手に入れば話は早い。

自分は以下のような感じで SamplesChunks からmdat中のサンプルのオフセットとサイズを計算するロジックを書いた。やっていることはシンプルで、チャンクのカウンタとチャンク内のサンプルのカウンタを加算しながら「いまどのチャンクのどのサンプルを読んでいるか?」をイテレーションして返していくだけの実装。

type FrameIterator struct {
    samples           mp4.Samples // track.Samples
    sampleIndex       int         // 0
    chunks            mp4.Chunks  // track.Chunks
    chunkIndex        int         // 0
    chunkSampleIndex  int         // 0
    chunkSampleOffset uint32      // track.Chunks[0].DataOffset
}

type Frame struct {
    Offset uint32
    Size   uint32
}

// イテレーションしてフレームの情報を返すメソッド
func (v *FrameIterator) Next() *Frame {
    if len(v.samples) <= v.sampleIndex || len(v.chunks) <= v.chunkIndex {
        return nil
    }

    offset := v.chunkSampleOffset
    size := v.samples[v.sampleIndex].Size
    currentChunk := v.chunks[v.chunkIndex]

    if currentChunk.SamplesPerChunk <= uint32(v.chunkSampleIndex+1) {
        if len(v.chunks) <= v.chunkIndex+1 {
            return nil
        }
        v.chunkIndex++
        v.chunkSampleIndex = 0
        v.chunkSampleOffset = v.chunks[v.chunkIndex].DataOffset
    } else {
        v.chunkSampleIndex++
        v.chunkSampleOffset += v.samples[v.sampleIndex].Size
    }

    v.sampleIndex++
    return &Frame{
        Offset: offset,
        Size:   size,
    }
}

github.com

ここまで出来たらば FrameIterator へ音声のストリームに該当するTrackの値を与えてやるだけでmdat中の音声サンプルのオフセットとサイズを取得できるので、取得された情報をもとにSectionReaderあたりでファイルをシークしつつバイナリデータを取り出してfdkaacでデコードするなりすればいい。

vim-lspでelm-language-serverを使うとリファクタリング系のCodeActionが動かない件を修正するPRを投げた

github.com

仕事柄vim-lspでelm-language-serverを用いてElmを書くことが多いのだが、なぜかVimだとリファクタリング系のCodeActionが動かないことが多かった。中でも関数のexpose/unexposeが動かないのはまあまあストレスだったので、この機会に腰を据えて調べてみた。

調べた結果をざっくり説明すると、どうやらvim-lspでは codeAction/resolve というcodeActionの遅延実行的なIFが未実装らしい。一方でelm-language-serverはリファクタリング関連のcodeActionをすべて codeAction/resolve で実装しているようだった。従ってLSPクライアントでも codeAction/resolve を実装しているものでしかelm-language-serverは動かないようにできていた。VSCodeは問題なく動いていたので、おそらくIFを正しく実装しているのだろう。

というわけで、取りうる手段はvim-lspに codeAction/resolve のIFを実装する」「elm-language-serverで codeAction/resolve が動かないケースをハンドリングする」のふたつだったのだが、前者はVimScriptを読み書きしないといけなく気乗りしなかったので後者を選択することにした... そんなこんなで、時間はかかったがめでたくマージされましたとさ。

abema/go-mp4でASCの値を取得する

以下の記事ではASC(Audio Specific Config)をffprobeで取り出したが、これはmp4デマルチプレクサでも取り出すことができる。

izumisy.work

今回はabema/go-mp4を使ってやってみる。

mp4の仕様によればesdsというboxの中に入っているらしいので、それを取り出せばいい。

// esdsからASCの値を含むデスクリプタを取り出す
func getASCDescriptor(reader io.ReadSeeker) (*mp4.Descriptor, error) {
    var ascDescriptor *mp4.Descriptor

    if results, err := mp4.ExtractBoxWithPayload(reader, nil, mp4.BoxPath{
        mp4.BoxTypeMoov(),
        mp4.BoxTypeTrak(),
        mp4.BoxTypeMdia(),
        mp4.BoxTypeMinf(),
        mp4.BoxTypeStbl(),
        mp4.BoxTypeStsd(),
        mp4.BoxTypeMp4a(),
        mp4.BoxTypeEsds(),
    }); err != nil {
        return nil, err
    } else {
        esds := results[0].Payload.(*mp4.Esds)
        for _, descriptor := range esds.Descriptors {
            if descriptor.Tag == mp4.DecSpecificInfoTag {
                ascDescriptor = &descriptor
                break
            }
        }

        if ascDescriptor == nil {
            return nil, errors.New("no descriptor found")
        }
    }

    return ascDescriptor, nil
}

単なるm4aファイルに対して動かして確認してみた限り esds.Descriptors には4つ程度のデスクリプタが含まれていた。その中で mp4.DecSepcificInfoTag という値に該当するやつがASCを持っているっぽい。なのでそれを探してやればいい。

あとはこんな感じでgo-fdkaacの初期化のときのパラメタとして使える。

descriptor, err := getASCDescriptor(m4aFile)
if err != nil {
    panic(err)
} else if len(descriptor.Data) == 0 {
    panic(errors.New("no ASC available"))
}

decoder := fdkaac.NewAacDecoder()
if err := decoder.InitRaw([]byte{
    descriptor.Data[0],
    descriptor.Data[1],
}); err != nil {
    panic(err)
}

Jelly Pro 2でBluetoothの接続が安定しない

1年ほどUnihertzのJelly Pro 2を使っているのだがFitbit (Charge 4)との相性が悪いのかBluetoothの接続が全く安定せず、諦めてFitbitをただの時計として使ってきた。

さすがにちょっと嫌だなと思い改めていろいろ調べた結果、以下のRedditの投稿で少なくとも通知は来るようになりある程度Bluetoothとの接続がうまくいくようになった。

www.reddit.com

ざっくりBluetooth MIDI Service というシステムのアプリケーションがバックグラウンドで動くようにしただけだが、これまで毎回失敗してたFitbitアプリでの同期やアプリからの通知が動くようになった。

Github Action上でEarthlyからGithub Container Registryへimageをpushする

自分で管理しているDockerイメージのリポジトリでリリースフローを自動化してみたので備忘メモ。

github.com

実際のAction実装は以下のような感じ。

Earthlyもキャッシュをいい感じにしてくれるので2回目以降はかなり速い。

name: release

on:
  push:
    branches: 
      - master
      - main

jobs:
  release:
    runs-on: ubuntu-latest
    permissions:
      packages: write
      contents: read
    steps:
      - uses: actions/checkout@v3

      - name: Login to GHCR
        uses: docker/login-action@v1
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Setup earthly
        run: |
          wget https://github.com/earthly/earthly/releases/download/v0.6.1/earthly-linux-amd64 -O /tmp/earthly
          chmod +x /tmp/earthly
          /tmp/earthly bootstrap
          /tmp/earthly --version
      - name: Build and push
        run: /tmp/earthly --push --ci +image

ポイントは permissions のところで、そこが設定されていないとpackageを公開する権限がなく403エラーになってしまう。

ちなみに、Github上ではPackageに対してリポジトリと1:Nの関係で権限を設定できる。うっかり自分はそれを設定してしまったのだが、その場合にはActionの設定画面でPackageに対する権限を明示的に Role: Write にする必要があるっぽい。デフォルト設定はReadのみなので、ここでWriteが設定がされていないとyamlファイルの中で permissions を指定していても403エラーが出るので注意。

Packageに対するリポジトリの紐づけを行う画面

Earthlyからは unexpected status: 403 Forbidden というエラーしか出してくれないのでなにが原因なのかわからずらい...

とりあえずopenresty/openrestyを使ったイメージを作る

超最小構成でこんな感じ

FROM openresty/openresty:1.19.9.1-alpine

WORKDIR /app

COPY nginx.conf nginx.conf

EXPOSE 8080

CMD ["nginx", "-c", "/app/nginx.conf"]
daemon off;

http {
  server {
    listen 8080;
    
    location /hello {
      default_type text/plain;
      content_by_lua_block {
        ngx.say("hello")
      }
    }
  }
}

参考になりそう→ 逆引きlua-nginx-module · GitHub

Github Actionでdockerイメージをビルドし任意のコマンドを実行する

docker/docker-push-actionaddnab/docker-run-action というやつを組み合わせると最小構成でいい感じにできる。

name: Test
on: push

jobs:
  test:
    name: test
    runs-on: Ubuntu-20.04
    steps:
      - name: Checkout
        uses: actions/checkout@v2

      - uses: docker/build-push-action@v2
        with:
          file: Dockerfile.test
          tags: yourtestapp
          push: false # pushするならここtrueでもいい

      - uses: addnab/docker-run-action@v3
        with:
          image: yourtestapp
          options: -v ${{ github.workspace }}:/src
          run: make test

ビルドするDockerfile.testはこういう感じ

FROM golang:1.16.14-bullseye

ENV APP_ROOT /src
WORKDIR $APP_ROOT

# download app deps
COPY go.mod $APP_ROOT
COPY go.sum $APP_ROOT
RUN go mod download

# use bash for entrypoint to run commands passed on running this Docker image
ENTRYPOINT ["/bin/bash", "-c"]

ローカルでもdocker上でテスト実行できるようにソースコードまるごとはCOPYしない。

あとは、最後のENTRYPOINTに /bin/bash -c を与えているのがポイント。こうしないと docker run に渡した任意のコマンドをコンテナ内で実行できない。

OpenTelemetryでもGin+GAEのログをリクエスト単位でグルーピングする

前回のOpenCensusでは比較的すんなりとGAEのログをリクエスト単位でグルーピングさせることができたが、OpenTelemetryの場合には追加でGoogleCloudPlatformのGithub Orgが用意しているpropagatorを使わねばならない。 github.com

以下がとりあえず動く超最小構成のミドルウェア

package middleware

import (
    cloudpropagator "github.com/GoogleCloudPlatform/opentelemetry-operations-go/propagator"
    "github.com/gin-gonic/gin"
    "go.opentelemetry.io/otel/propagation"
    "go.opentelemetry.io/otel/trace"
)

func Tracer() gin.HandlerFunc {
    return func(c *gin.Context) {
        ctx := c.Request.Context()

        // まずはX-Cloud-Trace-Contextからの読み取りをトライ
        // ここでエラーを拾っても何もできないので意図的にエラーは無視する
        if sc, _ := cloudpropagator.New().SpanContextFromRequest(c.Request); sc.IsValid() {
            ctx = trace.ContextWithRemoteSpanContext(ctx, sc)
        } else {
             // X-Cloud-Trace-ContextからValidな値が取れない場合には
             // traceparentヘッダからのTraceID/SpanIDのパースを試してみる
             prop := propagation.TraceContext{}
             ctx = prop.Extract(ctx, propagation.HeaderCarrier(c.Request.Header))
        }

        // 必要に応じてNewTracerProviderの中でsamplerやexporterを指定する
        traceProvider := sdktrace.NewTracerProvider()
        defer traceProvider.Shutdown(c)

        tracer := traceProvider.Tracer("yourapp/trace") // Empty: use default tracer name
        spanCtx, span := tracer.Start(ctx, c.FullPath())
        defer span.End()

        c.Request = c.Request.WithContext(spanCtx)
        c.Next()
    }
}

あとは前回の記事を参考にしてContextからSpanIDとTraceIDを取り出してロガーで送ってやればいい。まじめにやるならサンプラーやエクスポータなどもちゃんと設定してやったほうがいい*1が、そのあたりはこの記事では触れない。

OpenCensusのときはパッケージの中にStackdriver用のエクスポータが用意されていて、その中で X-Cloud-Trace-Context ヘッダから値を取り出すパーサが実装されていた。しかしOpenTelemetryからはどうやらエクスポータはインターフェイスのみを提供し、プラットフォーム固有のエクスポータは自分たちで用意してくれという方針になったように見える。Googleが使っている X-Cloud-Trace-Context 相当のヘッダはW3Ctraceparent としてベンダ非依存のオープンな仕様へと標準化されたらしく、むしろGoogle側がOpenTelemetryの仕様に従うという構図になっているっぽい。

もしかするとOpenCensus時代はまだ分散トレーシングが黎明期(?)だったこともあって、むしろ先立って分散トレーシングを製品化したり大規模に使ったりしていたGoogle側の仕様をOpenCensus側が取り入れたのかもしれない。

ヘッダの仕様に関しては以下のドキュメントが参考になった。 cloud.google.com

*1:たとえばこのサンプルではCloud Traceが動かない。トレーシングをするにしてもサンプリングの頻度指定もされていない。