DeckLink SDK に含まれる CapturePreview.app の仕様と改修

Blackmagic Desktop Video のドライバをインストールすると関連アプリとして Blackmagic Media Express.app がインストールされる。 このアプリはキャプチャした RAW Data を映像・音声ともに保存できる優れものであるが、プレビューのためのアプリとしては開発されておらず、 全画面表示はおろか、音声の再生にも対応していない。一方、DeckLink SDK には CapturePreview.app というキャプチャ映像のプレビューアプリが 含まれているが、Open Source ではあるものの、全画面表示はできず、音声の再生もできず、アスペクト比も操作できない [1-3]。ここに纏めた抄録はその CapturePreview.app を 解析し、以上の問題点を解消する為に個人的に改造した結果を纏める備忘録である。ライセンス的に安全か不明であるから現状バイナリデータを配布する予定はない。 なお以下の内容は、一般的な Objective-C を用いた macOS 用アプリ開発の基本的な知識と C++ の初歩的な知識を前提とした上で、 Core Audio Framework といった映像関連の API を用いるものである。-awakeFromNib の呼ばれるタイミング、AppDelegate の利用方法、 IBOutlet, IBAction の意味などは割愛する為、これがわからない場合は、Aaron Hillegass, Adam Preble の入門書 "MAC OS X COCOA Programming" などを参考にされたい。 図:CapturePreview.app で Nintendo Switch をキャプチャしている様子 図:全画面表示した CapturePreview.app で Mission Control を使った様子 図:全画面表示した CapturePreview.app で Main Menu を表示した様子 図:CapturePreview.app を Dock に格納している様子
  1. 【Mac】HDMIキャプチャーを行う備忘録(2015年版)
  2. Full HD映像をMacに入力してoFでリアルタイムにエフェクトをかける
  3. Cycling’74 Max で Blackmagic Design – Intensity Shuttle for Thunderbolt 用 視聴・プレビューアプリ を作ってみた

Indexes of:

  1. CapturePreview.app の概要
    1. CapturePreviewAppDelegate Class
      1. -startCapture
    2. DeckLinkDevice Class
      1. DeckLinkDevice::startCapture()
      2. DeckLinkDevice::stopCapture()
      3. DeckLinkDevice::VideoInputFormatChanged()
      4. DeckLinkDevice::VideoInputFrameArrived()
    3. VideoGLView Class
  2. CapturePreview.app の改修
    1. CapturePreviewAppDelegate の修正
    2. 映像データのビットマップ化
    3. 音声再生機能の追加
    4. 全画面表示への対応
    5. 安眠妨害:全画面表示時のスリープ対応
    6. レターボックスの導入
    7. Window を Dock にしまう(最小化)際の挙動
  3. Nintendo Switch (Wii U) の接続(Intensity Pro 4K over Thunderbolt)
    1. DeckLinkDevice::VideoInputFrameArrived() の修正
    2. 映像データの量子数
    3. 映像データの色空間
    4. Core Graphics とビットマップの形式
    5. サラウンドのチャンネルとスピーカの対応
    6. HDMI EDID 問題
    7. 配信とその映像の確認

CapturePreview.app の概要

CapturePreview.app は極めてシンプルなアプリケーションである。初めに MainMenu.xib を展開すると CapturePreviewAppDelegate という AppDelegate が定義されており、これが Main Window の全ての操作を受け付ける Controller となっていることが確認できる。 主な Source code は Objective-C++ で書かれており、CapturePreview.mm と DeckLinkController.mm に分かれている。 Objective-C Class の CapturePreviewAppDelegate を定義した CapturePreview.mm と 実際に DeckLink API を呼び出す C++ Class の DeckLinkDevice を定義した DeckLinkController.mm とである。 DeckLink API 自体は C++ で書かれているため、Objective-C++ を採用したと考えられる。 このうち CapturePreview.app の動作を理解する上で重要な箇所は限られているので、その箇所を抜萃して紹介する。

CapturePreviewAppDelegate Class

先ず CapturePreview.mm について見てみよう。ここで CapturePreviewAppDelegate Class は AppDelegate として定義されており、 UI の操作と対応しているため、DeckLink API の呼び出し方法に対して有益な知見を与えるものとなっている。 私がこの Source code で重要であると思う method は次の通りである。

- (void)startCapture;
- (void)stopCapture;
- (void)selectDetectedVideoMode:(BMDDisplayMode)newVideoMode;

それぞれキャプチャを開始、停止する、デバイスがキャプチャする映像のサイズといった format に変更があった場合に呼び出されるものとなっている。

-startCapture

例えばここで -startCapture について見てみることにする。

- (void)startCapture
{
	if (selectedDevice && selectedDevice->startCapture([[modeListPopup selectedItem] tag], screenPreviewCallback, ([applyDetectedVideoMode state] == NSOnState)))
	{
		// Update UI
		[startStopButton setTitle:@"Stop"];
		[self enableInterface: NO];
	}
}

-startCapture が行なっていることは selectedDevice が存在する場合に selectedDevice->startCapture() を呼び出しキャプチャを開始する事である。 CapturePreview.app はキャプチャボードを Thunderbolt 接続した際などの hot-swap や複数のキャプチャボードを搭載していた場合へ対応している。 selectedDevice が NULL の場合は、システムにキャプチャボードが認識されていない場合であり、selectedDevice が NULL でない場合は UI の Input Card で選択されたキャプチャボードを操作するインスタンスが割り当てらていることを意味している。 CapturePreview.h を確認すると、selectedDevice が C++ Class DeckLinkDevice のインスタンスであったことがわかる。

@interface CapturePreviewAppDelegate : NSObject <NSApplicationDelegate> {
	NSWindow*						window;
	
	DeckLinkDeviceDiscovery*		deckLinkDiscovery;
	IDeckLinkScreenPreviewCallback*	screenPreviewCallback;
	DeckLinkDevice*					selectedDevice;
	ProfileCallback*				profileCallback;
	BMDVideoConnection				selectedInputConnection;

DeckLinkDevice Class

次に DeckLinkController.mm について見てみることとする。ここで C++ Class DeckLinkDevice が定義されており、その Method のうち重要且つ変更を加えるべきは次の通りである。

bool DeckLinkDevice::startCapture(BMDDisplayMode displayMode, IDeckLinkScreenPreviewCallback* screenPreviewCallback, bool applyDetectedInputMode);
void DeckLinkDevice::stopCapture();
HRESULT DeckLinkDevice::VideoInputFormatChanged(/* in */ BMDVideoInputFormatChangedEvents notificationEvents, /* in */ IDeckLinkDisplayMode *newMode, /* in */ BMDDetectedVideoInputFormatFlags detectedSignalFlags);
HRESULT DeckLinkDevice::VideoInputFrameArrived(/* in */ IDeckLinkVideoInputFrame* videoFrame, /* in */ IDeckLinkAudioInputPacket* audioPacket);

それぞれキャプチャを具体的に開始、停止するもの、入力される映像のサイズなどに変更があった際の callback、入力された映像フレームを与える callback である。

DeckLinkDevice::startCapture()

キャプチャを具体的に開始する DeckLinkDevice::startCapture() は CapturePreviewAppDelegate の -startCapture から呼び出される手続きである。DeckLink API を用いる上で重要な箇所があるので、一つ一つ確認しておく。

bool		DeckLinkDevice::startCapture(BMDDisplayMode displayMode, IDeckLinkScreenPreviewCallback* screenPreviewCallback, bool applyDetectedInputMode)
{
	BMDVideoInputFlags		videoInputFlags;
	
	// Enable input video mode detection if the device supports it
	videoInputFlags = (supportFormatDetection && applyDetectedInputMode) ? bmdVideoInputEnableFormatDetection : bmdVideoInputFlagDefault;
	
	// Set the screen preview
	deckLinkInput->SetScreenPreviewCallback(screenPreviewCallback);
	
	// Set capture callback
	deckLinkInput->SetCallback(this);
	
	// Set the video input mode
	if (deckLinkInput->EnableVideoInput(displayMode, bmdFormat10BitYUV, videoInputFlags) != S_OK)
	{
		[uiDelegate showErrorMessage:@"This application was unable to select the chosen video mode. Perhaps, the selected device is currently in-use." title:@"Error starting the capture"];
		return false;
	}
	
	// Start the capture
	if (deckLinkInput->StartStreams() != S_OK)
	{
		[uiDelegate showErrorMessage:@"This application was unable to start the capture. Perhaps, the selected device is currently in-use." title:@"Error starting the capture"];
		return false;
	}
	
	currentlyCapturing = true;
	
	return true;
}

uiDelegate は CapturePreviewAppDelegate Class のインスタンスで名前の通り delegate として取り扱われている。 これは CapturePreviewAppDelegate の -applicationDidFinishLaunching: で new DeckLinkDeviceDiscovery(self) とすることで渡されたものである。 uiDelegate はこの code をみる限りここではエラーメッセージをユーザに伝える以上の機能を与えられていないとわかる。 ここでは主に IDeckLinkInput Class のインスタンスである deckLinkInput を操作していく。

初めに DeckLink.h を確認する。IDeckLinkInput::SetCallback() の引数は IDeckLinkInputCallback Class のインスタンスが指定されているが、 IDeckLinkInputCallback Class には IDeckLinkInputCallback::VideoInputFormatChanged() と IDeckLinkInputCallback::VideoInputFrameArrived() が定義されていて、DeckLinkDevice Class のインスタンスに これらを実装する事で Callback として機能させている。

次に IDeckLinkInput::EnableVideoInput() である。これは映像のキャプチャを許可しその設定を行うものである。 引数に関し DeckLinkAPIModes.h を確認すると BMDDisplayMode 定数は映像のサイズと FPS を指定する定数で、 bmdFormat10BitYUV と渡されているものは BMDPixelFormat 定数であり、映像形式として v210 を指している。 今ここで映像形式を指定する意味はあまり無いようである。何故ならば後述の DeckLinkDevice::VideoInputFormatChanged() で 動的に BMDDisplayMode 定数や BMDPixelFormat 定数を指定しているからである。例えば bmdFormat10BitRGB は r210 を指している。

そして最後に IDeckLinkInput::StartStreams() を呼び出す事でキャプチャを開始している。ここで DeckLinkAPI.h を確認しておく。

class BMD_PUBLIC IDeckLinkInput : public IUnknown
{
public:
    virtual HRESULT DoesSupportVideoMode (/* in */ BMDVideoConnection connection /* If a value of 0 is specified, the caller does not care about the connection */, /* in */ BMDDisplayMode requestedMode, /* in */ BMDPixelFormat requestedPixelFormat, /* in */ BMDSupportedVideoModeFlags flags, /* out */ bool *supported) = 0;
    virtual HRESULT GetDisplayMode (/* in */ BMDDisplayMode displayMode, /* out */ IDeckLinkDisplayMode **resultDisplayMode) = 0;
    virtual HRESULT GetDisplayModeIterator (/* out */ IDeckLinkDisplayModeIterator **iterator) = 0;

    virtual HRESULT SetScreenPreviewCallback (/* in */ IDeckLinkScreenPreviewCallback *previewCallback) = 0;

    /* Video Input */

    virtual HRESULT EnableVideoInput (/* in */ BMDDisplayMode displayMode, /* in */ BMDPixelFormat pixelFormat, /* in */ BMDVideoInputFlags flags) = 0;
    virtual HRESULT DisableVideoInput (void) = 0;
    virtual HRESULT GetAvailableVideoFrameCount (/* out */ uint32_t *availableFrameCount) = 0;
    virtual HRESULT SetVideoInputFrameMemoryAllocator (/* in */ IDeckLinkMemoryAllocator *theAllocator) = 0;

    /* Audio Input */

    virtual HRESULT EnableAudioInput (/* in */ BMDAudioSampleRate sampleRate, /* in */ BMDAudioSampleType sampleType, /* in */ uint32_t channelCount) = 0;
    virtual HRESULT DisableAudioInput (void) = 0;
    virtual HRESULT GetAvailableAudioSampleFrameCount (/* out */ uint32_t *availableSampleFrameCount) = 0;

    /* Input Control */

    virtual HRESULT StartStreams (void) = 0;
    virtual HRESULT StopStreams (void) = 0;
    virtual HRESULT PauseStreams (void) = 0;
    virtual HRESULT FlushStreams (void) = 0;
    virtual HRESULT SetCallback (/* in */ IDeckLinkInputCallback *theCallback) = 0;

    /* Hardware Timing */

    virtual HRESULT GetHardwareReferenceClock (/* in */ BMDTimeScale desiredTimeScale, /* out */ BMDTimeValue *hardwareTime, /* out */ BMDTimeValue *timeInFrame, /* out */ BMDTimeValue *ticksPerFrame) = 0;

protected:
    virtual ~IDeckLinkInput () {} // call Release method to drop reference count
};

IDeckLinkInput::EnableVideoInput() に対して IDeckLinkInput::EnableAudioInput() なる手続きが用意されている事がわかる。 実際 IDeckLinkInput::StartStreams() の前にこれを呼び出す事で音声のキャプチャも可能となるが、 CapturePreview.app にも DeckLink API にもキャプチャした音声を再生させる機能はついていない為、別途用意する必要性がある。

DeckLinkDevice::stopCapture()

キャプチャを具体的に停止する DeckLinkDevice::stopCapture() は CapturePreviewAppDelegate の -stopCapture から呼び出される手続きである。

void		DeckLinkDevice::stopCapture()
{
	// Stop the capture
	deckLinkInput->StopStreams();
	
	// Delete capture callback
	deckLinkInput->SetCallback(NULL);
	deckLinkInput->DisableVideoInput();
	
	currentlyCapturing = false;
}

DeckLinkDevice::startCapture() で行なった操作と逆の事を行っているだけである。

DeckLinkDevice::VideoInputFormatChanged()

入力される映像のサイズなどに変更があった際、IDeckLinkInput の Callback としてこの DeckLinkDevice::VideoInputFormatChanged() が呼び出される事になっている。 DeckLinkDevice::startCapture() で解説した様にここで初めて動的に BMDPixelFormat 定数など映像形式が動的に設定されている。

HRESULT		DeckLinkDevice::VideoInputFormatChanged (/* in */ BMDVideoInputFormatChangedEvents notificationEvents, /* in */ IDeckLinkDisplayMode *newMode, /* in */ BMDDetectedVideoInputFormatFlags detectedSignalFlags)
{
	UInt32				flags = bmdVideoInputEnableFormatDetection;
	BMDPixelFormat		pixelFormat = bmdFormat10BitYUV;
	
	NSAutoreleasePool* pool = [[NSAutoreleasePool alloc] init];

	if (detectedSignalFlags & bmdDetectedVideoInputRGB444)
		pixelFormat = bmdFormat10BitRGB;

	if (detectedSignalFlags & bmdDetectedVideoInputDualStream3D)
		flags |= bmdVideoInputDualStream3D;

	// Restart capture with the new video mode if told to
	if ([uiDelegate shouldRestartCaptureWithNewVideoMode] == YES)
	{
		// Stop the capture
		deckLinkInput->StopStreams();
		
		// Set the video input mode
		if (deckLinkInput->EnableVideoInput(newMode->GetDisplayMode(), pixelFormat, flags) != S_OK)
		{
			[uiDelegate stopCapture];
			[uiDelegate showErrorMessage:@"This application was unable to select the new video mode." title:@"Error restarting the capture."];
			goto bail;
		}
		
		// Start the capture
		if (deckLinkInput->StartStreams() != S_OK)
		{
			[uiDelegate stopCapture];
			[uiDelegate showErrorMessage:@"This application was unable to start the capture on the selected device." title:@"Error restarting the capture."];
			goto bail;
		}
	}
	
	// Update the UI with detected display mode
	[uiDelegate selectDetectedVideoMode: newMode->GetDisplayMode()];
	
bail:
	[pool release];
	return S_OK;
}

変更された映像信号に対応させる為に、一度キャプチャを停止させ、設定を反映させてから再度キャプチャを行う実装である。 主に IDeckLinkInput *deckLinkInput を操作していく点は DeckLinkDevice::startCapture() と大差が無い。 異なるのは IDeckLinkDisplayMode Class のインスタンス newMode の存在とその取り扱いである。DeckLinkAPIModes.h に次の通りある。

class BMD_PUBLIC IDeckLinkDisplayMode : public IUnknown
{
public:
    virtual HRESULT GetName (/* out */ CFStringRef *name) = 0;
    virtual BMDDisplayMode GetDisplayMode (void) = 0;
    virtual long GetWidth (void) = 0;
    virtual long GetHeight (void) = 0;
    virtual HRESULT GetFrameRate (/* out */ BMDTimeValue *frameDuration, /* out */ BMDTimeScale *timeScale) = 0;
    virtual BMDFieldDominance GetFieldDominance (void) = 0;
    virtual BMDDisplayModeFlags GetFlags (void) = 0;

protected:
    virtual ~IDeckLinkDisplayMode () {} // call Release method to drop reference count
};

現在の映像信号のサイズや色情報が動的に取得できる様になっているが、音声に関する設定は存在していない。 従って音声形式は IDeckLinkInput::EnableAudioInput() を呼び出す際に静的に指定する必要性がある。

DeckLinkDevice::VideoInputFrameArrived()

入力された映像フレームを与える IDeckLinkInput の Callback としてこの DeckLinkDevice::VideoInputFrameArrived() が呼び出される。後々様々な機能を追加していく上で最も重要な箇所である。

HRESULT 	DeckLinkDevice::VideoInputFrameArrived (/* in */ IDeckLinkVideoInputFrame* videoFrame, /* in */ IDeckLinkAudioInputPacket* audioPacket)
{
	BOOL							hasValidInputSource = (videoFrame->GetFlags() & bmdFrameHasNoInputSource) != 0 ? NO : YES;
	NSAutoreleasePool* 				pool = [[NSAutoreleasePool alloc] init];
	AncillaryDataStruct*			ancillaryData = [[[AncillaryDataStruct alloc] init] autorelease];
	
	// Update input source label
	[uiDelegate updateInputSourceState:hasValidInputSource];
	
	// Get the various timecodes and userbits for this frame
	ancillaryData.vitcF1 = getAncillaryDataFromFrame(videoFrame, bmdTimecodeVITC);
	ancillaryData.vitcF2 = getAncillaryDataFromFrame(videoFrame, bmdTimecodeVITCField2);
	ancillaryData.rp188vitc1 = getAncillaryDataFromFrame(videoFrame, bmdTimecodeRP188VITC1);
	ancillaryData.rp188ltc = getAncillaryDataFromFrame(videoFrame, bmdTimecodeRP188LTC);
	ancillaryData.rp188vitc2 = getAncillaryDataFromFrame(videoFrame, bmdTimecodeRP188VITC2);
	ancillaryData.rp188hfrtc = getAncillaryDataFromFrame(videoFrame, bmdTimecodeRP188HighFrameRate);
	ancillaryData.hdrMetadata = getHDRMetadataFromFrame(videoFrame);

	// Update the UI
	dispatch_block_t updateAncillary = ^{
		[uiDelegate setAncillaryData:ancillaryData];
		[uiDelegate reloadAncillaryTable];
	};
	
	dispatch_async(dispatch_get_main_queue(), updateAncillary);

	[pool release];
	return S_OK;
}

興味深い点は IDeckLinkVideoInputFrame Class のインスタンス videoFrame と IDeckLinkAudioInputPacket Class のインスタンス audioPacket が 二つの引数が与えられている点である。しかし、ここでやっている事は SMPTE RP188 準拠の補助データ(Ancillary Data)として time code 情報の表示を行っている事だけである。 キャプチャした映像フレームからは映像自体のデータが一切取り出されておらず、描画処理はここに記述されていない。描画処理に関しては後述する様に OpenGL で自動的に処理される実装が取られている。 IDeckLinkVideoInputFrame Class と IDeckLinkAudioInputPacket Class の実装を確認しておこう。DeckLinkAPI.h に次の記載がある。

class BMD_PUBLIC IDeckLinkVideoFrame : public IUnknown
{
public:
    virtual long GetWidth (void) = 0;
    virtual long GetHeight (void) = 0;
    virtual long GetRowBytes (void) = 0;
    virtual BMDPixelFormat GetPixelFormat (void) = 0;
    virtual BMDFrameFlags GetFlags (void) = 0;
    virtual HRESULT GetBytes (/* out */ void **buffer) = 0;

    virtual HRESULT GetTimecode (/* in */ BMDTimecodeFormat format, /* out */ IDeckLinkTimecode **timecode) = 0;
    virtual HRESULT GetAncillaryData (/* out */ IDeckLinkVideoFrameAncillary **ancillary) = 0; // Use of IDeckLinkVideoFrameAncillaryPackets is preferred

protected:
    virtual ~IDeckLinkVideoFrame () {} // call Release method to drop reference count
};

class BMD_PUBLIC IDeckLinkVideoInputFrame : public IDeckLinkVideoFrame
{
public:
    virtual HRESULT GetStreamTime (/* out */ BMDTimeValue *frameTime, /* out */ BMDTimeValue *frameDuration, /* in */ BMDTimeScale timeScale) = 0;
    virtual HRESULT GetHardwareReferenceTimestamp (/* in */ BMDTimeScale timeScale, /* out */ BMDTimeValue *frameTime, /* out */ BMDTimeValue *frameDuration) = 0;

protected:
    virtual ~IDeckLinkVideoInputFrame () {} // call Release method to drop reference count
};

class BMD_PUBLIC IDeckLinkAudioInputPacket : public IUnknown
{
public:
    virtual long GetSampleFrameCount (void) = 0;
    virtual HRESULT GetBytes (/* out */ void **buffer) = 0;
    virtual HRESULT GetPacketTime (/* out */ BMDTimeValue *packetTime, /* in */ BMDTimeScale timeScale) = 0;

protected:
    virtual ~IDeckLinkAudioInputPacket () {} // call Release method to drop reference count
};

具体的な利用法は後で詳述するが例えば IDeckLinkVideoInputFrame::GetBytes(), IDeckLinkAudioInputPacket::GetBytes() 共に定義されている。 引数の void **buffer は nullable で実際には DeckLink API 内部で確保しているポインタのアドレスを取得するための code とみられる。 IDeckLinkAudioInputPacket::GetSampleFrameCount() などを組み合せる事で、音声を再生したり、ビットマップ画像の取得が可能となる。

VideoGLView Class

以上見た通り、キャプチャした映像フレームはどこにも利用される事なく callback が終了してしまっている。 そうなるとどのように描画処理が行われているのかが最大の疑問となるわけであるが、これは CapturePreviewAppDelegate が持つ previewView がどのように生成されているか確認すると理解できる。NSApplicationDelegate の通知を確認すると以下の様になっている。

- (void)applicationDidFinishLaunching:(NSNotification *)aNotification {
	//
	// Setup UI
	
	// Empty popup menus
	[deviceListPopup removeAllItems];
	[modeListPopup removeAllItems];
	[ancillaryDataTable reloadData];
	
	// Disable the interface
	[startStopButton setEnabled:NO];
	[self enableInterface:NO];
	
	//
	// Create and initialise DeckLink device discovery, profile manager and preview objects
	screenPreviewCallback = CreateCocoaScreenPreview(previewView);
	deckLinkDiscovery = new DeckLinkDeviceDiscovery(self);
	profileCallback = new ProfileCallback(self);

ただの NSView* でしかない previewView は DeckLinkDevice::startCapture() を呼び出す際の引数の一つ IDeckLinkScreenPreviewCallback *screenPreviewCallback を CreateCocoaScreenPreview によって作成する事にしか使われていない。 それでは実際に previewView がどのような役割を担っているかといえば、稼働中の previewView の -subviews を呼び出して確認すると、 subview は一つしか存在せず、VideoGLView という Class のインスタンスとなっている。VideoGLView は NSOpenGLView の subclass である。 従って DeckLink API によって自動的に VideoGLView へと映像フレームが渡されて描画されていると考えられる。 故に DeckLinkDevice::VideoInputFrameArrived() の IDeckLinkVideoInputFrame * が用いられる事なく終了していても、映像が別途 OpenGL で描画されていることが理解できた。

CapturePreview.app の改修

これまで CapturePreview.app の重要な箇所と DeckLink API の要点に関して確認してきた。以下では実際に CapturePreview.app の改修を行う事で 音声を再生したり、全画面表示に対応させる事を行っていく。

CapturePreviewAppDelegate の修正

現在の最新版 SDK 11.1 を利用すると Xcode では CapturePreview.m において UI API called on a background thread と警告されるので、その修正内容を示す。

- (BOOL)shouldRestartCaptureWithNewVideoMode // Bug fixed: UI API called on a background thread
{
	dispatch_group_t group = dispatch_group_create();
	__block NSControlStateValue state;
	
	dispatch_group_async(group, dispatch_get_main_queue(), ^() {
		state = [applyDetectedVideoMode state];
	});
	
	dispatch_group_wait(group, DISPATCH_TIME_FOREVER);
	return (state == NSOnState) ? YES : NO;
}


- (void)updateInputSourceState:(BOOL)state // Bug fixed: UI API called on a background thread
{
	// Check if the state has changed
	dispatch_async(dispatch_get_main_queue(), ^() {
		if ([noValidSource isHidden] != state)
		{
			[noValidSource setHidden:state];
		}
	});
}


- (void)selectDetectedVideoMode:(BMDDisplayMode)newVideoMode // Bug fixed: UI API called on a background thread
{
	dispatch_async(dispatch_get_main_queue(), ^() {
		[modeListPopup selectItemWithTag:(NSInteger)newVideoMode];
	});
	
}

AppKit では UI を参照・変更する API は Main thread からの呼び出しでなければならないという制約が存在する。 これらの Method は DeckLinkDevice::VideoInputFormatChanged(), DeckLinkDevice::VideoInputFrameArrived() からの呼び出しであるが、 IDeckLinkInput が thread 並列化する事で遅延の低減を図っている事でこの様な問題が生じている。一般には単純に main queue に投げておけば良いが -shouldRestartCaptureWithNewVideoMode の様に結果を必要とする場合はきちんと wait して処理が終わるのを待つ必要性がある。

  1. Main Thread Checker: UI API called on a background thread: -[UIApplication applicationState]

映像データのビットマップ化

DeckLinkDevice::VideoInputFrameArrived に渡される IDeckLinkVideoInputFrame *videoFrame を取得する事でビットマップを作成する事を考える。 CapturePreviewAppDelegate に -didArriveDataInputFrame:inputPacket: とでもカテゴリを追加し、DeckLinkDevice::VideoInputFrameArrived へ [uiDelegate didArriveDataInputFrame:videoFrame inputPacket:audioPacket]; とする一文を追加しておく。

#include <CoreGraphics/CGImage.h>
#include <CoreGraphics/CGBitmapContext.h>
/***** omission *****/
- (void)didArriveDataInputFrame:(IDeckLinkVideoInputFrame *)videoFrame inputPacket:(IDeckLinkAudioInputPacket *)audioPacket
{
	CGColorSpaceRef colorSpace;
	CGContextRef context;
	CGImageRef image;
	CGSize size;
	NSImage *img;
	
	const unsigned char *rawData;
	unsigned char *mapData;
	NSUInteger height, width;
	NSUInteger bitsPerComponent, bytesPerRow;
	
	if(!videoFrame) return;
	
	width = videoFrame->GetWidth();
	height = videoFrame->GetHeight();
	size = CGSizeMake(width, height);
	colorSpace = CGColorSpaceCreateWithName(kCGColorSpaceDCIP3);
	
	//	32-Bit ARGB Format
	bitsPerComponent = 8;
	bytesPerRow = width * 4;
	
	mapData = (unsigned char *)malloc(bytesPerRow * height * sizeof(unsigned char));
	context = CGBitmapContextCreate(mapData, width, height, bitsPerComponent, bytesPerRow, colorSpace, kCGImageAlphaPremultipliedFirst);
	
	videoFrame->GetBytes(&rawData);
	while(height--)
	{
		width = size.width;
		while(width--)
		{
			//	r210 Format to 32-Bit ARGB Format
			mapData[0] = 0xFF; // A
			mapData[1] = (rawData[1] >> 6) | (rawData[0] << 2); // R
			mapData[2] = (rawData[2] >> 4) | (rawData[1] << 4); // G
			mapData[3] = (rawData[3] >> 2) | (rawData[2] << 6); // B
			mapData += 4;
			rawData += 4;
		}
	}
	
	CGContextFlush(context);
	image = CGBitmapContextCreateImage(context);
	
	img = [[NSImage alloc] initWithCGImage:image size:NSSizeFromCGSize(size)];
	[[img TIFFRepresentation] writeToFile:[NSString stringWithFormat:@"%@/test.tif", NSHomeDirectory()] atomically:YES];
	[img release];
	
	CGColorSpaceRelease(colorSpace);
	CGContextRelease(context);
	CGImageRelease(image);
	free(mapData);
}

上記の例は BMDPixelFormat として bmdFormat10BitRGB が指定されていた場合の所謂 r210 形式 [1] を 32 Bit の ARGB へ変換し NSImage を作成、実行ユーザのホームに test.tif として保存するサンプルである。 色空間は DCI-P3 を指定しているが正しい色空間は機器に依存して決まる。殆ど場合は sRGB だろう。BMDPixelFormat 毎のビット配置は DeckLink SDK Manual の 2.7.4 Pixel Formats に詳しく書かれているので適宜参照されると良い。 bmdFormat10BitRGB は Big-Endian と明言されているので PowerPC で実行すると異なる結果になるかもしれないが、Intel Mac では正しい結果が得られている。 尚リアルタイムにこの処理を行う場合は mapData などの逐次作成にメモリ管理上の無駄があるので、適当に DeckLinkDevice::VideoInputFormatChanged() が呼ばれた時に映像サイズを取得してキャッシュした方が良い。

また rawData から mapData を作る箇所は BMI2 で追加された PEXT 命令を使って最適化することが可能である。 例えば 32-Bit BGR の 3 bytes のビット列 Y は r210 の 4 bytes のビット列 X に対して Y := PEXT(SHUFFLE(X), 0x3FCFF3FC) と定義できる。 x86intrin.h を読み込んで _pext_u64 を使いコンパイラに -mbmi2 を指定(Xcode では Build Settings の Other C Flags に追加する)してやれば 4 bytes あたり 12 回あった演算命令が 2 回の演算命令に置き換わる。SHUFFLE は与えたビット列をバイトシャッフルする命令で、SSSE3 の _mm_shuffle_epi8 などで実装できる [2]。

  1. <mini> 10-bit RGB フォーマットまとめ
  2. x86/x64 SIMD命令一覧表 (SSE~AVX512)

音声再生機能の追加

今後対応しやすくする為には極力元のコードに変更を加える事を避けたいので、機能の追加による変更は DeckLinkController.mm と MainMenu.xib に留める事にする。 CapturePreviewAppDelegate への修正はカテゴリの追加で対応する事にして、-startCapture, -stopCapture だけは上書きで対処する。

#import <objc/runtime.h>

@implementation CapturePreviewAppDelegate (CapturePreviewAudioSupport)
+ (void)initialize
{
	if(self != [CapturePreviewAppDelegate self]) return;
	
	//	Replace API
	Method fromMethod, toMethod;
	fromMethod = class_getInstanceMethod(self, @selector(startCapture));
	toMethod = class_getInstanceMethod(self, @selector(startExtendedCapture));
	method_exchangeImplementations(fromMethod, toMethod);
	fromMethod = class_getInstanceMethod(self, @selector(stopCapture));
	toMethod = class_getInstanceMethod(self, @selector(stopExtendedCapture));
	method_exchangeImplementations(fromMethod, toMethod);
}

- (void)startExtendedCapture
{
	[self startExtendedCapture]; // Send Message, inputal -startCapture
	
	if([[startStopButton title] isEqualToString:@"Stop"]) // On success is always "Stop"
	{
		[[CaptureObject sharedObject] startCaptureAudio];
	}
}
- (void)stopExtendedCapture
{
	[[CaptureObject sharedObject] stopCaptureAudio];
	
	[self stopExtendedCapture]; // Send Message, inputal -stopCapture
}
@end

所謂 Runtime 函数を呼び出すことで Method の入れ替えを行う邪法である [1]。一般にこの様な Runtime 函数は Objective-C のオーバヘッドの逓減を企図した場合に使うことが殆どであるが、 今回は利用法がはっきりしているのでこの様な実装をとる。CapturePreviewAppDelegate が -init される前に入れ替えたいので +initialize で入れ替えを行う。 CaptureObject は singleton として定義した Objective-C Class で CapturePreviewAppDelegate を書き換えない場合にインスタンス変数を持たせる為に用意している [2,3]。

次に音声データのキャプチャを開始させる為、DeckLinkDevice.mm の DeckLinkDevice::startCapture() と DeckLinkDevice::VideoInputFormatChanged() の IDeckLinkInput::EnableVideoInput() を呼び出している辺りに次の様な code を挿入する。

		// Set the audio input mode
		if (deckLinkInput->EnableAudioInput(bmdAudioSampleRate48kHz, bmdAudioSampleType32bitInteger, 2) != S_OK)
		{
			[uiDelegate stopCapture];
			[uiDelegate showErrorMessage:@"This application was unable to select the new audio mode." title:@"Error restarting the capture."];
			goto bail;
		}

また DeckLinkDevice.mm の DeckLinkDevice::stopCapture() の IDeckLinkInput::DisableVideoInput() を呼び出している辺りに次の code を挿入する。

		deckLinkInput->DisableAudioInput();

上記では入力される音声データは linear PCM のステレオ音声でサンプリングは 48 kHz で 32 Bit とであると仮定している。 DeckLink API ではサンプリング周波数は 48 kHz 固定であり、ビット深度は 16 Bit か 32 Bit、チャンネル数は 2, 8, 16 の何れかをサポートする。 音声データの符号化方式は全く仮定されておらず、おそらく Dolby Digital AC-3 といった符号化データをキャプチャすることもできる。 BMDAudioFormat として bmdAudioFormatPCM が定義されているが、これは IDeckLinkEncoderInput で用いる物である。

次に Core Audio API を呼び出す CaptureObject の定義と実装を示す。

#import <CoreAudio/CoreAudio.h>
#import <AudioUnit/AudioUnit.h>
#import <AudioToolbox/AudioServices.h>

typedef struct _DataContainer * DataContainerRef;
typedef union _DataBucket * DataBucketRef;
typedef struct _DataContainer DataContainer;
typedef struct _DataStream DataStream;
typedef union _DataBucket DataBucket;
typedef struct _DataFormat DataFormat;

static OSStatus CaptureAudioRenderingCallback(void *inRefCon, AudioUnitRenderActionFlags *ioActionFlags, const AudioTimeStamp *inTimeStamp, UInt32 inBusNumber, UInt32 inNumberFrames, AudioBufferList *ioData);

union _DataBucket{
	unsigned char UChar4[4]; // SInt32 Sample Depth
	unsigned char UChar3[3]; // SInt24 Sample Depth
	unsigned char UChar2[2]; // SInt16 Sample Depth
	SInt64 SInt32:32;
	SInt64 SInt24:24;
	SInt64 SInt16:16;
	SInt64 SInteger;
};
struct _DataStream
{
	NSUInteger sizeOfFrame, sizeOfChannel, sampleDepth, channels;
	DataBucketRef buckets;
};
struct _DataContainer
{
	NSUInteger inSampleDepth, outSampleDepth;
	DataStream input, output;
	
	NSUInteger indexes[16]; // surround mixer
	
	//	ring-buffer
	UInt64 count, tail, capacity;
	unsigned char *buffer;
	bool isRound;
};
struct _DataFormat
{
	NSUInteger sampleDepth;
	NSUInteger channels;
	
	BOOL isInterleaved, isPacked;
}
@interface CaptureObject : NSObject
{
	AudioUnit unit;
	
	NSUInteger sampleRate;
	DataContainer data;
	DataFormat inFormat, outFormat;
	DataBucket inBuckets[16], outBuckets[16];
}
@end

NS_INLINE DataStream MakeDataSteam(DataFormat format)
{
	DataStream stream;
	
	stream.channels = format.channels;
	stream.sampleDepth = format.sampleDepth;
	stream.sizeOfChannel = stream.sampleDepth / 8;
	stream.sizeOfFrame = stream.channels * stream.sizeOfChannel;
	
	return stream;
}

@implementation CaptureObject
static CaptureObject *_sharedData = nil;

+ (instancetype)sharedObject
{
	@synchronized(self)
	{
		if(!_sharedData) _sharedData = [self new];
	}
	return _sharedData;
}

- (instancetype)init
{
	AudioComponentDescription descriptor;
	AudioComponent component;
	OSStatus status;
	
	self = [super init];
	if(self)
	{
		unit = NULL;
		
		descriptor.componentType = kAudioUnitType_Output;
		descriptor.componentSubType = kAudioUnitSubType_DefaultOutput;
		descriptor.componentManufacturer = kAudioUnitManufacturer_Apple;
		descriptor.componentFlags = 0;
		descriptor.componentFlagsMask = 0;
		component = AudioComponentFindNext(NULL, &descriptor);
		if(!component) goto bail;
		status = AudioComponentInstanceNew(component, &unit);
		if(status != kAudioServicesNoError) goto bail;
		status = AudioUnitInitialize(unit);
		if(status != kAudioServicesNoError) goto bail;
		
		data.buffer = NULL;
		
		sampleRate = 48000;
		inFormat.sampleDepth = 32;
		inFormat.channels = 2;
		inFormat.isPacked = YES;
		inFormat.isInterleaved = YES;
		outFormat.sampleDepth = 32;
		outFormat.channels = 2;
		outFormat.isPacked = YES;
		outFormat.isInterleaved = YES;
	}
	return self;

bail:
	AudioUnitUninitialize(unit);
	AudioComponentInstanceDispose(unit);
	[self release];
	
	return nil;
}
- (void)dealloc
{
	AudioUnitUninitialize(unit);
	AudioComponentInstanceDispose(unit);
	free(data.buffer);
	[super dealloc];
}

- (void)startCaptureAudio
{
	[self configure];
	AudioOutputUnitStart(unit);
}
- (void)stopCaptureAudio
{
	AudioOutputUnitStop(unit);
}

- (void)configure
{
	AURenderCallbackStruct callbackInfo;
	AudioStreamBasicDescription	description;
	AudioUnitElement element = 0;
	OSStatus status;
	NSUInteger sizeOfBuffer;
	unsigned char *buffer;
	
	//	Detect Rendering Data Stream
	data.output = MakeDataSteam(outFormat);
	data.output.buckets = &outBuckets;
	data.outSampleDepth = outFormat.sampleDepth;
	data.input = MakeDataSteam(inFormat);
	data.input.buckets = &inBuckets;
	data.inSampleDepth = inFormat.sampleDepth;
	
	//	Create Buffer:
	free(data.buffer);
	data.isRound = NO;
	data.count = 0;
	data.tail = 0;
	sizeOfBuffer = sampleRate * data.input.sizeOfFrame;
	buffer = malloc(sizeOfBuffer); // 1 sec buffer size
	if(!buffer)
	{
		data.capacity = 0;
		data.buffer = NULL;
		return;
	}
	memset(buffer, 0, sizeOfBuffer);
	data.capacity = sampleRate;
	data.buffer = buffer;
	
	//	Descript Stream Info:
	description.mBitsPerChannel = data.output.sampleDepth;
	description.mBytesPerFrame = data.output.sizeOfFrame;
	description.mBytesPerPacket = data.output.sizeOfFrame;
	description.mChannelsPerFrame = data.output.channels;
	description.mFormatFlags = kAudioFormatFlagIsSignedInteger;
	if(outFormat.isPacked) description.mFormatFlags |= kAudioFormatFlagIsPacked;
	description.mFormatID = kAudioFormatLinearPCM;
	description.mFramesPerPacket = 1;
	description.mReserved = 0;
	description.mSampleRate = sampleRate;
	
	status = AudioUnitSetProperty(unit, kAudioUnitProperty_StreamFormat, kAudioUnitScope_Input, element, &description, sizeof(AudioStreamBasicDescription));
	if(status != kAudioServicesNoError) return;
	
	//	Descript Callback Info:
	callbackInfo.inputProcRefCon = &data;
	callbackInfo.inputProc = CaptureAudioRenderingCallback;
	
	status = AudioUnitSetProperty(unit, kAudioUnitProperty_SetRenderCallback, kAudioUnitScope_Input, element, &callbackInfo, sizeof(AURenderCallbackStruct));
	if(status != kAudioServicesNoError) return;
}
@end

-init では AudioUnit の初期化作業と入出力の音声データ形式の初期値を与えている。この AudioUnit は AudioComponent に関連づけられた上でフィルタやミキサーといった種々の処理を自動的に行う。 AudioComponent は内蔵かヘッドフォンかといった各デバイスに対応し、AudioComponentDescription が入力か出力かといったデバイスの種類を記述する。 今の場合は -startCaptureAudio や -stopCaptureAudio の様に AudioOutputUnitStart(), AudioOutputUnitStop() を呼び出せば AudioUnit に出力を行わせることが出来る [4]。

-configure では AudioUnit に行わせる処理を AudioStreamBasicDescription で指定する。何度も設定が変わることを前提とした実装にしている。 音声データ形式として一般的としては Packed であること、Interleaved であること(Planer ではないこと)[5]、符号付整数であることを仮定している。 AURenderCallbackStruct は音声データのレンダリングに callback を用いたい場合に使用するもので、キャプチャの様な不定のストリームを取り扱う場合には一般的な選択肢である。 ここで callback には AURenderCallback の函数ポインタとして CaptureAudioRenderingCallback を指定し、参照用のポインタとして DataContainerRef を渡している。

後は AURenderCallback として指定した CaptureAudioRenderingCallback の実装とキャプチャした音声データの受け渡し部を記述するだけである。例えば AURenderCallback は次の様に書く。

static OSStatus CaptureAudioRenderingCallback(void *inRefCon, AudioUnitRenderActionFlags *ioActionFlags, const AudioTimeStamp *inTimeStamp, UInt32 inBusNumber, UInt32 inNumberFrames, AudioBufferList *ioData)
{
	AudioBuffer *bufferRef = &ioData->mBuffers[0];
	DataContainerRef dataRef = inRefCon;
	
	unsigned int delta, location, length, lengthOfFrame;
	const DataStream inStream = dataRef->input;
	const DataStream outStream = dataRef->output;
	const unsigned char *inputData;
	unsigned char *outputData;
	
	delta = dataRef->deltaDepth;
	inputData = dataRef->buffer;
	outputData = bufferRef->mData;
	
	location = inStream.sizeOfFrame * dataRef->count;
	if(dataRef->capacity < dataRef->count + inNumberFrames)
	{
		lengthOfFrame = dataRef->capacity - dataRef->count;
		dataRef->count = inNumberFrames - lengthOfFrame;
		
		length = outStream.sizeOfFrame * lengthOfFrame;
		memcpy(outputData, inputData + location, length);
		memcpy(outputData + length, inputData, bufferRef->mDataByteSize - length);
	}
	else
	{
		dataRef->count += inNumberFrames;
		memcpy(outputData, inputData + location, bufferRef->mDataByteSize);
	}
	
	//	if you need swap channels, coding here with "dataRef->indexes".
	
	//	adjust sound gap for latency
	if(dataRef->isRound)
	{
		if(dataRef->count < dataRef->tail) dataRef->isRound = false;
	}
	else if(dataRef->tail < dataRef->count) dataRef->count = dataRef->tail;
	
	return kAudioServicesNoError;
}

AudioStreamBasicDescription の記述 [6,7] の際に説明を省いたが、AudioUnit において Frame と Packet は一般に異なる概念として取り扱われている。 Interleaved なデータ形式とそうでない Planer なデータ形式の両方に対応させるためである。AudioBufferList を Packet 配列、そのメンバの AudioBuffer を Frame 配列とみなしてよいだろう。 実際 Interleaved な場合はチャンネル数に関わらず Packet と Frame は等価な大きさでよい。Planer な場合は mFramesPerPacket などに応じて ioData->mBuffers の長さは可変であり、 その長さは ioData->mNumberBuffers で決まっている。

この処理を一般化するには inputData を inStream.buckets にコピーし、適当に減衰・合成しながら outStream.buckets に代入、outStream.buckets を outputData にコピーすれば良い。 もしもサラウンドのチャンネル配置を変えたい場合は inBuckets にコピーする際に転置すれば十分である。但し今の場合はチャンネル数も Frame の大きさも全く同一なので、出力データの順番を入れ替える事でも対応できる。 またビット深度を変えたい、サラウンドを down-mix したい、チャンネルを入れ替えたいといった実装を必要とする場合は、 DataBucket を上手に使って CaptureAudioRenderingCallback において channel 毎の操作を行うことで対応することができる。例えば一般には

inBuckets = inStream.buckets;
inNumberChannels = inStream.channels;
while(inNumberChannels--)
{
	inBuckets->SInteger = 0;
	memcpy(inBuckets->UChar4, inputData, inStream.sizeOfChannel);
	inBuckets->SInteger = (inBuckets->SInt32 < 0) ? ++(inBuckets->SInt32): inBuckets->SInt32;
	inputData += inStream.sizeOfChannel;
	inBuckets++;
}
outBuckets = outStream.buckets;
outBuckets->SInteger = (SInt64)((double)(inBuckets[0]->SInteger + inBuckets[1]->SInteger)*M_SQRT1_2);

と書けば入力の 1 ch と 2 ch を合成し -3 dB = 0.7071 減衰したモノラル音声が outBuckets に入っているので、ビット深度に合わせて outputData に入力すればよい。 また inBuckets へ代入する先を dataRef->indexes を用いて決定すればチャンネルの入れ替えにも対応することが出来る。 ここで SInt32 を SInteger へ代入した理由は負値の場合に正しく合算される様に二の補数を求め直して修正するためで、outData を作る際には逆の補正が必要な場合がある。 SInteger の表現数は SInt32 より遙かに大きいので合成後に減衰しても桁溢れは起こさない。また 24 Bit 整数は一般に定義されないためビットフィールドを用いて対応する。 共用体のバイト配列が Native Endian と一致するかどうかはコンパイラ依存であって C では規定されていなかった様に思うので、正しく動くかは各自で確認していただきたい。

さて、最後に肝腎の音声データの受け取りは次の様に CaptureObject に -addBytes:frameCount: を定義してバッファへ入力する。

- (void)addBytes:(const unsigned char *)bytes frameCount:(NSUInteger)inNumberFrames
{
	NSUInteger length, location, lengthOfFrame, sizeOfFrame;
	
	sizeOfFrame = data.input.sizeOfFrame;
	location = sizeOfFrame * data.tail;
	if(bytes)
	{
		if(data.capacity < data.tail + inNumberFrames)
		{
			lengthOfFrame = data.capacity - data.tail;
			length = sizeOfFrame * lengthOfFrame;
			memcpy(data.buffer + location, bytes, length);
			lengthOfFrame = inNumberFrames - lengthOfFrame;
			memcpy(data.buffer, bytes + length, sizeOfFrame * lengthOfFrame);
			data.tail = lengthOfFrame;
			data.isRound = true;
		}
		else
		{
			memcpy(data.buffer + location, bytes, sizeOfFrame * inNumberFrames);
			data.tail += inNumberFrames;
		}
	}
	else
	{
		if(data.capacity < data.tail + inNumberFrames)
		{
			lengthOfFrame = data.capacity - data.tail;
			length = sizeOfFrame * lengthOfFrame;
			memset(data.buffer + location, 0, length); // silent values
			lengthOfFrame = inNumberFrames - lengthOfFrame;
			memset(data.buffer, 0, sizeOfFrame * lengthOfFrame); // silent values
			data.tail = lengthOfFrame;
			data.isRound = true;
		}
		else
		{
			memset(data.buffer + location, 0, sizeOfFrame * inNumberFrames); // silent values
			data.tail += inNumberFrames;
		}
	}
}

この実装は所謂環状バッファ [8] data.buffer に逐次データ入力しているものである。環状バッファは -configure で確保していて一秒分の大きさがあり、フレームあたりのバイトサイズで位置を管理する。 data.isRound はバッファが一周したかどうかを識別するもので、起動時の入力遅延に対応するための簡易な実装である。 この実装の DeckLink API が音声データを入力し始めるまで 60 FPS の場合に 4 Frame 程の遅延が発生するが、 Core Audio が音声データを再生し始めるまで 1 Frame もかからない。従って入力遅延が発生しているため、 バッファの利用位置 data.tail を参照することで入力遅延に対応する。

CaptureObject に定義した -addBytes:frameCount: は次の様に実装することで音声データの入力が可能になる。 それは、映像データのビットマップでやった様に、CapturePreviewAppDelegate に -didArriveDataInputFrame:inputPacket: を追加した上で DeckLinkDevice::VideoInputFrameArrived() を書き換えることである。例えば -didArriveDataInputFrame:inputPacket: は次の様にする。


- (void)didArriveDataInputFrame:(IDeckLinkVideoInputFrame *)videoFrame inputPacket:(IDeckLinkAudioInputPacket *)audioPacket
{
	void *buffer;
	
	if(!audioPacket) return;
	audioPacket->GetBytes(&buffer);
	[[CaptureObject sharedObject] addBytes:buffer frameCount:audioPacket->GetSampleFrameCount()];
}

以上で音声の再生が可能になったはずである。

  1. method_exchangeImplementationsでクラスメソッドを置換する
  2. singletonの色んな実装方法を集めました。
  3. Objective-C で継承可能な Singleton Class を作る
  4. CoreAudioの使い方メモ
  5. RAW形式の画像フォーマットメモ
  6. Core Audio その2 AudioStreamBasicDescription
  7. 詳説 AudioStreamBasicDescription / AVAudioFormat
  8. 循環バッファ - アルゴリズムとデータ構造

全画面表示への対応

AppKit では全画面表示がサポートされており、現在の実装では NSView の -enterFullScreenMode:withOptions: と NSWindow の -toggleFullScreen: とが利用可能である。 NSView -enterFullScreenMode:withOptions: は 10.5 以降、NSWindow -toggleFullScreen: は 10.7 以降に導入されたもので、それぞれ Exposé、Mission Control に対応したものとなっている。 このうち -enterFullScreenMode:withOptions: は Mission Control に対応しないので、現在は -toggleFullScreen: を用いるのが現実的である。

- (void)awakeFromNib
{
	NSNotificationCenter *center = [NSNotificationCenter defaultCenter];
	
	[center addObserver:self selector:@selector(willEnterFullScreen:) name:NSWindowWillEnterFullScreenNotification object:nil];
	[center addObserver:self selector:@selector(didEnterFullScreen:) name:NSWindowDidEnterFullScreenNotification object:nil];
	[center addObserver:self selector:@selector(willExitFullScreen:) name:NSWindowWillExitFullScreenNotification object:nil];
	[center addObserver:self selector:@selector(didExitFullScreen:) name:NSWindowDidExitFullScreenNotification object:nil];
}

初めに前準備としてこの様な NSWindowDelegate の通知を -awakeFromNib で登録しておく。Window Controller を用意して MainMenu.xib へ Object を追加しても良いし、 NSWindow Class を継承した MyWindow Class を定義し、MainMenu.xib の Main Window を MyWindow と変更して実装しても良い。 ここでは MyWindow を用意したという前提で code を示す事にする。 そこで MyWindow の Received Action である -toggleFullScreen: へは、Main Menu の適当なところに Full Screen と言った NSMenuItem を作成し対応させる事にする。 この Key Equivalent は ⌃⌘F に設定しておくと直感的である。尚この方法では再度 -toggleFullScreen: を呼ぶか或いは ESC Key を入力する事で全画面表示を解除することができる。 そうすると最も単純な実装は次の様になるであろう。

- (void)willEnterFullScreen:(id)sender
{
	[previewView removeFromSuperview];
	[fullScreenView addSubview:previewView];
}
- (void)didEnterFullScreen:(id)sender
{
	[self enableMonitorAssertion];
	
	// Start Mouse Control
	trackingRectTag = [fullScreenView addTrackingRect:[fullScreenView bounds] owner:self userData:NULL assumeInside:NO];
	[self setAcceptsMouseMovedEvents:YES];
	[hiddenTimer invalidate];
	hiddenTimer = [NSTimer scheduledTimerWithTimeInterval:3.0 target:self selector:@selector(hideCursor:) userInfo:nil repeats:NO];
	
	// updateTimer = [NSTimer scheduledTimerWithTimeInterval:1.0/60 target:self selector:@selector(onIntervalTimerInUpdatePreview:) userInfo:nil repeats:YES];
}
- (void)willExitFullScreen:(id)sender
{
	// [updateTimer invalidate];
	// updateTimer = nil;
	
	[self disableAssertions];
	
	// Stop Mouse Control
	[self showCursor];
	[hiddenTimer invalidate];
	hiddenTimer = nil;
	[fullScreenView removeTrackingRect:trackingRectTag];
}
- (void)didExitFullScreen:(id)sender
{
	[self enebleSystemAssertion];
	
	[previewView removeFromSuperview];
	[screenView addSubview:previewView];
	[screenView setFrame:[screenView frame]];
}

- (void)showCursor
{
	[NSCursor unhide];
}
- (void)hideCursor:(NSTimer*)theTimer
{
	[NSCursor hide];
	hiddenTimer = nil;
}

- (void)mouseMoved:(NSEvent *)theEvent
{
	[self showCursor];
	[hiddenTimer invalidate];
	hiddenTimer = [NSTimer scheduledTimerWithTimeInterval:3.0 target:self selector:@selector(hideCursor:) userInfo:nil repeats:NO];
}

- (void)onIntervalTimerInUpdatePreview:(NSTimer *)theTimer
{
	[previewView setNeedsDisplay:YES];
}

レターボックスをつける為に CapturePreviewAppDelegate が持っている previewView の配置を変え、previewView のあった位置に screenView を置き、 screenView の下に previewView を置き、fullScreenView は Main Window 全体に貼り付けたものとなっている。好みの実装で MainMenu.xib を書き換えるか awakeFromNib でコードを打ち込んでしまえば良い。 また screenView と fullScreenView との間で previewView の配置を入れ替えてやる事で全画面表示時に操作ボタンなどが表示されない様にしている。 レターボックスがある場合は適当なタイミングで -setHidden: を送らないと表示が乱れる可能性があるので注意する。

-didEnterFullScreen:, -willExitFullScreen: では画面のスリープ妨害(後述)と全画面表示時のマウスの除去を行っている。例では三秒後にマウスが hiddenTimer によって隠される仕様になっている。 念のために dealloc で hiddenTimer を停止させた方が良い。nil を代入する様にしているので -invalidate を呼んでおけば十分である。 もう一つタイマーが存在しているがこの updateTimer は全画面表示している際に Mission Control を行った際にサムネイルが更新されない問題に対応させたものである。 60 FPS を設定しているが負荷が大きいので無用なら取り除くとよい。(誤謬を犯していたので削除、理由は後述の註を参照) trackingRectTag は全画面表示時にマウスを消した後にマウスに動きがあれば再表示するためのトラッキングを行っているが、その管理タグである。これも負荷が小さくないので必要でなくなればきちんと取り除く。

(註)後日別件で調査していた結果、全画面表示した際に Mission Control でサムネイルが更新されない理由は、CapturePreview.app のビューは Core Animation で導入された CALayer による Backing Layer 方式の描画を用いていない為であると判明した。 Mission Control でサムネイルを更新したい場合は -setWantsLayer: を YES に設定する必要性がある。具体的には previewView が与えられた際に次のようなコードを -awakeFromNib あたりに挿入すると解決する。

NSView *superview = previewView;
while(superview)
{
	[superview setWantsLayer:YES];
	superview = [superview superview];
}

描画を行ったビューを保持しているビューにレイヤを挿入しておけば反映される様なので、ルートのビューまで全てレイヤを与えておく。 (似た問題が Metal Kit の MTKView に対して -setColorspace: を指定した場合に存在し、MTKView の親にレイヤが存在しないと全画面表示すると色が変化することがある)

- (IBAction)toggleFullScreen:(id)sender
{
	BOOL isEnteredFullScreen = [screenView isInFullScreenMode];
	NSArray *objectArray = [NSArray arrayWithObjects:[NSNumber numberWithBool:!isEnteredFullScreen],nil];
	NSArray *keyArray    = [NSArray arrayWithObjects:NSFullScreenModeAllScreens,nil];
	NSDictionary *fullScreenDict = [NSDictionary dictionaryWithObjects:objectArray forKeys:keyArray];
	
	if(isEnteredFullScreen)
	{
		[self willExitFullScreen:nil];
		[screenView exitFullScreenModeWithOptions:fullScreenDict];
		[self didExitFullScreen:nil];
	}
	else
	{
		[self willEnterFullScreen:nil];
		isEnteredFullScreen = [screenView enterFullScreenMode:[NSScreen mainScreen] withOptions:fullScreenDict];
		if(isEnteredFullScreen) [self didEnterFullScreen:nil];
	}
}

10.5, 10.6 では -toggleFullScreen: を上記の様に追加しておく事で全画面表示対応することができる。-willExitFullScreen:, -didExitFullScreen:, -willEnterFullScreen:, -didEnterFullScreen: は NSWindowDelagate Protocol の各通知に対応したものであるが、10.7 以降にしか存在しない通知なので手動で呼び出す事にする。この様に実装しておけば 10.7 以降の -toggleFullScreen: の code をそのまま利用することができるはずである。 ただし screenView は全画面表示させたい NSView のインスタンスであり、予め IBOutlet を定義し MainMenu.xib で関連づけておいたものとする。 このままでは ESC Key を入力する事で全画面表示を解除することはできないので、NSResponder の -keyUp: を上書きする。

- (void)keyUp:(NSEvent *)theEvent
{
	if ([theEvent keyCode] == 53 && [screenView isInFullScreenMode]) [self toggleFullScreen:nil];
	else [super keyUp:theEvent];
}

-keyUp: は -acceptsFirstResponder が YES を返せば受け取ることができる。-didEnterFullScreen: などで MyWindow の -makeFirstResponder を呼ぶなどすれば良いであろう。 KEY 入力時にエラー音がする場合は Key Window を適切に指定しておく必要性がある。53 はここでは ESC Key を指しているが ASCII ではない。 これは HIToolbox Framework の Events.h で kVK_Escape として定義されている物である。VK は仮想キーボードか仮想キーコードの略かと思われる [1,2]。 ASCII にない Mac 特有の ⌘ 入力などに対応させるための方法であると考えられる。#include <Carbon/Carbon.h> とすれば実際に kVK_Escape の値を確認することができる。

  1. Where can I find a list of Mac virtual key codes?
  2. Map NSEvent keyCode to virtual key code
  3. Layer-backed OpenGLView redraws only if window is resized

安眠妨害:全画面表示時のスリープ対応

全画面表示した際にスリープしてしまうと何の為の全画面表示かわからなくなるものである。そこで安眠妨害の方策を立てる事にする。 昔は Core Services Framework の UpdateSystemActivity(OverallAct) が有効であった [1] が 10.9 以降は deprecated である。 Core Graphics Framework の CGEvent を発生させて妨害する方法も存在するが、正攻法では無い上に Key Window が見つからない場合にエラー音が鳴るといった諸問題を抱えているのでお勧め出来ない [2]。 最近はこれに変わって IOKit Framework の IOPMAssertion を発行する事で対応する [3, 4]。

IOPMAssertionID assertionID;
IOPMAssertionDeclareUserActivity(CFSTR("My Assertion Comment"), kIOPMUserActiveLocal, &assertionID);

stackoverflow で紹介されているこの方法は 10.9 でビルドした場合に有効であったが、10.13 でビルドすると無効になっていた。 IOKit を正しく使う為には不十分な実装な様である。より汎用的な実装を考えるならば、実は VLC の source code が参考になる [5]。 VLC 3.0.x 系の source code を確認すると、VLCInputManager.m の -inhibitSleep にディスプレイとシステムのスリープを妨害するコードが試行錯誤されていて、コメントがついている。 以上を参考にすると、全画面表示で行うという観点から次の様な code を Window Controller か NSWindow にでも追加しておき、 NSWindowWillEnterFullScreenNotification や NSWindowWillExitFullScreenNotification の通知を受け取った際に on/off すれば良い。 systemAssertionID, monitorAssertionID は global static 変数かインスタンス変数とでもすれば良い。 また -dealloc で -disableAssertions を呼ぶ事を忘れない様にする。

#include <IOKit/pwr_mgt/IOPMLib.h>
/***** omission *****/
- (void)disableAssertions
{
	if(systemAssertionID != kIOPMNullAssertionID)
	{
		IOPMAssertionRelease(systemAssertionID);
		systemAssertionID = kIOPMNullAssertionID;
	}
	if(monitorAssertionID != kIOPMNullAssertionID)
	{
		IOPMAssertionRelease(monitorAssertionID);
		monitorAssertionID = kIOPMNullAssertionID;
	}
}
- (BOOL)enableMonitorAssertion
{
	IOPMAssertionID activityAssertionID;
	if(IOPMAssertionDeclareUserActivity(CFSTR("Capture Preview Assersion"), kIOPMUserActiveLocal, &activityAssertionID) != kIOReturnSuccess) return NO;
	return IOPMAssertionCreateWithName(kIOPMAssertionTypeNoDisplaySleep, kIOPMAssertionLevelOn, CFSTR("Capture Preview Assersion"), &monitorAssertionID) == kIOReturnSuccess;
}
- (BOOL)enebleSystemAssertion
{
	return IOPMAssertionCreateWithName(kIOPMAssertionTypeNoIdleSleep, kIOPMAssertionLevelOn, CFSTR("Capture Preview Assersion"), &systemAssertionID) == kIOReturnSuccess;
}

macOS Catalina から IOKit から DriverKit へ移行しろと言われているが、対応策は現在調査中である。

  1. システムをスリープさせないようにする
  2. Xamarin.Mac/キーボードの入力をシミュレートする
  3. Macアプリを開いている間はスリープさせない2行。
  4. Programmatically wake display on OSX
  5. vlc/modules/gui/macosx at master · videolan/vlc · GitHub

レターボックスの導入

レターボックスとは、画面サイズのアスペクト比と表示する映像サイズのアスペクト比が異なる場合、映像のアスペクト比を保てる様に映像を拡大縮小し残る領域は黒色で被覆して描写する方式の事である。 この様な実装を考えるならば、全画面表示時に導入した screenView や fullScreenView は MyView Class などと定義して実装した方がやりやすい。

@interface MyView : NSView
{
	NSSize inputSize;
	
	IBOutlet NSView* previewView;
}
@end

@implementation MyView
- (void)awakeFromNib
{
	inputSize = NSMakeSize(480, 360);
	[super awakeFromNib];
}

- (void)drawRect:(NSRect)dirtyRect
{
	[super drawRect:dirtyRect];
	[[NSColor blackColor] set];
	NSRectFill(dirtyRect);
}

- (void)setFrame:(NSRect)frame
{
	CGFloat inputRatio, outRatio, r;
	NSSize outSize = frame.size;
	NSRect clippingFrame;
	
	[super setFrame:frame];
	if(![[self subviews] count]) return;
	
	//	Keep Aspect Ratio
	inputRatio = inputSize.height / inputSize.width;
	outRatio = outSize.height / outSize.width;
	r = inputRatio - outRatio;
	if(ABS(r) < DBL_EPSILON) clippingFrame = NSMakeRect(0, 0, outSize.width, outSize.height);
	else
	{
		if(inputRatio > outRatio) // EX. 16:9 > 4:3 = 12:9
		{
			r = outSize.width*outRatio/inputRatio;
			clippingFrame = NSMakeRect((outSize.width-r)/2, 0, r, outSize.height);
		}
		else // EX. 12:9 = 4:3 < 16:9
		{
			r = outSize.height*inputRatio/outRatio;
			clippingFrame = NSMakeRect(0, (outSize.height-r)/2, outSize.width, r);
		}
	}
	[previewView setFrame:clippingFrame];
	[previewView displayIfNeeded];
}

- (void)detectedFrameSize:(NSSize)size
{
	inputSize = size;
}
@end

-drawRect: はこの MyView が黒色で描画される様に指定しているものであり、上に貼り付けた previewView によってレターボックスが完成する。 -setFrame: が呼び出された際に自身の subview になっているはずの previewView のサイズをアスペクト比を考慮して計算し指定する。 DeckLinkDevice::VideoInputFormatChanged() が呼ばれた際に IDeckLinkDisplayMode *newDisplayMode が与えられているから、 IDeckLinkDisplayMode::GetWidth() などを用いて映像のサイズを -detectedFrameSize: に与える様にすれば良い。

Window を Dock にしまう(最小化)際の挙動

AppKit ではアプリケーションアイコンや Dock に Window をしまう最小化を行った際のアイコンを指定する方法が与えられている。 後者の場合は対象の Window で -dockTile を呼んで NSDockTile Class のインスタンスを取得し、-setContentView: などにカスタムビューを渡すなどしてカスタマイズすることができる。 NSWindowDelegate で用意された NSWindowWillMiniaturizeNotification, NSWindowDidMiniaturizeNotification, NSWindowDidDeminiaturizeNotification を使えば最小化を検知する事ができる。 以下に示すものは最小化した際に表示されるアイコンを現在 previewView に表示されているキャプチャ画像にしたいと考えた場合に行う実装である。

NSDockTile には幾つかの実装方法が考えられる。例えば全画面表示時の様に fullScreenView へ previewView を貼り付けて NSTimer で -setNeedsDisplay: に YES を渡す方法である。 ところがこの様な方法であると Window 全体を計算して表示内容を決める為か負荷が大きく FPS を上げることができない。 次に contentView をカスタムする方法が考えられるが、-setContentView: に fullScreenView を与えるなどすると今度は VideoGLView が描写されなくなる問題が生じる。 誠に不思議なことではあるが、例えばマイクロ秒を測定して背景色を変化させる適当なカスタムビューを -setContentView: に与えると意図した通りに画像が変化することから、 これは NSOpenGLView といった OpenGL で直接描画するビューを NSDockTile の contentView に指定した場合に特有の問題であろうと推察される。

@implementation MyView
- (void)drawRect:(NSRect)dirtyRect
{
	[super drawRect:dirtyRect];
	
	[[NSColor colorWithGenericGamma22White:((NSUInteger)([[NSDate date] timeIntervalSince1970]*1000) % 1000)/1000.0 alpha:1.0] set];
	
	NSRectFill(fillRect);
}
@end

そこで contentView には NSImageView を渡す事とし、NSImageView に渡す NSImage は映像データをビットマップ化する場合のサンプルコードで示した方法で作成する。 この場合はビットマップデータを作る演算負荷がかかるものの、60 FPS でも耐えるだけの計算速度を得ることができる。 NSDockTile に対しては定期的に NSTimer で呼び出して -display を送る事で NSImageView の更新が行われる。 これらの NSTimer の設定は NSWindowWillMiniaturizeNotification などで通知を受けて執り行なえば良い。

Nintendo Switch (Wii U) の接続(Intensity Pro 4K over Thunderbolt)

ここではより実践的な情報を纏めておく。

DeckLinkDevice::VideoInputFrameArrived() の修正

これまで書いてきたことで説明していなかった点が一つある。それは音声データをキャプチャしている場合に DeckLinkDevice::VideoInputFrameArrived() において、 videoFrame と audioPacket がどちらかが NULL になっている場合があり、そのことによって既存のコードで Null 参照が発生しうるという点である。 例えば getAncillaryDataFromFrame() は videoFrame が NULL であることを考慮しているが、getHDRMetadataFromFrame はそうではない。 従って videoFrame が NULL になることを考慮した上で DeckLinkDevice::VideoInputFrameArrived() に修正を施しておく必要性がある。

映像データの量子数

Nintendo Switch は full-range RGB に対応している。PlayStation では PS3 の頃からビット深度 48 Bit の RGBまで対応しているそう [1] であるが、 Nintendo Switch でいう full-range RGB は 0-255 のビット深度 24 Bit の RGB である。 Intensity Pro 4K はビット深度 24 Bit から、CapturePreview.app は r210 の ビット深度 30 Bit によるキャプチャを行なっているが、 一部のネット上に書かれている [2] 様な一成分あたり 12 Bit 以上のキャプチャを行わなくとも Nintendo Switch の場合は色情報が失われることはないので安心していただきたい。 (そもそも 2^8=256 なので 8 Bit RGB で十分と分かりそうなものだが誠に不思議な話である)

  1. <mini> Intensity Pro 4K で HDMI の RGB range “Full” で取り込む場合
  2. 色空間やら色情報の話

映像データの色空間

ビットマップ化する際に色空間に DCI-P3 を指定していた。これは実は正確な色空間とは異なっている様で、VideoGLView が描画する色合いとは僅かに異なったものが出力される。 他に近い色合いになる色空間は Adobe RGB 色空間を指す kCGColorSpaceAdobeRGB1998 である。一般的には sRGB 色空間を指す kCGColorSpaceSRGB か CGColorSpaceCreateDeviceRGB() が返す CGColorSpaceRef を採用しておけば問題がない様に思われるが、今の場合はそうはいかない様である。

一方、DeckLink API では BMDColorspace として DeckLinkAPI.h には次の様に定義されている。

typedef uint32_t BMDColorspace;
enum _BMDColorspace {
    bmdColorspaceRec601                                          = 'r601',
    bmdColorspaceRec709                                          = 'r709',
    bmdColorspaceRec2020                                         = '2020'
};

これは ITU-R BT.601, BT.709, BT.2020 として知られる放送規格用の色空間である [1-2]。 Core Graphics には kCGColorSpaceITUR_709, kCGColorSpaceITUR_2020 として BT.709, BT.2020 の色空間が用意されているが、いずれも色合いが近しいとは言い難い結果となった。 BT.601 は sRGB 色空間に近いのでその様に取り扱うこともできたが sRGB 色空間では色合いが異なるものとなっていた。 HDMI 2.0 の頃から BT.2020 に対応している [3] そうであるが、どの様な色空間でキャプチャされているのかはいまいち明瞭でない。

IDeckLinkAPIInformation::GetInt() に bmdDeckLinkFrameMetadataColorspace を指定すると BMDColorspace が取得できる様であるが、現在調査中である。

  1. 色空間とか色域とかカラースペースとか、そういうお話
  2. 新規格「HDR10+」も発表!画質向上「HDR」を基礎から徹底解説!
  3. ”HDMI 2.1仕様について”、映像情報メディア学会誌、Vol.72、No.6、pp.913-916 (2018)
図:色空間を DCI-P3 にした場合 図:色空間を Adobe RGB にした場合 図:色空間を sRGB にした場合

CapturePreview.app では背景色が 0x222222 なのに対してそれぞれ DCI-P3 が 0x1B1B1B、Adobe RGB が 0x292929、sRGB が 0x2D2D2D になっている。 また FAMILY COMPUTER のロゴの背景色は 0xFF0000 なのに対してそれぞれ DCI-P3 が 0xF30000、Adobe RGB が 0xFF010E、sRGB が 0xE70113 になっている(註)。

註:後でわかったことであるが、色が正確ではない問題は OpenGLView に起因するものらしいとわかった。Metal Kit の MTKView を用いて実装し直して sRGB を指定すると正しい色味が得られる。 OpenGLView を使う限りは DCI-P3 がまだ近い色味になるが、改善する方法は今の所見つけられていない。Apple は OpenGL を "deprecated" としているので、拘りがないなら MTKView を使えば良い。

Core Graphics とビットマップの形式

映像データをビットマップ化した際に Core Graphics にデータを受け渡しているが、Core Graphics が受け付けるビットマップの形式には限りがある。 Core Graphics の受け付ける RGB 形式は 16 Bit の High Colour、32 Bit の True Colour、64 Bit, 128 Bit の Deep Colour がある。 例えば 32768 色を表現する High Colour は XRGB、1 Bit を X として無視した上で 5 Bit ずつ RGB に割り当てる方式である。 所謂約1678万色を表現する True Colour は 32 Bit 単位でしか受け付けず、24 Bit のデータとして取り扱うことはできない [1]。

	while(height--)
	{
		width = size.width;
		while(width--)
		{
			mapData[1] = rawData[1] & 0x0F;
			mapData[0] = (mapData[1] >> 2) | ((rawData[0] & 0x3E) << 1);
			mapData[1] = (rawData[3] >> 5) | ((rawData[2] & 0x03) << 3) | ((rawData[2] & 0x80) >> 7) | (mapData[1] << 7);
			
			mapData += 2;
			rawData += 4;
		}
	}

上記は映像データをビットマップ化する際に示したコードの一部を書き換えたもので r210 RGB を Core Graphics が受け付ける 16 Bit XRGB に変換するコード例である。 注意したいのはそのビット配列である。各成分のビットの上下と各バイトのビットの上下が一致する様に選ぶ。先ず一バイト目は上位ビットから X, R, G と並び X は 1 Bit、R は 5 Bit、G は 2 Bit 割り当てる。 二バイト目は上位ビットから G, B と並び G は 3 Bit、B は 5 Bit 割り当てる。

  1. Quartz 2D Programming Guide - Graphics Contexts - Creating a Bitmap Graphics Context - Supported Pixel Formats

サラウンドのチャンネルとスピーカの対応

一般的な 5.1 ch サラウンドのスピーカは 1 ch から 6 ch へ順に L, R, C, LFE, Ls, Rs と割り当てる。C はセンター、LFE はサブウーファ、Ls, Rs は左右サラウンドである (SMPTE/ITU [1])。 Nintendo Switch ではサラウンド音声のテストを行うことができ、各スピーカに対応した音が六回流れる。鳴る順番はそれぞれ L, R, Ls, Rs, C, LFE である。 Wii U では Switch とほぼ同じであるが、流れる音は五回であり、五回目に C と LFE の音が同時に流れる様になっている。 対して Intensity Pro 4K でキャプチャを行うと、Nintendo Switch の C と LFE とに逆のチャンネルを割り当ててしまっていることがわかる。 macOS では Audio MIDI 設定で割り当てるチャンネルを指定することもできるが、スピーカやアンプが対応していない場合は CapturePreview 側で明示的にチャンネルを入れ替える必要性があるので注意したい。 (例えば Splatoon ではシオカラーズやジャッジくんの声は C から出力されるが LFE に割り当ててしまうと話しかけても何も聞こえない事態が発生する)

なお余談であるが、Mac でサラウンド環境を整えるのはいささか面倒がある。一部の Mac Pro、MacBook Pro や Mac mini ではイヤホンジャックから光オーディオ出力が可能であり、 光オーディオ端子も 5.1 ch に対応している [2] とのことだが、macOS 側の制約でステレオ音声の出力に制限されている場合があるという。 USB-DDC としては Creative の Sound Blaster X-Fi Surround 5.1 Pro や Sound Blaster Omni Surround 5.1 が有名である [3] が、 X-Fi の方は macOS を公式にサポートしておらず 5.1 ch にしてもステレオ出力になるという話や、Omni の方は macOS をサポートしているものの光出力の仕様が不明瞭という問題がある [4-6]。 最も単純な解決策は DisplayPort か USB アダプタを用いて HDMI から音声出力を得る方法である [7]。ただし一部の Mac は DisplayPort からの音声出力に非対応とのことである [8]。 光オーディオは jitter の問題があるといった話もあって銅線に対して有利とは限らないので、そこまで拘る必要性はないと思われる [9-10]。 (困った場合には [11] の様な 2 ch 出力の DDC を三つ繫ぎ「複数出力装置」を構成する方法もあるが、三つのデジタルオーディオ入力を処理するアンプは存在するか疑問である)

  1. Using surround format templates
  2. Mac miniやMacBook Proのオーディオ出力は光デジタル出力(S/PDIF)に対応し、5.1チャンネルオーディオ出力に対応。
  3. Creative Sound Blaster Omni Surround 5.1 vs. Creative Sound Blaster X-Fi Surround 5.1 Pro
  4. Creative X-Fi Surround 5.1 on iMac 10.7
  5. Creative Labs Sound Blaster X-Fi Surround 5.1 PRO Sound Card
  6. How to get optical 5.1 surround audio output Retina MacBook Pro from Logic Pro X
  7. How to enable 5.1 surround sound in VLC on OSX over HDMI
  8. Send audio and video to HDMI displays through Mini DisplayPort
  9. 誤解していませんか!? クロックジッターの「真実」を解説
  10. 光インターフェースの改善
  11. FX-AUDIO- FX-D03J USBバスパワー駆動DDC USB接続でOPTICAL・COAXIALデジタル出力を増設

HDMI EDID 問題

Nintendo Switch を Intensity Pro 4K に接続する場合、EDID Handshake 問題が生じる場合がある [1,2]。本来 1080p にしたいところが 480p になってしまう問題である。 Nintendo は伝統的に互換性を重視することが多く、ファミコンにおいて RF 出力だった様に、Wii まで HDMI などのデジタル端子をサポートしてなかった様に、保守的な思想を採用していて、 Switch もその例に洩れず、正体不明のモニタに対しては 480p の映像を出力する。EDID が認識できる場合はその限りではない様である。 C-Force 009 [3] などの非公式な Dock を用いる場合はこの問題を回避できる場合もあるが、一般には EDID セレクタを別途導入する必要性がある [4]。

DeckLink API には IDeckLinkHDMIInputEDID Class が用意されているので、適宜この API を利用する事で解決可能かもしれない。DeckLinkDevice::init() では次の様に初期化されている。

	// Enable all EDID functionality if possible
	if (deckLink->QueryInterface(IID_IDeckLinkHDMIInputEDID, (void**)&deckLinkHDMIInputEDID) == S_OK && deckLinkHDMIInputEDID)
	{
		int64_t allKnownRanges = bmdDynamicRangeSDR | bmdDynamicRangeHDRStaticPQ | bmdDynamicRangeHDRStaticHLG;
		deckLinkHDMIInputEDID->SetInt(bmdDeckLinkHDMIInputEDIDDynamicRange, allKnownRanges);
		deckLinkHDMIInputEDID->WriteToEDID();
	}

IDeckLinkHDMIInputEDID::SetInt() に指定する内容を変えてやれば適切な EDID を通知してやることができるであろう。 CapturePreview.app に EDID を切り替えるための要素を付け加えてやれば良いと思われる。DeckLinkAPI.h には次の様に定義されている。

/* Enum BMDDynamicRange - SDR or HDR */

typedef uint32_t BMDDynamicRange;
enum _BMDDynamicRange {
    bmdDynamicRangeSDR                                           = 0,
    bmdDynamicRangeHDRStaticPQ                                   = 1 << 29,	// SMPTE ST 2084
    bmdDynamicRangeHDRStaticHLG                                  = 1 << 30	// ITU-R BT.2100-0
};

/* Enum BMDDeckLinkHDMIInputEDIDID - DeckLink HDMI Input EDID ID */

typedef uint32_t BMDDeckLinkHDMIInputEDIDID;
enum _BMDDeckLinkHDMIInputEDIDID {
    bmdDeckLinkHDMIInputEDIDDynamicRange                         = 'HIDy'	// Parameter is of type BMDDynamicRange. Default is (bmdDynamicRangeSDR|bmdDynamicRangeHDRStaticPQ)
};
  1. Blackmagic Intensity Pro 4K Nintendo Switch
  2. EDID(Extended Display Identification Data) – HDMI-NAVI.com
  3. CF009 ┃🎮Switch - C-FORCE
  4. BlackMagicDesign社のIntensity Shuttle for Thunderbolt(Mac)のHDMI出力についての解説。

配信とその映像の確認

私自身は特に興味がないのだが、配信しながら映像も確認する需要が一定する存在するであろうことは認識している。ゲーム機から出力された信号を TV やキャプチャボードなどで受像している時点で既に遅延が発生しており、 Wii U GamePad において無線通信の最適化を行った結果、TV より GamePad の方が早く映像が表示される場合があるといったことは有名な話である [1](そういう意味で実はこの GamePad がある程度の操作環境の遅延レベルの指標に使える)。 HDMI Splitter を導入するなどの複雑な装置の配置や設定を行えば行うほど遅延は長くなることはあっても短くなることはない。配信を行う場合は、プレイヤーにとって遅延は重大な問題であるが、 視聴者にとってリアルタイム視聴の遅延が問題になるかといえば殆ど問題にならないと思われるので、簡便な実装で配信できる方が利便性は高いであろう。

単純に思いつく簡単な配信方法は収録してしまうことであるが、現実には 1920p/60FPS の映像は r210 の場合に 4 bytes/pixel なので映像だけで 474.61 MB/sec.、 Splatoon のナワバリバトル一回ですら 83.43 GB に達するため、録画し続けると容量不足に陥る上に、4 Gbps 以上の接続方式で認識されている RAID を組んだ HDD か NVMe の SSD を用意しないと録画すら覚束ない。 音声が 32 Bit/48 kHz/5.1 ch の linear PCM の場合に 1.099 MB/sec. しかないのと比べるとデータ量は極めて大きく、録画は現実的な方法ではない。

解決策は CapturePreview.app を使って Streaming 配信してしまうことである。一秒分程度のバッファを確保し充塡することは昨今のコンピュータ事情では比較的容易である。 データ容量の多い映像処理をいかに効率よく捌くかは例えば産業用の GigE Vision 規格のラインスキャンカメラは 128 MB/sec. のスループットがあるのでその技術が役に立つかもしれない。 また 4 Gbps 以上を流せる Switching Hub や 10Gbase-T, 10GBase-SR といった接続方式はまだ一般的ではないので遅延なく圧縮するための技術も必要である。 ffmpeg は非圧縮データを圧縮することが可能であったように思うので、TS Stream 化してしまえば、1920i/MPEG-2 TS で送信されている TV の場合に 20 Mbps 程度なので、現実的なデータ量となる。 またこの TS Stream を別のエンコードサーバなどで受信して H.264 圧縮してから配信すれば 5 Mbps 程度のストリームで済むはずである。

以上の考察から、HDMI Splitter を導入する場合は Mirakurun のような使い勝手の launch daemon を起動させてしまうの事も現実的な落とし所であろうが、 導入しない場合に CapturePreview.app へ付け加えると便利で需要がありそうな機能は MPEG-2 TS を垂れ流す機能であると思われる。 プロセス間通信で通信できるデータの帯域には限りがあり、CapturePreview.app に Socket 通信機能を付け加える必要性があるためである。

尚 DeckLink API には H.264 のエンコーダに関するものが存在しているが、これは Hardware エンコーダを制御するもので、Intensity Pro 4K などは仕様にない為用いることはできないと考えられる。

  1. Wii U GamePad篇 - 社長が訊く『Wii U』

VideoToolbox Encoder

DeckLink API の用いる OpenGL に基づく View は最適化されておらず、かなりの CPU リソースを消費する。これは QuickTime や VLC が H.264 Codec の mp4 を再生する場合に比べると十倍近い開きがある。 macOS には VideoToolbox Framework と呼ばれる AudioToolbox に対応した低レベルな API が用意されており、次の様にして Encoder のリストを確認できる。

#import <VideoToolbox/VTVideoEncoderList.h>

CFArrayRef encoder_list;
NSArray *array;
VTCopyVideoEncoderList(NULL, &encoder_list);
array = (__bridge NSArray*)encoder_list;
for(NSDictionary *dict in array) NSLog(@"%@", dict);
CFRelease(encoder_list);

例えば Encoder として 24-Bit RGB、32 Bit RGB、Apple ProRes 422、Apple ProRes 422 HQ、Apple ProRes 422 LT、Apple ProRes 4444、H.263、H.264、HEVC (H.265) の存在が確認できた。 実際に使用する際は Core Media Framework、AVFoundation Framework などを適宜利用する。

技術考󠄁 > CapturePreview.app (Blackmagic DeckLink SDK) の仕様と改修:Nintendo Switch を念頭に
Copyright© R01[2019], H31[2019]. All Rights Reserved.