# Main function that builds window and adds page. Also, adds audio file and dialogs that are invisible as overlay.
async def main(page: ft.Page):
page.title = 'Speech + Subtitles Player'
page.window_height = 800
page.theme_mode=ft.ThemeMode.SYSTEM
page.update()
# Appends audio as an overlay to the page.
async def load_audio():
page.overlay.append(app.audio1)
page.update()
# Creates an instance of AudioSubPlayer class. Passes load_audio for the instance to append audio to the page.
app = AudioSubPlayer(load_audio)
page.add(app)
# Adds dialog instance methods to the page.
page.overlay.extend([app.pick_speech_file_dialog, app.pick_text_file_dialog,
app.export_as_srt_dialog, app.export_as_txt_dialog])
page.update()
def __init__(self, load_audio):
super().__init__()
self.position = 0
self.duration = 0
self.isPlaying = False
self.load_audio = load_audio
# == Controls ==
# Audio control with default properties
self.audio1 = ft.Audio(
src='',
volume=1,
balance=0,
playback_rate=1,
on_loaded=self.loaded,
on_position_changed = self.position_changed,
on_state_changed = self.playback_completed,
)
# Path of the audio file
self.base_dir = ft.Text(value=f"Base Directory: ")
# Open speech file button
self.speech_file_button = ft.ElevatedButton(
text='Open Speech File',
icon=ft.icons.RECORD_VOICE_OVER_OUTLINED,
width=210,
on_click=self.pre_pick_speech_file,
)
# Speech file picker control
self.pick_speech_file_dialog = ft.FilePicker(on_result=self.pick_speech_file_result)
# Speech file name
self.speech_file_name = ft.Text(value='← Click to open a speech file.')
# Alert dialog that opens if subtitle was edited but not saved when Open Speech File button is clicked.
self.speech_save_or_cancel_dialog = ft.AlertDialog(
modal=True,
title=ft.Text('Change not saved.'),
content=ft.Text('Do you want to discard the change?'),
actions=[
#ft.TextButton('Save', on_click=self.save_then_open, tooltip='Save then open another file.'),
ft.TextButton('Open without save', on_click=self.open_speech_without_save, tooltip='Change will be lost.'),
ft.TextButton('Cancel', on_click=self.close_speech_save_or_cancel_dialog),
]
)
# Open text file button
self.text_file_button = ft.ElevatedButton(
text='Open SRT/TXT File',
icon=ft.icons.TEXT_SNIPPET_OUTLINED,
on_click=self.pre_pick_text_file,
disabled=True,
width=210,
)
# Text file picker control
self.pick_text_file_dialog = ft.FilePicker(on_result=self.pick_text_file_result)
# Text file name
self.text_file_name = ft.Text(value='No file selected.')
# Save button to update edited subtitles. No dialog, it just overwrites current text file.
self.save_button = ft.ElevatedButton(
text='Save',
icon=ft.icons.SAVE_OUTLINED,
tooltip='Update current SRT/TXT file.',
disabled=True,
on_click=self.save_clicked
)
# Export as SRT button which opens a save dialog. Only available when SRT is open because SRT needs timestamp.
self.export_as_srt_button = ft.ElevatedButton(
text = 'SRT',
icon=ft.icons.SAVE_ALT,
on_click=self.export_as_srt,
disabled=True,
tooltip='Export as SRT file.'
)
# Export as SRT file picker
self.export_as_srt_dialog = ft.FilePicker(on_result=self.export_as_srt_result)
# Export as TXT button which opens a save dialog. TXT has not timestamp, subtitle text only.
self.export_as_txt_button = ft.ElevatedButton(
text = 'TXT',
icon=ft.icons.SAVE_ALT,
on_click=self.export_as_txt,
disabled=True,
tooltip='Export as TXT file.'
)
# Export as TXT file picker
self.export_as_txt_dialog = ft.FilePicker(on_result=self.export_as_txt_result)
# Export button to open a dialog (not in use)
self.export_button = ft.ElevatedButton(
text='Export as...',
icon=ft.icons.SAVE_ALT,
on_click=self.open_export_dialog,
disabled=True,
)
# Export as dialog (not in use)
self.export_dialog = ft.AlertDialog(
modal = True,
title = ft.Text('Export text as...'),
content = ft.Text('Plesae select a file type.'),
actions = [
ft.TextButton('SRT', on_click=self.export_as_srt, tooltip='Subtitles with timestamps'),
ft.TextButton('TXT', on_click=self.export_as_txt, tooltip='Subtitles only (no timestamps)'),
#ft.TextButton('CSV', on_click=self.export_csv, tooltip='Comma separated value'),
# I guess no one needs subtitles in CSV...
ft.TextButton('Cancel', on_click=self.close_export_dialog),
],
actions_alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
)
# Alert dialog that opens if subtitle was edited but not saved when Open SRT/TXT File button is clicked.
self.text_save_or_cancel_dialog = ft.AlertDialog(
modal=True,
title=ft.Text('Change not saved.'),
content=ft.Text('Do you want to discard the change?'),
actions=[
#ft.TextButton('Save', on_click=self.save_then_open, tooltip='Save then open another file.'),
ft.TextButton('Open without save', on_click=self.open_text_without_save, tooltip='Change will be lost.'),
ft.TextButton('Cancel', on_click=self.close_text_save_or_cancel_dialog),
]
)
# Audio position slider
self.audio_slider = ft.Slider(
min = 0,
value = int(self.position/10000),
label = "{value}ms",
on_change = self.slider_changed,
)
# Current playing position and duration of audio file
self.position_text = ft.Text(value='Current position')
self.duration_text = ft.Text(value='Duration (hh:mm:ss,nnn)')
# Rewinds 5 seconds
self.rewind_button = ft.ElevatedButton(
icon=ft.icons.REPLAY_5,
text="5 secs",
tooltip='Rewind 5 secs',
on_click=self.rewind_clicked,
disabled=True,
)
# Play/Pause button. After loading audio file, this button will always be focused (space/enter to play/pause).
self.play_button = ft.ElevatedButton(
icon=ft.icons.PLAY_ARROW,
text = "Play",
on_click=self.play_button_clicked,
disabled=True,
)
# 1.5x faster toggle switch
self.faster_sw = ft.Switch(
label='1.5x',
value=False,
on_change=self.playback_rate,
)
# Auto scroll toggle switch
self.sub_scroller_sw = ft.Switch(
label='Auto scroll',
value=True,
)
# Area to add subtitles as buttons
self.subs_view = ft.Column(
spacing = 5,
height= 400,
width = float("inf"),
scroll = ft.ScrollMode.ALWAYS,
auto_scroll=False,
)
# Notification bar control at the bottom
self.notification_bar=ft.SnackBar(
content=ft.Text('Speech + Subtitle Player'),
duration=2000,
bgcolor=ft.colors.BLUE_GREY_700,
)
# Called once audio file is loaded. Enable/disable buttons, create subtitles list, etc.
async def loaded(self, e):
self.audio_slider.max = int(await self.audio1.get_duration_async())
self.duration_text.value = f'{ms_to_hhmmssnnn(self.audio_slider.max)}'
self.audio_slider.divisions = self.audio_slider.max//60
# Enables buttons if associated text file exists.
if self.text_file != 'No Text File.':
# Call function to create the list of subtitles, self.subtitles.
self.subtitles = create_subtitles(self.text_file)
self.save_button.text = 'Save'
self.save_button.disabled=False
self.export_button.disabled=False
self.export_as_srt_button.disabled=False
self.export_as_txt_button.disabled=False
# Disable buttons if associated text file does not eixt.
else:
self.save_button.disabled=True
self.export_button.disabled=True
self.export_as_srt_button.disabled=True
self.export_as_txt_button.disabled=True
self.subtitles = []
self.speech_file_button.autofocus=False
self.speech_file_button.update()
self.play_button.disabled=False
self.play_button.focus()
self.play_button.autofocus=True
self.play_button.update()
self.rewind_button.disabled=False
self.text_file_button.disabled=False
self.subs_view.controls.clear()
# Create buttons of subtitles from the list self.subtitles.
if self.subtitles != []:
# .txt or .srt file
for i in range(len(self.subtitles)):
index = self.subtitles[i][0]
start_time = self.subtitles[i][1]
# .txt file (timestap is dummy, 55:55:55,555) disable buttons.
if self.subtitles[0][1]== 201355555:
self.sub_scroller_sw.value=False
self.sub_scroller_sw.disabled=True
self.export_dialog.actions[0].disabled=True
self.export_as_srt_button.disabled=True
# .srt file
else:
self.sub_scroller_sw.value=True
self.sub_scroller_sw.disabled=False
self.sub_scroller_sw.update()
end_time = self.subtitles[i][2]
text = self.subtitles[i][3]
# Create button instance of each subtitle. Include methods and controls for the instance to call or update.
sub = SubButton(index, start_time, end_time, text, self.sub_time_clicked, self.play_button,
self.save_button, self.subtitles)
# Add button to the subtitle button area, subs_view.
self.subs_view.controls.append(sub)
# Call snackbar to show a notification.
notification = f'Subtitle file loaded: {os.path.basename(self.text_file)}'
await self.open_notification_bar(notification)
# No text file found. Call snackbar to show an alert.
else:
notification = f'Subtitle file (.srt or .txt) not found.'
await self.open_notification_bar(notification, type='error')
print('Subtitle file not found.')
self.update()
on_position_changed にはプロパティがあり、メソッドで e として受け取っています。e.data には再生位置 (経過時間) がミリ秒で入っていますので、ここではスライダの位置を変更するためにその値をaudio_slider コントロールの value プロパティに代入しています。また、position_text コントロールの value に読みやすい形に変換した値を入れています。これはスライダの左端に表示されることになります。
if (self.sub_scroller_sw.value == True) and (self.text_file_name.value != 'No Text File.'):
self.scroll_to(self.audio_slider.value)
self.update()
ここで判定しているのは、字幕のオートスクロールのスイッチの状態と、字幕ファイルの有無です。字幕ファイルは、読み込めなかったときに “No Text File.” と表示させているので、表示内容自体をフラグとして利用しています。そして、それぞれが真の場合、字幕をスクロールするメソッドscroll_toに引数self.audio_slider.valueを渡しています。最後のself.update()で、ここのメソッド自身としては再生時間の更新がされます。
# Create button instance of each subtitle. Include methods and controls for the instance to call or update.
sub = SubButton(index, start_time, end_time, text, self.sub_time_clicked, self.play_button,
self.save_button, self.subtitles)
# When the timestamp is clicked, jump to its position and play if not playing.
async def sub_time_clicked(self, start_time):
self.audio1.seek(int(start_time))
if self.isPlaying == False:
await self.play_button_clicked(start_time)
self.update()
# Called once Open Speech File button is clicked to pause playback and check if changes saved.
async def pre_pick_speech_file(self, e):
if self.isPlaying == True:
await self.play_button_clicked(e)
if self.save_button.text == '*Save':
#print('Save is not done.')
await self.speech_save_or_cancel()
else:
await self.pick_speech_file()
# Opens a dialog if change is not saved.
async def speech_save_or_cancel(self):
self.page.dialog = self.speech_save_or_cancel_dialog
self.speech_save_or_cancel_dialog.open = True
self.page.update()
# Alert dialog that opens if subtitle was edited but not saved when Open Speech File button is clicked.
self.speech_save_or_cancel_dialog = ft.AlertDialog(
modal=True,
title=ft.Text('Change not saved.'),
content=ft.Text('Do you want to discard the change?'),
actions=[
#ft.TextButton('Save', on_click=self.save_then_open, tooltip='Save then open another file.'),
ft.TextButton('Open without save', on_click=self.open_speech_without_save, tooltip='Change will be lost.'),
ft.TextButton('Cancel', on_click=self.close_speech_save_or_cancel_dialog),
]
)
未保存の変更があるときに開くダイアログです。セーブせずにファイルを開く Open without save と、キャンセルしてダイアログを閉じる Cancel ボタンが配置されています。ここからセーブもできるようにしたかったのですがうまくいかず、Save ボタンはコメントで残っています。
# Saves as .txt file.
async def save_as_txt(self, save_file_name):
with open(save_file_name, 'w') as txt:
for i in self.subtitles:
for j in range(len(i)):
if j % 4 == 3:
txt.write('%s\n' % i[j])
notification = f'Subtitle saved as a TXT file: {os.path.basename(save_file_name)}'
await self.open_notification_bar(notification)
self.update()
# Exports current open SRT file as another SRT file.
async def export_as_srt(self, e):
if os.path.splitext(self.text_file)[1] == '.srt':
suggested_file_name = os.path.basename(self.text_file).split('.', 1)[0]+'_'+datetime.now().strftime("%Y%m%d%H%M")+'.srt'
self.export_as_srt_dialog.save_file(
dialog_title='Export as an SRT file',
allowed_extensions=['srt'],
file_name = suggested_file_name,
file_type=ft.FilePickerFileType.CUSTOM,
)
# Checks result of Export as SRT File Picker and passes absolute path to self.save_as_srt if exists.
async def export_as_srt_result(self, e: ft.FilePicker.result):
if e.path:
await self.save_as_srt(e.path)
TXT で書き出すメソッドのコードを開く
# Exports current open text file as a TXT file.
async def export_as_txt(self, e):
if os.path.exists(os.path.splitext(self.text_file)[0]+'.txt'):
suggested_file_name = os.path.basename(self.text_file).split('.', 1)[0]+'_'+datetime.now().strftime("%Y%m%d%H%M")+'.txt'
else:
suggested_file_name = os.path.basename(self.text_file).split('.', 1)[0]+'.txt'
self.export_as_txt_dialog.save_file(
dialog_title='Export as a TXT file',
allowed_extensions=['txt'],
file_name = suggested_file_name,
file_type=ft.FilePickerFileType.CUSTOM,
)
# Checks result of Export as TXT File Picker and passes absolute path to self.save_as_txt if exists.
async def export_as_txt_result(self, e: ft.FilePicker.result):
if e.path:
await self.save_as_txt(e.path)
# Notification bar control at the bottom
self.notification_bar=ft.SnackBar(
content=ft.Text('Speech + Subtitle Player'),
duration=2000,
bgcolor=ft.colors.BLUE_GREY_700,
)
# Opens notification bar with given text. If type is 'error', shows message longer with caution color.
async def open_notification_bar(self, notification, type='normal'):
if type == 'normal':
self.notification_bar.content=ft.Text(notification, color=ft.colors.LIGHT_BLUE_ACCENT_400)
self.notification_bar.bgcolor=ft.colors.BLUE_GREY_700
elif type == 'error':
self.notification_bar.content=ft.Text(notification, color=ft.colors.RED)
self.notification_bar.bgcolor=ft.colors.YELLOW
self.notification_bar.duration=4000
self.notification_bar.open=True
self.notification_bar.update()
# === BUILD METHOD ===
def build(self):
# Start time button
self.display_start_time = ft.TextButton(text=f"{ms_to_hhmmssnnn(int(self.start_time))}",
# Disable jump button if loaded text is TXT, no timestamp.
disabled=(self.start_time==201355555),
# When enabled, jump to the key when clicked.
key=self.index,
width=130,
on_click=self.jump_clicked,)
# Subtitle text button in display view. Click to edit.
self.display_text= ft.TextButton(text=f"{self.text}",
on_click=self.edit_clicked,
tooltip='Click to edit')
# Placeholder of subtitle text button in edit view.
self.edit_text = ft.TextField(expand=1)
# Put controls together. Left item is the key=index.
self.display_view = ft.Row(
alignment=ft.MainAxisAlignment.START,
controls=[
ft.Text(value=self.index, width=30),
self.display_start_time,
self.display_text,
]
)
# Change tool tip of start time button which is only clickable for SRT.
if self.start_time==201355555:
self.display_start_time.tooltip='Jump not available'
else:
self.display_start_time.tooltip='Click to jump here'
# Subtitle edit view visible when clicked.
self.edit_view = ft.Row(
visible=False,
#alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
#vertical_alignment=ft.CrossAxisAlignment.CENTER,
controls=[
self.edit_text,
ft.IconButton(
icon=ft.icons.DONE_OUTLINE_OUTLINED,
tooltip='Update Text',
on_click=self.save_clicked,
),
ft.IconButton(
icon=ft.icons.CANCEL_OUTLINED,
tooltip='Close wihout change',
on_click=self.cancel_clicked,
)
]
)
return ft.Column(controls=[self.display_view, self.edit_view])
Flet は割と簡単にモダンなデザインでアプリが作れるので、そこが大きな魅力です。GUI の構成要素となるボタンやテキストはもちろん、音声ファイルの処理やスライダー、通知やダイヤログなど、あらかた必要なものはそろっており、細かいところは気にせずに配置していくだけで、とりあえずアプリっぽいものが作れてしまいます。持っていないので想像でしかないのですが、質の良い 3D プリンタを手に入れたのと近い気がしています。3D モデルデータが実物になるように、Python のコードが触れるようになるという感覚です。
オフィシャルのドキュメントは充実しており、うまく理解できれば自分のアプリに利用できます。基本的に全ての機能はウェブブラウザで動く設計なので、実際に触って試せる Live example も多数用意されていて、いじりながら自分のアプリに使う部品を探す作業も楽しいです。このギャラリーに行けば、おそらくほとんどのコントロールや機能のサンプルを試し、GitHub で実際のコードの確認が行えるので、すごく助かります。
# Called when slider position is changed and scroll to subtitle with the nearest end_time.
async def scroll_to(self, e):
end_time = [item[2] for item in self.subtitles]
# Numpy is only used below:
#index = np.argmin(np.abs(np.array(end_time) - e))
# Below works without using Numpy:
index = min(range(len(end_time)), key=lambda i: abs(end_time[i]-e))
key=str(self.subtitles[index][0])
self.subs_view.scroll_to(key=key, duration =1000)
self.update()
% flet build macos --build-version "1.0.1" --template flet-build-template --include-packag
es flet_audio
Creating Flutter bootstrap project...OK
Customizing app icons and splash images...OK
Generating app icons...Because flet_audio <0.20.1 depends on flet ^0.20.0 and flet_audio >=0.20.1 <0.20.2 depends on flet ^0.20.1, flet_audio <0.20.2 requires flet ^0.20.0.
And because flet_audio ^0.20.2 depends on flet ^0.20.2 and flet_audio >=0.21.0 <0.21.1 depends on flet ^0.21.0, flet_audio <0.21.1 requires flet ^0.20.0 or
^0.21.0.
And because flet_audio >=0.21.1 <0.21.2 depends on flet ^0.21.1 and flet_audio >=0.21.2 depends on flet ^0.21.2, every version of flet_audio requires flet
^0.20.0 or >=0.21.0 <0.22.0.
So, because fletaudioplayback depends on both flet ^0.19.0 and flet_audio any, version solving failed.
You can try the following suggestion to make the pubspec resolve:
* Try upgrading your constraint on flet: dart pub add flet:^0.21.2
Error building Flet app - see the log of failed command above.
Include in Image: realistic, masterpiece, best quality, retro future, happy young guy partially gray hair with glasses jumping with big simle in front of a beautiful building
映像に字幕を埋め込みたい方が主な対象ユーザになると思います。また、Whisper 含め文字起こし AI の精度の検証を行うエンジニアや、コールセンタで通話内容をレポートにまとめるオペレータと言った方々には有用だと思います。他には、ミーティングの議事録を AI に出力させてから清書をするとか、外国語の学習にも便利に使ってもらえるでしょう (精度の違いを無視すれば、Whisper ではかなりの数の言語がサポートされています: Supported languages)。
アプリの実行方法
アプリアプリ言ってますが、現状ダブルクリックで開くアプリケーションとして書き出せないため、下記方法でコマンドラインからの実行が推奨です。無事アプリにビルドできたので、別記事にしました。テストおよびビルドは macOS のみで行っています。大きな違いは無いはずですが、Windows や Linux の方は、すみませんがよしなにお願いします。コードは GitHub に置いてあります。