OUCC

BLOG

記事一覧 タグ一覧

PowerPointの音声ファイルをWhisperで文字起こししてみた

投稿日:

これは、OUCCアドベントカレンダーの22日目の記事です。Qiitaにもマルチ投稿しています。

なんで文字起こしするのか

自分は大学生なのですが、時々授業が動画ではなく音声付Power Pointになることがあります。この時に何が困るかっていうと倍速再生ができないのでまともに授業を受けるととても時間がかかるのです。
というわけで、文字起こしして音声聞かずに済ませることで時間短縮を狙ったのが今回の動機です。

どうやってやるのか

今回はC#のライブラリであるOpen-XML-SDKDocumentFormat.OpenXmlライブラリを用いてPower Pointから音声データとタイミングを取得して、文字起こしで有名なwhisperを使って文字起こしをしてtxtファイルにまとめてみました。
本来はPower Pointのノート部分に文字起こしした分を書き込もうかと思ったのですが断念しました。

Power Pointから音声データと音声の再生順を取ってくる

まず初めに、知っておいた方が分かりやすいと思うのでPower Pointがどのような構造になっているかを書きたいと思います。また、自分もすべてを理解したわけではないので間違いがあるかもしれません。

Power Pointの構造

現在のPower PointはOpen XMLという規格に標準化されていて、これさえ理解することができればPower PointはともかくWordやExcelも操作することができます。(なんならWord、Excelを操作する方がメイン説はありますが…)
Power Pointの中身はZipファイルで、ここに音声ファイルやXMLで記述されたスライドなどが格納されています。Zipファイルの中のディレクトリはこんな感じになってます。

(Power PointのZipファイル)
 ├─docProps
 ├─ppt
 │  ├─media
 │  ├─notesMasters
 │  │  └─_rels
 │  ├─notesSlides
 │  │  └─_rels
 │  ├─slideLayouts
 │  │  └─_rels
 │  ├─slideMasters
 │  │  └─_rels
 │  ├─slides
 │  │  └─_rels
 │  ├─theme
 │  └─_rels
 └─_rels

まずZipファイル直下には[Content_Types].xmlというファイルがありここにすべてのxmlファイルのありかが書かれています。次にdocPropsディレクトリにはパワポのサムネや詳細情報が書かれた.xmlファイルが存在します。そしてpptディレクトリにはスライドに関するファイル全般が格納されています。あと_relsディレクトリには<RelationShip>(後述)が記されたファイルが格納されています。

ここからは肝心のpptディレクトリ(と一部ファイル)についてもう少し詳しく見ていきましょう。

  • mediaディレクトリ:プレゼンテーション内の画像、音声、動画が ファイル名を変更されて 格納されています。
  • motesMastersディレクトリ:プレゼンテーションのノート部分の書式設定などが書かれた.xmlファイルが格納されています。
  • noteSlidesディレクトリ:プレゼンテーションのノート本体の.xmlファイルが格納されています。
    • _relsディレクトリ:各ノートそれぞれにつき<RelationShips>が書かれたファイルが格納されています。
  • slideLayoutsディレクトリ:複数のスライドテンプレートの`.xml’ファイルが格納されています。
  • _relsディレクトリ:各テンプレートそれぞれにつき<RelationShips>が書かれたファイルが格納されています。
  • slideMastersディレクトリ:プレゼンテーションのスライド部分の書式設定などが書かれた.xmlファイルが格納されています。
  • slidesディレクトリ:プレゼンテーションのスライドの.xmlファイルが格納されています。
    • _relsディレクトリ:各スライドそれぞれにつき<Relationships>が書かれたファイルが格納されています。ここに各スライドに対応するノートの`を埋め込むことでノートとスライドを関係づけていそうです。
  • themeディレクトリ:プレゼンテーションのテーマの.xmlファイルが格納されています。
  • presentation.xmlファイル:ここにスライドやノートなどの情報がまとめられています。

<Relationship>について

こいつが今回の主役その1です。これについて説明する前に実物を見た方がすぐにわかると思います。

<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
    <Relationship Id="rId3" Type="http://schemas.microsoft.com/office/2007/relationships/media"
        Target="../media/media2.mp3" />
    <Relationship Id="rId2"
        Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/audio"
        Target="../media/media1.mp3" />
    <Relationship Id="rId1" Type="http://schemas.microsoft.com/office/2007/relationships/media"
        Target="../media/media1.mp3" />
    <Relationship Id="rId6"
        Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"
        Target="../media/image1.png" />
    <Relationship Id="rId5"
        Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout"
        Target="../slideLayouts/slideLayout2.xml" />
    <Relationship Id="rId4"
        Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/audio"
        Target="../media/media2.mp3" />
</Relationships>

これはあるスライドの<Relationships>のファイルですが、こんな感じでそのスライドが参照しているものをすべて列挙したものになります。このrIdというのはそれぞれの<Relationships>につき一意になっています。(つまり別の<Relationships>ではかぶるということです。ちなみにですが、この<Relationships>は各.xmlファイルにつき基本一つ存在します。)この<Relationship>ひとつひとつをたどることで音声ファイルを入手することができます。

Power Pointの開き方

ここではそもそもPower PointをどうやってC#で扱うのかついて見ていきます。
Power Pointを扱うには、初めにも述べましたがOpen XML SDKDocumentFormat.OpenXmlライブラリを用います。ちなみにライブラリを選んだのは何といっても最新の.Net 8で動作できるからです。
このライブラリは、NuGetで配布されているので下のコマンドかVisual Studioで「NuGetパッケージの管理」からプロジェクトに追加してください。

dotnet add package DocumentFormat.OpenXml --version 3.0.0

Open XML SDKではPower PointをPresentationDocumentクラスで管理しており、Power Pointを読み込むコードは下の通りです。

using DocumentFormat.OpenXml.Presentation;

using var pre = PresentationDocument.Open("test.pptx", false);

第一引数にPower Pointのファイルパスを指定し、第二引数で編集可能にするかどうかを選択します。今回は読み取るだけなのでfalseにしてあります。
以下このpreに対して操作をしていくことでPower Pointを操作していきます。

Power Pointのスライド

ここではPower Pointのスライドについて見ていきます。Power Pointの構造ですこし述べましたが、スライドはslidesのファイルと_rels<Relationships>のファイルからなり、Open XML SDKでは各スライドごとにこの2つをSlidePartというクラスにまとめて管理されています。  

SlidePart クラス (DocumentFormat.OpenXml.Packaging)
SlidePart を定義します
SlidePart クラス (DocumentFormat.OpenXml.Packaging) favicon https://learn.microsoft.com/ja-jp/dotnet/api/documentformat.openxml.packaging.slidepart?view=openxml-2.8.1
SlidePart クラス (DocumentFormat.OpenXml.Packaging)

また、スライドのXMLを操作するにはSlidePart.Slideで得られるSlideクラスを使います。

Slide クラス (DocumentFormat.OpenXml.Presentation)
プレゼンテーション スライド。 このクラスは、Office 2007 以降で使用できます。 オブジェクトを xml としてシリアル化されるときに、修飾名は p:sld です。
Slide クラス (DocumentFormat.OpenXml.Presentation) favicon https://learn.microsoft.com/ja-jp/dotnet/api/documentformat.openxml.presentation.slide?view=openxml-2.8.1
Slide クラス (DocumentFormat.OpenXml.Presentation)

またスライドのXMLはおおまかには下のような感じになっておりSlideクラスは<sld>ノードに対応します。

<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
  <!--スライドのテキストや図形など-->
  <p:cSld>
    <p:spTree>
      <p:pic>
        図形についてのxml(Power Point の図形を参照)
      </p:pic>
    </p:spTree>
  </p:cSld>
  <!--スライドのアニメーション-->
  <p:timing>
    <p:tnLst>
      <p:par>
        <p:cTn id="1" dur="indefinite" restart="never" nodeType="tmRoot">
          <p:childTnLst>
            アニメーションのxml(Child Time Node Listを参照)
          </p:childTnLst>
        </p:cTn>
      </p:par>
    </p:tnLst>
  </p:timing>
</p:sld>

Power Pointのアニメーション

音声ファイルへのとっかかりは<Relationships>のところで述べましたが、今度は音声ファイルの再生順を調べなくてはなりません。
アニメーションのXML構造は<p:timing>ノードに格納されています。ぱっと見ではわからないノード名が多かったり親子関係が循環していたりして理解がかなり難しくなっています。実際自分もまだよくわかってないところがありますが、わかっている分は書きたいと思います。

<p:timing>ノード

これは上で述べたようにアニメーションの構造を格納する一番上の階層のノードです。これは3つの子要素を持ちますが、ここではそのうちの一つである<tnLst>についてしか述べません。

Timing クラス (DocumentFormat.OpenXml.Presentation)
スライドのタイミング情報。 このクラスは、Office 2007 以降で使用できます。 オブジェクトを xml としてシリアル化されるときに、修飾名は p:timing です。
Timing クラス (DocumentFormat.OpenXml.Presentation) favicon https://learn.microsoft.com/ja-jp/dotnet/api/documentformat.openxml.presentation.timing?view=openxml-2.8.1
Timing クラス (DocumentFormat.OpenXml.Presentation)

<p:tnLst>(Time Node List)ノード

子要素に<par>を持つこと以外は大切じゃないので流します。

TimeNodeList クラス (DocumentFormat.OpenXml.Presentation)
Time Node List。 このクラスは、Office 2007 以降で使用できます。 オブジェクトを xml としてシリアル化されるときに、修飾名は p:tnLst です。
TimeNodeList クラス (DocumentFormat.OpenXml.Presentation) favicon https://learn.microsoft.com/ja-jp/dotnet/api/documentformat.openxml.presentation.timenodelist?view=openxml-2.8.1
TimeNodeList クラス (DocumentFormat.OpenXml.Presentation)

<p:par>(Parallel Time Node)ノード

これも同じく子要素に<cTn>を持つこと以外は大切じゃないので流します。

ParallelTimeNode クラス (DocumentFormat.OpenXml.Presentation)
Parallel Time ノード。 このクラスは、Office 2007 以降で使用できます。 オブジェクトを xml としてシリアル化されるときに、修飾名は p:par です。
ParallelTimeNode クラス (DocumentFormat.OpenXml.Presentation) favicon https://learn.microsoft.com/ja-jp/dotnet/api/documentformat.openxml.presentation.paralleltimenode?view=openxml-2.8.1
ParallelTimeNode クラス (DocumentFormat.OpenXml.Presentation)

<p:cTn>(Common Time Node)ノード

アニメーション一つ一つを表したり、一連のアニメーション全体を表したりとまさにcommonなノードです。nodeType属性によってどんな役割を持つかが定義され、presetClass属性がmediacallのものが音声・動画のアニメーションを表します。またこれのid属性の順番にアニメーションが再生されていそうです。子要素には<childTnLst>,<stCondLst>などを持ちます。

CommonTimeNode クラス (DocumentFormat.OpenXml.Presentation)
Parallel TimeNode。 このクラスは、Office 2007 以降で使用できます。 オブジェクトを xml としてシリアル化されるときに、修飾名は p:cTn です。
CommonTimeNode クラス (DocumentFormat.OpenXml.Presentation) favicon https://learn.microsoft.com/ja-jp/dotnet/api/documentformat.openxml.presentation.commontimenode?view=openxml-2.8.1
CommonTimeNode クラス (DocumentFormat.OpenXml.Presentation)

<p:seq>(Sequence Time Node)ノード

ひとまとまりのアニメーションを表します。子要素には<cTn>などを持ちます。

SequenceTimeNode クラス (DocumentFormat.OpenXml.Presentation)
シーケンス時間ノード。 このクラスは、Office 2007 以降で使用できます。 オブジェクトを xml としてシリアル化されるときに、修飾名は p:seq です。
SequenceTimeNode クラス (DocumentFormat.OpenXml.Presentation) favicon https://learn.microsoft.com/ja-jp/dotnet/api/documentformat.openxml.presentation.sequencetimenode?view=openxml-2.8.1
SequenceTimeNode クラス (DocumentFormat.OpenXml.Presentation)

<p:childTnLst>(Child Time Node List)ノード

これは<par>,<cTn>ノードとともにノードの「まとまり」を表します。例えば、フェードイン、音声再生、フェードアウトのアニメーションがあったとするとこんな感じになります。

<childTnLst>
  <seq>
    <cTn nodeType="mainSeq">
      <childTnLst>
        <!--アニメーション1-->
        <par><cTn><childTnLst>
          <par><cTn><childTnLst>
            <par><cTn presetClass="entr" nodeType="clickEffect"><childTnLst>
                フェードイン
            </childTnLst></cTn></par>
          </childTnLst></cTn></par>
        </childTnLst></cTn></par>
        <!--アニメーション2-->
        <par><cTn><childTnLst>
          <par><cTn><childTnLst>
            <par><cTn presetClass="mediacall" nodeType="clickEffect"><childTnLst>
                音声再生(cmdノードが埋め込まれる)
            </childTnLst></cTn></par>
          </childTnLst></cTn></par>
        </childTnLst></cTn></par>
        <!--アニメーション3-->
        <par><cTn><childTnLst>
          <par><cTn><childTnLst>
            <par><cTn presetClass="entr" nodeType="clickEffect"><childTnLst>
                フェードアウト
            </childTnLst></cTn></par>
          </childTnLst></cTn></par>
        </childTnLst></cTn></par>
      </childTnLst>
    </cTn>
  </seq>
</childTnList>

これを見てわかるように子要素に<par>,<seq>,<cmd>ノードなどを持ちます。

ChildTimeNodeList クラス (DocumentFormat.OpenXml.Presentation)
ChildTimeNodeList クラスを定義します。 このクラスは、Office 2007 以降で使用できます。 オブジェクトを xml としてシリアル化されるときに、修飾名は p:childTnLst です。
ChildTimeNodeList クラス (DocumentFormat.OpenXml.Presentation) favicon https://learn.microsoft.com/ja-jp/dotnet/api/documentformat.openxml.presentation.childtimenodelist?view=openxml-2.8.1
ChildTimeNodeList クラス (DocumentFormat.OpenXml.Presentation)

<p:cBhvr>(Common Behavior)ノード

<cTn>と組み合わせてアニメーションを定義するノードです。子要素に<cTn>,<tgtEl>などを持ちます。

CommonBehavior クラス (DocumentFormat.OpenXml.Presentation)
CommonBehavior クラスを定義します。 このクラスは、Office 2007 以降で使用できます。 オブジェクトを xml としてシリアル化されるときに、修飾名は p:cBhvr です。
CommonBehavior クラス (DocumentFormat.OpenXml.Presentation) favicon https://learn.microsoft.com/ja-jp/dotnet/api/documentformat.openxml.presentation.commonbehavior?view=openxml-2.8.1
CommonBehavior クラス (DocumentFormat.OpenXml.Presentation)

<p:tgtEl>(Target Element)ノード

このノードでは、子要素でアニメーションが適用される対象を指定します。子要素に<spTgt>などを持ちます。

TargetElement クラス (DocumentFormat.OpenXml.Presentation)
ターゲット要素トリガーの選択。 このクラスは、Office 2007 以降で使用できます。 オブジェクトを xml としてシリアル化されるときに、修飾名は p:tgtEl です。
TargetElement クラス (DocumentFormat.OpenXml.Presentation) favicon https://learn.microsoft.com/ja-jp/dotnet/api/documentformat.openxml.presentation.targetelement?view=openxml-2.8.1
TargetElement クラス (DocumentFormat.OpenXml.Presentation)

<p:spTgt>(Shape Target)ノード

このノードでは、アニメーションが適用される図形をspid属性で指定します。また、DocumentFormat.OpenXmlではなぜか図形のidはuintなのに、これはstringでとってくるのでuintに変換する必要があります。

ShapeTarget クラス (DocumentFormat.OpenXml.Presentation)
図形ターゲット。 このクラスは、Office 2007 以降で使用できます。 オブジェクトを xml としてシリアル化されるときに、修飾名は p:spTgt です。
ShapeTarget クラス (DocumentFormat.OpenXml.Presentation) favicon https://learn.microsoft.com/ja-jp/dotnet/api/documentformat.openxml.presentation.shapetarget?view=openxml-2.8.1
ShapeTarget クラス (DocumentFormat.OpenXml.Presentation)

<p:cmd>(Command Node)ノード

これは「音声や動画を再生する」(playForm)などのコマンドを打つためのノードで、<cBhvr>,<cTn>,<tgtEl>,<spTgt>などを組み合わせて表現されます。

Command クラス (DocumentFormat.OpenXml.Presentation)
コマンド。 このクラスは、Office 2007 以降で使用できます。 オブジェクトを xml としてシリアル化されるときに、修飾名は p:cmd です。
Command クラス (DocumentFormat.OpenXml.Presentation) favicon https://learn.microsoft.com/ja-jp/dotnet/api/documentformat.openxml.presentation.command?view=openxml-2.8.1
Command クラス (DocumentFormat.OpenXml.Presentation)

ちなみに音声が再生される<cmd>ノードとその子要素のxmlは大まかには以下のような感じになります。

<p:cmd type="call" cmd="playFrom(0.0)">
  <p:cBhvr>
    <p:cTn dur="99240"/>
    <p:tgtEl>
      <p:spTgt spid="5" />
    </p:tgtEl>
  </p:cBhvr>
</p:cmd>

<spTgt>ノードで述べましたが、このspidで指定されている図形にアニメーションが適応されます。つまり、Power Pointでは音声が図形として埋め込まれていることを示唆していて(そして実際にそうですが)、この spidの先の図形をたどることで音声を取得できる ことが分かります。

Power Point の図形

ここまでの話から、以下の順番でアニメーション順に音声のある図形(画像)のidを取得できることが分かります。

  1. <cTn>のうちpresetClass="mediacall"なものを取ってきて、idの順番にする
  2. 上で取れた<cTn>から<cmd>-><cBhvr>-><tgtEl>-><spTgt>の順で取ってくる
  3. 上で取れた<spTgt>spid属性の値を取得する

ということで、今度は図形を取得しなければいけないので図形を表すxmlが格納されているについて見ていきましょう。
アニメーションのところとと同じく分かってないところがありますが、わかっている分については書いていきます。

<p:cSld>(Common Slide Data)ノード

これがスライドの図形やテキストボックスなどを表すxmlの一番上の要素です。またアニメーションの親玉だった<timing>ノードとは同階層にあります。子要素には<spTree>ノードなどを持ちます。

CommonSlideData クラス (DocumentFormat.OpenXml.Presentation)
ノート スライドの一般的なスライド データ。 このクラスは、Office 2007 以降で使用できます。 オブジェクトを xml としてシリアル化されるときに、修飾名は p:cSld です。
CommonSlideData クラス (DocumentFormat.OpenXml.Presentation) favicon https://learn.microsoft.com/ja-jp/dotnet/api/documentformat.openxml.presentation.commonslidedata?view=openxml-2.8.1
CommonSlideData クラス (DocumentFormat.OpenXml.Presentation)

<p:spTree>(Shape Tree)ノード

このノードの下に実際の図形やテキストボックスのxmlが格納されています。子要素には<pic>ノードなどを持ちます。

ShapeTree クラス (DocumentFormat.OpenXml.Presentation)
図形ツリー。 このクラスは、Office 2007 以降で使用できます。 オブジェクトを xml としてシリアル化されるときに、修飾名は p:spTree です。
ShapeTree クラス (DocumentFormat.OpenXml.Presentation) favicon https://learn.microsoft.com/ja-jp/dotnet/api/documentformat.openxml.presentation.shapetree?view=openxml-2.8.1
ShapeTree クラス (DocumentFormat.OpenXml.Presentation)

<p:pic>(Picture)ノード

これは画像一つ一つを表します。音声は画像と結びついているので、このノードを取ってこれれば音声を取ってこれます。子要素には<nvPicPr>ノードなどを持ちます。

Picture クラス (DocumentFormat.OpenXml.Presentation)
Picture クラスを定義します。 このクラスは、Office 2007 以降で使用できます。 オブジェクトを xml としてシリアル化されるときに、修飾名は p:pic です。
Picture クラス (DocumentFormat.OpenXml.Presentation) favicon https://learn.microsoft.com/ja-jp/dotnet/api/documentformat.openxml.presentation.picture?view=openxml-2.8.1
Picture クラス (DocumentFormat.OpenXml.Presentation)

<p:nvPicPr>(Non Visual Picture Properties)ノード

ここには画像以外の情報つまり音声や動画の情報が格納されています。子要素には<cNvPr>,<nvPr>ノードなどを持ちます。

NonVisualPictureProperties クラス (DocumentFormat.OpenXml.Presentation)
図のビジュアル以外のプロパティ。 このクラスは、Office 2007 以降で使用できます。 オブジェクトを xml としてシリアル化されるときに、修飾名は p:nvPicPr です。
NonVisualPictureProperties クラス (DocumentFormat.OpenXml.Presentation) favicon https://learn.microsoft.com/ja-jp/dotnet/api/documentformat.openxml.presentation.nonvisualpictureproperties?view=openxml-2.8.1
NonVisualPictureProperties クラス (DocumentFormat.OpenXml.Presentation)

<p:cNvPr>(Non Visual Drawing Properties)ノード

これのid属性の値が<spTgt>で指定されるidになります。またname属性に音声ファイルの名前が入ります。

NonVisualDrawingProperties クラス (DocumentFormat.OpenXml.Presentation)
ビジュアル以外の描画プロパティ。 このクラスは、Office 2007 以降で使用できます。 オブジェクトを xml としてシリアル化されるときに、修飾名は p:cNvPr です。
NonVisualDrawingProperties クラス (DocumentFormat.OpenXml.Presentation) favicon https://learn.microsoft.com/ja-jp/dotnet/api/documentformat.openxml.presentation.nonvisualdrawingproperties?view=openxml-2.8.1
NonVisualDrawingProperties クラス (DocumentFormat.OpenXml.Presentation)

<p:nvPr>(Application Non Visual Drawing Properties)ノード

これはどういったファイルが埋め込まれるかを表すノードをまとめるノードです。子要素に<audioFile>ノードなどを持ちます。

ApplicationNonVisualDrawingProperties クラス (DocumentFormat.OpenXml.Presentation)
アプリケーションのビジュアル以外の描画プロパティ。 このクラスは、Office 2007 以降で使用できます。 オブジェクトを xml としてシリアル化されるときに、修飾名は p:nvPr です。
ApplicationNonVisualDrawingProperties クラス (DocumentFormat.OpenXml.Presentation) favicon https://learn.microsoft.com/ja-jp/dotnet/api/documentformat.openxml.presentation.applicationnonvisualdrawingproperties?view=openxml-2.8.1
ApplicationNonVisualDrawingProperties クラス (DocumentFormat.OpenXml.Presentation)

<p:audioFile>(Audio From File)ノード

これはlink属性にwav以外の音声ファイルへの<Relationship>ridを持ちます。これを取得することで音声データにアクセスできるようになります。

AudioFromFile クラス (DocumentFormat.OpenXml.Drawing)
ファイルからのオーディオ。 このクラスは、Office 2007 以降で使用できます。 オブジェクトを xml としてシリアル化されるときに、修飾名は a:audioFile です。
AudioFromFile クラス (DocumentFormat.OpenXml.Drawing) favicon https://learn.microsoft.com/ja-jp/dotnet/api/documentformat.openxml.drawing.audiofromfile?view=openxml-2.8.1
AudioFromFile クラス (DocumentFormat.OpenXml.Drawing)

画像(音声付)のところで音声にかかわるxmlををまとめると次のようになります。

<p:pic>
  <p:nvPicPr>
    <p:cNvPr id="4" name="week01-1"></p:cNvPr>
    <p:nvPr>
      <a:audioFile r:link="rId2" />
    </p:nvPr>
  </p:nvPicPr>
</p:pic>

つまり、音声を取得するには各スライドごとに<pic>ノードを取得して

  • <cNvPr>ノードのid属性
  • <audioFile>ノードのlink属性

を取得する必要があることがわかりました。

音声を取ってくるまでのコード

ここからはこれまでの内容を踏まえたうえで、実際のコードを見ていくことにします。

<Relationship>から音声データ(DataPart)を取る

Power PointのアニメーションPower Point の図形から、音声の存在する図形のid(spid)を介して音声のrIdとアニメーション順をつなげられることが分かりました。では今度はrIdから音声データ(DataPart)を取る方法を見ていきます。

DataPartクラスというのは音声などのmediaフォルダに埋め込まれたファイルへの参照を取ってきたものです。例えば音声データの<Relationship>rIdが’rId1`の場合は以下のようになります。

using var pre = PresentationDocument.Open(file.FullName, false);
var dataPart = pre.PresentationPart?.SlideParts[0].DataPartReferenceRelationships
                      .Where(x => x.Id == "rId1").First().DataPart;

ここで注意が必要なのは<Relationship>を取る際にGetReferenceRelationship(String) メソッドを使わないことです。いかにも取ってこれそうなやつですがこれだと目当てのDataPartを取ることができません。

スライドから音声ファイルを含む画像spidとその音声ファイルのDataPartを取得する

前の節で述べたように図形のId(spid)を介すことで音声のrIdとアニメーション順を紐づけることができます。

ここでは、あるスライドの図形のspidと音声ファイルのrIdを取得したうえで、spidDataPartIEnumerable<uint, DataPart>を返すメソッドのコードを示します。

internal static IEnumerable<(uint shapeId, DataPart audioData)> GetShapeIdAndAudioReference(SlidePart slidePart)
{
    foreach (var nvPicPr in slidePart.Slide.CommonSlideData?.ShapeTree?.Descendants<Picture>().Select(pi => pi.NonVisualPictureProperties) ?? [])
    {
        uint? shapeId = (nvPicPr?.NonVisualDrawingProperties?.Id?.HasValue ?? false) ? nvPicPr.NonVisualDrawingProperties.Id.Value : null;
        if (shapeId is null)
            continue;
        foreach (var audioFile in nvPicPr?.ApplicationNonVisualDrawingProperties?.Descendants<DocumentFormat.OpenXml.Drawing.AudioFromFile>() ?? [])
        {
            var rId = (audioFile.Link?.HasValue ?? false) ? audioFile.Link.Value : null;
            if (rId is not null)
            {
                yield return (shapeId.Value, slidePart.DataPartReferenceRelationships.Where(x => x.Id == rId).First().DataPart);
            }
            else
            {
                continue;
            }
        }
    }
}

このコードは<pic>ノードから<nvPicPr>ノードを取得して、その子要素である

  • <cNvPr>ノードのid属性(spid)
  • <audioFile>ノードのlink属性 (rId) -> DataPartReferenceRelationships -> DataPart

という流れを抑えていればそれほど難しくないと思います。

音声の図形のspidとアニメーション順を取得する

Power Pointのアニメーションから、アニメーションの順で音声のspidを取ってくるには

  1. presetClass属性がmediacallcTnノードを取ってくる&id属性の順番で並び替える
  2. それぞれについてspTgtノードのspid属性を取得

をすることで得られます。
以下のコードは一例です。各SlidePartに対してこれらの動作を実行しています。

foreach (var slidePart in pre.PresentationPart?.SlideParts ?? [])
{
    // 条件を満たすcTnノードを取ってくる
    var ctnList = slidePart.Slide.Timing?.Descendants<CommonTimeNode>()
        .Where(ctn => (ctn.PresetClass?.HasValue ?? false) && ctn.PresetClass.Value == TimeNodePresetClassValues.MediaCall)
        .OrderBy(ctn => ctn.Id!.Value);

    if (ctnList != null && ctnList.Any())
    {
        foreach (var ctn in ctnList)
        {
            // spTgtノードを取ってくる
            var shapeTarget = ctn.ChildTimeNodeList?.Descendants<ShapeTarget>().FirstOrDefault();
            var shapeId = (shapeTarget?.ShapeId?.HasValue ?? false) ? shapeTarget.ShapeId.Value : null;
            if (uint.TryParse(shapeId, out var id))
            {
                // ここのidがspidになっているので、ここで音声の文字起こししたものと組み合わせる
            }
        }
    }
}

ここで便利メソッドであるDecendants<T>()を紹介しておくと、このメソッドは<T>に検索したいxmlに対応するクラスを指定することで子要素に存在する<T>IEnumerableで返してくれます。
このコードと上で例として出したGetShapeIdAndAudioReference()を組み合わせることで音声データをアニメーション順で処理することができます。

whisperで文字起こし

今回は文字起こしにwhisperを使っていきます。whisperの呼び出しにはBetalgo.OpenAIライブラリを使います。

まずはOpenAIのAPIキーを取得しましょう。初めての方は以下のサイトを参考にしてみてください。

【2023年版】OpenAIのAPIキー発行・取得手順!ChatGPTやGPT-4、DALL-E3をAPI経由で利用可能 | AutoWorker〜Google Apps Script(GAS)とSikuliで始める業務改善入門
2022年11月にOpenAIが発表した文章生成AI「ChatGPT」は大きな反響を集めました。 現在ChatGPTはサービスページからのみ利用できませんが、ChatGPTのような...
【2023年版】OpenAIのAPIキー発行・取得手順!ChatGPTやGPT-4、DALL-E3をAPI経由で利用可能 | AutoWorker〜Google Apps Script(GAS)とSikuliで始める業務改善入門 favicon https://auto-worker.com/blog/?p=6988
【2023年版】OpenAIのAPIキー発行・取得手順!ChatGPTやGPT-4、DALL-E3をAPI経由で利用可能 | AutoWorker〜Google Apps Script(GAS)とSikuliで始める業務改善入門

取得できたらコードには直書きしなくて済むように環境変数に入れたり、UserSecretを使ったりしてAPIキーを保存します。(今回は環境変数に入れたとして話を進めます)

githubにある例を参考にしてOpenAIServiceを初期化します。

var openApi = new OpenAIService(new OpenAiOptions()
{
    ApiKey = Environment.GetEnvironmentVariable("OpenAiApiKey")!
});

ここまで来たら、実際にwhisperを使って音声を文字起こしする準備ができました。チュートリアルコードがあるのでこれを参考にすると、このようなコードになります。

internal static async Task<string> Transcript(DataPart audio, OpenAIService openApi, CancellationToken ct)
{
    ct.ThrowIfCancellationRequested();
    // DaraPartから音声ファイルのStreamを取ってくる
    using var audioStream = audio.GetStream();
    // ここで実際にwhisperを叩く
    var audioResult = await openApi.Audio.CreateTranscription(new OpenAI.ObjectModels.RequestModels.AudioCreateTranscriptionRequest
    {
        FileName = Path.GetFileName(audio.Uri.ToString()),
        Model = Models.WhisperV1,
        FileStream = audioStream
    }, ct);
    if (audioResult.Successful)
    {
        return audioResult.Text;
    }
    else
    {
        return "";
    }
}

これで音声データ(DataPart)から実際に文字起こしをすることができるようになりました。
あとwhisperに限らずですがOpenAIのAPIを叩くのには制限(50回/分)があるので短めのファイルを大量に連続でかけると引っかかる恐れがあるので注意しましょう。

まとめ

ここまでの流れをおおざっぱにまとめると、

  1. アニメーション順にspidを取る (参照)
  2. そのspid先の図形から音声へのrId(<RelationShips>)を取る (参照)
  3. 音声のrIdから音声のDataPartを取る (参照)
  4. 音声のDataPartからStreamを取ってwhisperに投げて文字起こし (参照)

といった流れになっています。 ここまでの話を組み合わせることでPower Pointの文字起こしができるようになっています。皆さんも試してみてください。
参考までに自分が書いたコードも載せておきます。

Program.cs
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Presentation;
using OpenAI;
using OpenAI.Managers;
using System.CommandLine;
using System.Diagnostics;
using System.Text;
using static PowerPointTranscript.Helper;

var rootCommand = new RootCommand()
{
    Description = "powerpointの文字を起こしたファイルを出力します。"
};
var filenameArgument = new Argument<FileInfo>("filename");
rootCommand.AddArgument(filenameArgument);
var outDirOption = new Option<DirectoryInfo>(["--outDir", "-o"]);
rootCommand.AddOption(outDirOption);
rootCommand.SetHandler(async (file, outDir) =>
{
    outDir ??= new DirectoryInfo(Environment.CurrentDirectory);
    if (!outDir.Exists)
    {
        outDir.Create();
    }

    if (!file.Exists)
        return;

    // スライド番号と文字起こししたデータ
    Dictionary<int, string> data = [];
    // スライド番号と図形idとそこから文字起こししたやつのリスト
    List<(uint slideId, uint shapeId, string transscript)> transcriptList = [];

    var openApi = new OpenAIService(new OpenAiOptions()
    {
        ApiKey = Environment.GetEnvironmentVariable("OpenAiApiKey")!
    });

    using var cts = new CancellationTokenSource();

    using var pre = PresentationDocument.Open(file.FullName, false);

    // さきに音声を処理しておく + レート制限対策
    var audioListBy45 = pre.PresentationPart?.SlideParts.SelectMany(slidePart =>
    {
        var slideId = uint.Parse(GetSlideName(slidePart.Uri).Replace("slide", ""));
        return GetShapeIdAndAudioReference(slidePart).Select(x => (slideId, x.shapeId, x.audioData));
    }).Chunk(45).ToArray() ?? [];
    for (int i = 0; i < audioListBy45.Length; i++)
    {
        (uint slideId, uint shapeId, DataPart audioData)[]? chunk = audioListBy45[i] ?? [];
        var tasks = chunk.Select(async x => (x.slideId, x.shapeId, await Transcript(x.audioData, openApi, cts.Token)));
        // ほんとはTask.WhenAll()をやりたかったがうまくできなかったのでforeach
        foreach (var task in tasks)
        {
            transcriptList.Add(await task);
        }
        await Task.Delay(TimeSpan.FromMinutes(1), cts.Token);
    }

    foreach (var slidePart in pre.PresentationPart?.SlideParts ?? [])
    {
        var ctnList = slidePart.Slide.Timing?.Descendants<CommonTimeNode>()
            .Where(ctn => (ctn.PresetClass?.HasValue ?? false) && ctn.PresetClass.Value == TimeNodePresetClassValues.MediaCall)
            .OrderBy(ctn => ctn.Id!.Value);
        var slideId = int.Parse(GetSlideName(slidePart.Uri).Replace("slide", ""));

        if (ctnList != null && ctnList.Any())
        {
            var builder = new StringBuilder();
            builder.AppendLine(slidePart.Uri.ToString());
            foreach (var ctn in ctnList)
            {
                var shapeTarget = ctn.ChildTimeNodeList?.Descendants<ShapeTarget>().FirstOrDefault();
                var shapeId = (shapeTarget?.ShapeId?.HasValue ?? false) ? shapeTarget.ShapeId.Value : null;
                if (uint.TryParse(shapeId, out var id))
                {
                    Debug.WriteLine($"{id}, {slidePart.Uri}");
                    var trans = transcriptList.Where(x => x.slideId == slideId && x.shapeId == id);
                    if (trans.Any())
                    {
                        builder.AppendLine(trans.First().transscript);
                    }
                    else
                    {
                        string message = $"{slidePart.Slide.CommonSlideData!.ShapeTree!.Descendants<Picture>().Select(pi => pi.NonVisualPictureProperties!.NonVisualDrawingProperties).Where(p => p!.Id!.Value == id).First()!.Name!.Value} is not an audio file.";
                        Console.WriteLine($"{message} (in slide{slideId})");
                        builder.AppendLine($"[Warning: {message}]");
                    }
                }
            }
            data.Add(slideId, builder.ToString());
        }
    }

    // txtに書き込み
    if (data.Count > 0)
    {
        using var stream = File.Create(Path.Combine(outDir.FullName, $"{Path.GetFileNameWithoutExtension(file.Name)}.txt"));
        using var writer = new StreamWriter(stream);
        foreach (var val in data.OrderBy(x => x.Key).Select(x => x.Value))
        {
            writer.WriteLine($"{val}\n");
        }
        writer.Flush();
    }

}, filenameArgument, outDirOption);

return await rootCommand.InvokeAsync(args);
Healper.cs
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Presentation;
using OpenAI.Managers;
using OpenAI.ObjectModels;

namespace PowerPointTranscript;

internal static class Helper
{
    internal static IEnumerable<(uint shapeId, DataPart audioData)> GetShapeIdAndAudioReference(SlidePart slidePart)
    {
        foreach (var nvPicPr in slidePart.Slide.CommonSlideData?.ShapeTree?.Descendants<Picture>().Select(pi => pi.NonVisualPictureProperties) ?? [])
        {
            uint? shapeId = (nvPicPr?.NonVisualDrawingProperties?.Id?.HasValue ?? false) ? nvPicPr.NonVisualDrawingProperties.Id.Value : null;
            if (shapeId is null)
                continue;
            foreach (var audioFile in nvPicPr?.ApplicationNonVisualDrawingProperties?.Descendants<DocumentFormat.OpenXml.Drawing.AudioFromFile>() ?? [])
            {
                var rId = (audioFile.Link?.HasValue ?? false) ? audioFile.Link.Value : null;
                if (rId is not null)
                {
                    yield return (shapeId.Value, slidePart.DataPartReferenceRelationships.Where(x => x.Id == rId).First().DataPart);
                }
                else
                {
                    continue;
                }
            }
        }
    }

    internal static async Task<string> Transcript(DataPart audio, OpenAIService openApi, CancellationToken ct)
    {
        ct.ThrowIfCancellationRequested();
        using var audioStream = audio.GetStream();
        var audioResult = await openApi.Audio.CreateTranscription(new OpenAI.ObjectModels.RequestModels.AudioCreateTranscriptionRequest
        {
            FileName = Path.GetFileName(audio.Uri.ToString()),
            Model = Models.WhisperV1,
            FileStream = audioStream
        }, ct);
        if (audioResult.Successful)
        {
            return audioResult.Text;
        }
        else
        {
            Console.WriteLine($"Data:{Path.GetFileName(audio.Uri.ToString())}");
            if (audioResult.Error == null)
            {
                throw new Exception("Unknown Error");
            }
            Console.WriteLine($"{audioResult.Error.Code}: {audioResult.Error.Message}");
            return "unknown";
        }
    }

    internal static string GetSlideName(Uri uri)
    {
        return Path.GetFileNameWithoutExtension(uri.ToString());
    }
}

今後の課題としては、音声処理をTask.WhenAll()でやろうとするとStreamの取得がうまくいかない事象の改善に取り組みたいです。(何かわかる方がいたらコメントで教えていただけると嬉しいです)

長文になり読みにくかったかと思いますが、ここまで読んでくださった皆さんありがとうございました。

環境など

  • .NET 8 (Version 8.0.100)
  • Visual Studio 2022 Community (Version 17.8.3)
  • Power Point (Version 2311)
  • Betalgo.OpenAI (Version 7.4.3)
  • DocumentFormat.OpenXml (Version 3.0.0)
  • DocumentFormat.OpenXml.Linq (Version 3.0.0)
  • System.CommandLine (Version 2.0.0-beta4.22272.1)