これは、OUCCアドベントカレンダーの22日目の記事です。Qiitaにもマルチ投稿しています。
なんで文字起こしするのか
自分は大学生なのですが、時々授業が動画ではなく音声付Power Pointになることがあります。この時に何が困るかっていうと倍速再生ができないのでまともに授業を受けるととても時間がかかるのです。
というわけで、文字起こしして音声聞かずに済ませることで時間短縮を狙ったのが今回の動機です。
どうやってやるのか
今回はC#のライブラリであるOpen-XML-SDKのDocumentFormat.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 SDKのDocumentFormat.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
というクラスにまとめて管理されています。
また、スライドのXMLを操作するにはSlidePart.Slide
で得られるSlide
クラスを使います。
またスライドの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>
についてしか述べません。
<p:tnLst>(Time Node List)ノード
子要素に<par>
を持つこと以外は大切じゃないので流します。
<p:par>(Parallel Time Node)ノード
これも同じく子要素に<cTn>
を持つこと以外は大切じゃないので流します。
<p:cTn>(Common Time Node)ノード
アニメーション一つ一つを表したり、一連のアニメーション全体を表したりとまさにcommon
なノードです。nodeType
属性によってどんな役割を持つかが定義され、presetClass
属性がmediacall
のものが音声・動画のアニメーションを表します。またこれのid
属性の順番にアニメーションが再生されていそうです。子要素には<childTnLst>
,<stCondLst>
などを持ちます。
<p:seq>(Sequence Time Node)ノード
ひとまとまりのアニメーションを表します。子要素には<cTn>
などを持ちます。
<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>
ノードなどを持ちます。
<p:cBhvr>(Common Behavior)ノード
<cTn>
と組み合わせてアニメーションを定義するノードです。子要素に<cTn>
,<tgtEl>
などを持ちます。
<p:tgtEl>(Target Element)ノード
このノードでは、子要素でアニメーションが適用される対象を指定します。子要素に<spTgt>
などを持ちます。
<p:spTgt>(Shape Target)ノード
このノードでは、アニメーションが適用される図形をspid
属性で指定します。また、DocumentFormat.OpenXml
ではなぜか図形のidはuint
なのに、これはstring
でとってくるのでuint
に変換する必要があります。
<p:cmd>(Command Node)ノード
これは「音声や動画を再生する」(playForm)などのコマンドを打つためのノードで、<cBhvr>
,<cTn>
,<tgtEl>
,<spTgt>
などを組み合わせて表現されます。
ちなみに音声が再生される<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を取得できることが分かります。
<cTn>
のうちpresetClass="mediacall"
なものを取ってきて、id
の順番にする- 上で取れた
<cTn>
から<cmd>
-><cBhvr>
-><tgtEl>
-><spTgt>
の順で取ってくる - 上で取れた
<spTgt>
のspid
属性の値を取得する
ということで、今度は図形を取得しなければいけないので図形を表すxmlが格納されている
アニメーションのところとと同じく分かってないところがありますが、わかっている分については書いていきます。
<p:cSld>(Common Slide Data)ノード
これがスライドの図形やテキストボックスなどを表すxmlの一番上の要素です。またアニメーションの親玉だった<timing>
ノードとは同階層にあります。子要素には<spTree>
ノードなどを持ちます。
<p:spTree>(Shape Tree)ノード
このノードの下に実際の図形やテキストボックスのxmlが格納されています。子要素には<pic>
ノードなどを持ちます。
<p:pic>(Picture)ノード
これは画像一つ一つを表します。音声は画像と結びついているので、このノードを取ってこれれば音声を取ってこれます。子要素には<nvPicPr>
ノードなどを持ちます。
<p:nvPicPr>(Non Visual Picture Properties)ノード
ここには画像以外の情報つまり音声や動画の情報が格納されています。子要素には<cNvPr>
,<nvPr>
ノードなどを持ちます。
<p:cNvPr>(Non Visual Drawing Properties)ノード
これのid
属性の値が<spTgt>
で指定されるidになります。またname
属性に音声ファイルの名前が入ります。
<p:nvPr>(Application Non Visual Drawing Properties)ノード
これはどういったファイルが埋め込まれるかを表すノードをまとめるノードです。子要素に<audioFile>
ノードなどを持ちます。
<p:audioFile>(Audio From File)ノード
これはlink
属性にwav以外の音声ファイルへの<Relationship>
のrid
を持ちます。これを取得することで音声データにアクセスできるようになります。
画像(音声付)のところで音声にかかわる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
を取得したうえで、spid
とDataPart
のIEnumerable<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
を取ってくるには
presetClass
属性がmediacall
のcTn
ノードを取ってくる&id
属性の順番で並び替える- それぞれについて
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キーを取得しましょう。初めての方は以下のサイトを参考にしてみてください。
取得できたらコードには直書きしなくて済むように環境変数に入れたり、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回/分)があるので短めのファイルを大量に連続でかけると引っかかる恐れがあるので注意しましょう。
まとめ
ここまでの流れをおおざっぱにまとめると、
- アニメーション順に
spid
を取る (参照) - その
spid
先の図形から音声へのrId
(<RelationShips>
)を取る (参照) - 音声の
rId
から音声のDataPart
を取る (参照) - 音声の
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)