From 8d240b378be9716e113e37c66eb8080282c277fe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Oct 2025 16:41:10 +0000 Subject: [PATCH 1/3] Initial plan From ae1e26cf36bce11e100a2130b8df9c742c69964c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Oct 2025 16:49:07 +0000 Subject: [PATCH 2/3] Separate voice generation and daemon logic into distinct modules Co-authored-by: Meatwo310 <72017364+Meatwo310@users.noreply.github.com> --- src/gen.rs | 140 +++++++++++++++++++++++++++++++ src/main.rs | 231 ++-------------------------------------------------- src/run.rs | 92 +++++++++++++++++++++ 3 files changed, 237 insertions(+), 226 deletions(-) create mode 100644 src/gen.rs create mode 100644 src/run.rs diff --git a/src/gen.rs b/src/gen.rs new file mode 100644 index 0000000..83674ab --- /dev/null +++ b/src/gen.rs @@ -0,0 +1,140 @@ +use crate::voicevox::VoicevoxClient; +use anyhow::{Context, Result}; +use indicatif::{ProgressBar, ProgressFinish, ProgressStyle}; +use semver::{Version, VersionReq}; +use std::collections::HashMap; +use std::io::{stdout, Cursor, Read, Write}; +use url::Url; +use zip::ZipArchive; + +pub fn validate_interval(interval: u8) -> Result<()> { + if interval == 0 || interval > 60 { + anyhow::bail!("intervalは1から60の間で指定してください。指定された値: {interval}"); + } + Ok(()) +} + +pub fn handle_gen(speaker_id: Option, url: String, interval: u8) -> Result<()> { + validate_interval(interval)?; + + let client = VoicevoxClient::new(Url::parse(&url) + .context("VOICEVOXサーバーのURLが不正です")? + ); + + let required = VersionReq::parse(">=0.24.0")?; + let current = Version::parse(&client.get_version()?)?; + + if required.matches(¤t) { + println!("VOICEVOX: {current}"); + } else { + println!( + "警告: VOICEVOX {current} は必要なバージョン {required} を満たしていません", + ); + } + + let speakers = client.list_speakers()?; + + if speaker_id.is_none() { + println!("\nスタイル一覧:"); + for speaker in speakers { + println!("\n{}:", speaker.name); + for style in speaker.styles { + println!("{:4}. {}", style.id, style.name); + } + } + return Ok(()); + } + + let speaker_id = speaker_id.context("ここでスタイルIDが提供されるべきです")?; + let (speaker, style) = client.find_speaker_and_style(speaker_id, &speakers)?; + + println!( + "{}. {} ({})", + style.id, + speaker.name, + style.name, + ); + + if !client.is_initialized_speaker(speaker_id)? { + print!("スタイルを初期化中... "); + stdout().flush()?; + client.initialize_speaker(speaker_id)?; + println!("完了"); + } + + generate_voice_files(&client, speaker_id, interval)?; + + Ok(()) +} + +fn create_progress_bar(length: u64, message: &str) -> Result { + let bar = ProgressBar::new(length) + .with_style( + ProgressStyle::with_template(&format!("{} [{{bar:24}}] {{pos:>2}}/{{len:>2}}", message))? + .progress_chars("#..") + ) + .with_finish(ProgressFinish::AndLeave); + bar.force_draw(); + Ok(bar) +} + +fn generate_voice_files(client: &VoicevoxClient, speaker_id: u32, interval: u8) -> Result<()> { + let mut queries: HashMap> = HashMap::new(); + + let minutes_per_hour: Vec = (0..60).step_by(interval as usize).collect(); // [0, 15, 30, 45] + let total_queries = (24 * minutes_per_hour.len()) as u64; + + let bar = create_progress_bar(total_queries, "クエリを生成")?; + + for hour in 0..24 { + let mut minute_queries: HashMap = HashMap::new(); + for &minute in &minutes_per_hour { + let hour_text = if hour == 0 { "零" } else { &hour.to_string() }; + let text = if minute == 0 { + format!("{hour_text}時です") + } else { + format!("{hour_text}時{minute}分です") + }; + let query = client.audio_query(&text, speaker_id)?; + minute_queries.insert(minute, query); + bar.inc(1); + } + queries.insert(hour, minute_queries); + } + + bar.finish(); + + std::fs::create_dir_all("voice_files")?; + + let bar = create_progress_bar(total_queries, "ボイスを生成")?; + + for hour in 0..24 { + let hour_queries = queries.get(&hour).unwrap(); + let query_vec: Vec = minutes_per_hour + .iter() + .map(|minute| hour_queries.get(minute).unwrap().clone()) + .collect(); + let zip_data = client.multi_synthesis(&query_vec, speaker_id)?; + + let cursor = Cursor::new(zip_data); + let mut archive = ZipArchive::new(cursor)?; + + for (i, &minute) in minutes_per_hour.iter().enumerate() { + if i < archive.len() { + let mut file = archive.by_index(i)?; + if file.is_file() { + let mut buffer = Vec::new(); + file.read_to_end(&mut buffer)?; + let output_path = format!("voice_files/{:02}-{:02}.wav", hour, minute); + std::fs::write(&output_path, buffer)?; + } + } + } + bar.inc(minutes_per_hour.len() as u64); + } + + bar.finish(); + println!("すべての音声ファイルが正常に生成・保存されました!"); + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index f91a213..1cf777e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,22 +1,10 @@ mod voicevox; mod platform; +mod gen; +mod run; -use crate::platform::run_tray; -use crate::voicevox::VoicevoxClient; -use anyhow::{Context, Result}; -use chrono::{Local, Timelike}; +use anyhow::Result; use clap::{Parser, Subcommand}; -use cron_tab::Cron; -use indicatif::{ProgressBar, ProgressFinish, ProgressStyle}; -use regex::Regex; -use semver::{Version, VersionReq}; -use std::collections::HashMap; -use std::fs::File; -use std::io::{stdout, Cursor, Read, Write}; -use std::path::Path; -use url::Url; -use user_idle::UserIdle; -use zip::ZipArchive; #[derive(Parser)] struct Cli { @@ -56,220 +44,11 @@ enum Commands { } } -fn validate_interval(interval: u8) -> Result<()> { - if interval == 0 || interval > 60 { - anyhow::bail!("intervalは1から60の間で指定してください。指定された値: {interval}"); - } - Ok(()) -} - -fn handle_gen(speaker_id: Option, url: String, interval: u8) -> Result<()> { - validate_interval(interval)?; - - let client = VoicevoxClient::new(Url::parse(&url) - .context("VOICEVOXサーバーのURLが不正です")? - ); - - let required = VersionReq::parse(">=0.24.0")?; - let current = Version::parse(&client.get_version()?)?; - - if required.matches(¤t) { - println!("VOICEVOX: {current}"); - } else { - println!( - "警告: VOICEVOX {current} は必要なバージョン {required} を満たしていません", - ); - } - - let speakers = client.list_speakers()?; - - if speaker_id.is_none() { - println!("\nスタイル一覧:"); - for speaker in speakers { - println!("\n{}:", speaker.name); - for style in speaker.styles { - println!("{:4}. {}", style.id, style.name); - } - } - return Ok(()); - } - - let speaker_id = speaker_id.context("ここでスタイルIDが提供されるべきです")?; - let (speaker, style) = client.find_speaker_and_style(speaker_id, &speakers)?; - - println!( - "{}. {} ({})", - style.id, - speaker.name, - style.name, - ); - - if !client.is_initialized_speaker(speaker_id)? { - print!("スタイルを初期化中... "); - stdout().flush()?; - client.initialize_speaker(speaker_id)?; - println!("完了"); - } - - generate_voice_files(&client, speaker_id, interval)?; - - Ok(()) -} - -fn create_progress_bar(length: u64, message: &str) -> Result { - let bar = ProgressBar::new(length) - .with_style( - ProgressStyle::with_template(&format!("{} [{{bar:24}}] {{pos:>2}}/{{len:>2}}", message))? - .progress_chars("#..") - ) - .with_finish(ProgressFinish::AndLeave); - bar.force_draw(); - Ok(bar) -} - -fn generate_voice_files(client: &VoicevoxClient, speaker_id: u32, interval: u8) -> Result<()> { - let mut queries: HashMap> = HashMap::new(); - - let minutes_per_hour: Vec = (0..60).step_by(interval as usize).collect(); // [0, 15, 30, 45] - let total_queries = (24 * minutes_per_hour.len()) as u64; - - let bar = create_progress_bar(total_queries, "クエリを生成")?; - - for hour in 0..24 { - let mut minute_queries: HashMap = HashMap::new(); - for &minute in &minutes_per_hour { - let hour_text = if hour == 0 { "零" } else { &hour.to_string() }; - let text = if minute == 0 { - format!("{hour_text}時です") - } else { - format!("{hour_text}時{minute}分です") - }; - let query = client.audio_query(&text, speaker_id)?; - minute_queries.insert(minute, query); - bar.inc(1); - } - queries.insert(hour, minute_queries); - } - - bar.finish(); - - std::fs::create_dir_all("voice_files")?; - - let bar = create_progress_bar(total_queries, "ボイスを生成")?; - - for hour in 0..24 { - let hour_queries = queries.get(&hour).unwrap(); - let query_vec: Vec = minutes_per_hour - .iter() - .map(|minute| hour_queries.get(minute).unwrap().clone()) - .collect(); - let zip_data = client.multi_synthesis(&query_vec, speaker_id)?; - - let cursor = Cursor::new(zip_data); - let mut archive = ZipArchive::new(cursor)?; - - for (i, &minute) in minutes_per_hour.iter().enumerate() { - if i < archive.len() { - let mut file = archive.by_index(i)?; - if file.is_file() { - let mut buffer = Vec::new(); - file.read_to_end(&mut buffer)?; - let output_path = format!("voice_files/{:02}-{:02}.wav", hour, minute); - std::fs::write(&output_path, buffer)?; - } - } - } - bar.inc(minutes_per_hour.len() as u64); - } - - bar.finish(); - println!("すべての音声ファイルが正常に生成・保存されました!"); - - Ok(()) -} - -fn check_voice_files(interval: u8) -> Result<()> { - let voice_files = Path::new("voice_files"); - let pattern = Regex::new(r"^([01]\d|2[0-3])-([0-5]\d)\.wav$")?; - - let expected_file_count = 24 * ((59 / interval + 1) as usize); - let file_count = match voice_files.read_dir() { - Ok(entries) => entries - .filter_map(|e| e.ok()) - .filter_map(|e| e.file_name().to_str().map(String::from)) - .filter(|name| pattern.is_match(name)) - .count(), - Err(_) => 0, - }; - - if !voice_files.exists() || !voice_files.is_dir() || file_count == 0 { - println!("警告: 音声ファイルが存在しません。genコマンドを実行して音声ファイルを生成してください。"); - } else if file_count < expected_file_count { - println!("警告: 音声ファイルが不足しています。genコマンドを実行してすべての音声ファイルを生成してください。"); - } - - Ok(()) -} - -fn get_idle_minutes() -> u64 { - UserIdle::get_time().map(|u| u.as_minutes()).unwrap_or(0) -} - -fn handle_run(interval: u8, idle_timeout: u64, cli: bool) -> Result<()> { - validate_interval(interval)?; - check_voice_files(interval)?; - - let mut cron = Cron::new(Local); - let cron_spec = format!("0 */{interval} * * * *"); - - // https://github.com/tuyentv96/rust-crontab?tab=readme-ov-file#-cron-expression-format - // ┌───────────── 秒 (0 - 59) - // │ ┌─────────── 分 (0 - 59) - // │ │ ┌───────── 時 (0 - 23) - // │ │ │ ┌─────── 日 (1 - 31) - // │ │ │ │ ┌───── 月 (1 - 12) - // │ │ │ │ │ ┌─── 曜日 (0 - 6) (日曜日から土曜日) - // │ │ │ │ │ │ ┌─ 年 (1970 - 3000) - // │ │ │ │ │ │ │ - // * * * * * * * - cron.add_fn(&cron_spec, move || { - let now = Local::now(); - let hour = now.hour(); - let minute = (now.minute() as u8) / interval * interval; - - if idle_timeout == 0 || get_idle_minutes() < idle_timeout { - println!("{:02}:{:02}です", hour, minute); - } else { - println!("{:02}:{:02} - スキップ", hour, minute); - return; - } - - let filename = format!("voice_files/{:02}-{:02}.wav", hour, minute); - let file = File::open(&filename) - .expect(&format!("ファイル {filename} を開けませんでした")); - - let mut handle = rodio::OutputStreamBuilder::open_default_stream().unwrap(); - handle.log_on_drop(false); - let sink = rodio::play(handle.mixer(), file).unwrap(); - sink.sleep_until_end(); - })?; - cron.start(); - println!("cronスケジューラを開始しました!"); - - if cli { - std::thread::park(); - Ok(()) - } else { - println!("システムトレイへ常駐します"); - run_tray() - } -} - fn main() -> Result<()> { let args = Cli::parse(); match args.command.unwrap_or(Commands::Run {interval: 15, idle_timeout: 10, cli: false}) { - Commands::Gen { speaker_id, url, interval } => handle_gen(speaker_id, url, interval)?, - Commands::Run { interval, idle_timeout, cli } => handle_run(interval, idle_timeout, cli)?, + Commands::Gen { speaker_id, url, interval } => gen::handle_gen(speaker_id, url, interval)?, + Commands::Run { interval, idle_timeout, cli } => run::handle_run(interval, idle_timeout, cli)?, } Ok(()) } diff --git a/src/run.rs b/src/run.rs new file mode 100644 index 0000000..ed655a6 --- /dev/null +++ b/src/run.rs @@ -0,0 +1,92 @@ +use crate::platform::run_tray; +use anyhow::Result; +use chrono::{Local, Timelike}; +use cron_tab::Cron; +use regex::Regex; +use std::fs::File; +use std::path::Path; +use user_idle::UserIdle; + +pub fn validate_interval(interval: u8) -> Result<()> { + if interval == 0 || interval > 60 { + anyhow::bail!("intervalは1から60の間で指定してください。指定された値: {interval}"); + } + Ok(()) +} + +fn check_voice_files(interval: u8) -> Result<()> { + let voice_files = Path::new("voice_files"); + let pattern = Regex::new(r"^([01]\d|2[0-3])-([0-5]\d)\.wav$")?; + + let expected_file_count = 24 * ((59 / interval + 1) as usize); + let file_count = match voice_files.read_dir() { + Ok(entries) => entries + .filter_map(|e| e.ok()) + .filter_map(|e| e.file_name().to_str().map(String::from)) + .filter(|name| pattern.is_match(name)) + .count(), + Err(_) => 0, + }; + + if !voice_files.exists() || !voice_files.is_dir() || file_count == 0 { + println!("警告: 音声ファイルが存在しません。genコマンドを実行して音声ファイルを生成してください。"); + } else if file_count < expected_file_count { + println!("警告: 音声ファイルが不足しています。genコマンドを実行してすべての音声ファイルを生成してください。"); + } + + Ok(()) +} + +fn get_idle_minutes() -> u64 { + UserIdle::get_time().map(|u| u.as_minutes()).unwrap_or(0) +} + +pub fn handle_run(interval: u8, idle_timeout: u64, cli: bool) -> Result<()> { + validate_interval(interval)?; + check_voice_files(interval)?; + + let mut cron = Cron::new(Local); + let cron_spec = format!("0 */{interval} * * * *"); + + // https://github.com/tuyentv96/rust-crontab?tab=readme-ov-file#-cron-expression-format + // ┌───────────── 秒 (0 - 59) + // │ ┌─────────── 分 (0 - 59) + // │ │ ┌───────── 時 (0 - 23) + // │ │ │ ┌─────── 日 (1 - 31) + // │ │ │ │ ┌───── 月 (1 - 12) + // │ │ │ │ │ ┌─── 曜日 (0 - 6) (日曜日から土曜日) + // │ │ │ │ │ │ ┌─ 年 (1970 - 3000) + // │ │ │ │ │ │ │ + // * * * * * * * + cron.add_fn(&cron_spec, move || { + let now = Local::now(); + let hour = now.hour(); + let minute = (now.minute() as u8) / interval * interval; + + if idle_timeout == 0 || get_idle_minutes() < idle_timeout { + println!("{:02}:{:02}です", hour, minute); + } else { + println!("{:02}:{:02} - スキップ", hour, minute); + return; + } + + let filename = format!("voice_files/{:02}-{:02}.wav", hour, minute); + let file = File::open(&filename) + .expect(&format!("ファイル {filename} を開けませんでした")); + + let mut handle = rodio::OutputStreamBuilder::open_default_stream().unwrap(); + handle.log_on_drop(false); + let sink = rodio::play(handle.mixer(), file).unwrap(); + sink.sleep_until_end(); + })?; + cron.start(); + println!("cronスケジューラを開始しました!"); + + if cli { + std::thread::park(); + Ok(()) + } else { + println!("システムトレイへ常駐します"); + run_tray() + } +} From a93e19d25b40a84593743606e66a4af09c50dff0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Oct 2025 16:49:58 +0000 Subject: [PATCH 3/3] Apply rustfmt formatting Co-authored-by: Meatwo310 <72017364+Meatwo310@users.noreply.github.com> --- src/gen.rs | 22 ++++++++-------------- src/main.rs | 30 +++++++++++++++++++++--------- src/platform/default.rs | 6 ++---- src/run.rs | 3 +-- src/voicevox.rs | 40 ++++++++++++++++++++++++++++------------ 5 files changed, 60 insertions(+), 41 deletions(-) diff --git a/src/gen.rs b/src/gen.rs index 83674ab..ff1f648 100644 --- a/src/gen.rs +++ b/src/gen.rs @@ -17,9 +17,7 @@ pub fn validate_interval(interval: u8) -> Result<()> { pub fn handle_gen(speaker_id: Option, url: String, interval: u8) -> Result<()> { validate_interval(interval)?; - let client = VoicevoxClient::new(Url::parse(&url) - .context("VOICEVOXサーバーのURLが不正です")? - ); + let client = VoicevoxClient::new(Url::parse(&url).context("VOICEVOXサーバーのURLが不正です")?); let required = VersionReq::parse(">=0.24.0")?; let current = Version::parse(&client.get_version()?)?; @@ -27,9 +25,7 @@ pub fn handle_gen(speaker_id: Option, url: String, interval: u8) -> Result< if required.matches(¤t) { println!("VOICEVOX: {current}"); } else { - println!( - "警告: VOICEVOX {current} は必要なバージョン {required} を満たしていません", - ); + println!("警告: VOICEVOX {current} は必要なバージョン {required} を満たしていません",); } let speakers = client.list_speakers()?; @@ -48,12 +44,7 @@ pub fn handle_gen(speaker_id: Option, url: String, interval: u8) -> Result< let speaker_id = speaker_id.context("ここでスタイルIDが提供されるべきです")?; let (speaker, style) = client.find_speaker_and_style(speaker_id, &speakers)?; - println!( - "{}. {} ({})", - style.id, - speaker.name, - style.name, - ); + println!("{}. {} ({})", style.id, speaker.name, style.name,); if !client.is_initialized_speaker(speaker_id)? { print!("スタイルを初期化中... "); @@ -70,8 +61,11 @@ pub fn handle_gen(speaker_id: Option, url: String, interval: u8) -> Result< fn create_progress_bar(length: u64, message: &str) -> Result { let bar = ProgressBar::new(length) .with_style( - ProgressStyle::with_template(&format!("{} [{{bar:24}}] {{pos:>2}}/{{len:>2}}", message))? - .progress_chars("#..") + ProgressStyle::with_template(&format!( + "{} [{{bar:24}}] {{pos:>2}}/{{len:>2}}", + message + ))? + .progress_chars("#.."), ) .with_finish(ProgressFinish::AndLeave); bar.force_draw(); diff --git a/src/main.rs b/src/main.rs index 1cf777e..9b60244 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,7 @@ -mod voicevox; -mod platform; mod gen; +mod platform; mod run; +mod voicevox; use anyhow::Result; use clap::{Parser, Subcommand}; @@ -9,7 +9,7 @@ use clap::{Parser, Subcommand}; #[derive(Parser)] struct Cli { #[command(subcommand)] - command: Option + command: Option, } #[derive(Subcommand)] @@ -35,20 +35,32 @@ enum Commands { interval: u8, /// 指定分以上操作がない場合、時報をスキップします。 - #[arg(short='t', long, default_value = "10")] + #[arg(short = 't', long, default_value = "10")] idle_timeout: u64, /// CLIモードで実行します。トレイアイコンは表示されません。 #[arg(long)] - cli: bool - } + cli: bool, + }, } fn main() -> Result<()> { let args = Cli::parse(); - match args.command.unwrap_or(Commands::Run {interval: 15, idle_timeout: 10, cli: false}) { - Commands::Gen { speaker_id, url, interval } => gen::handle_gen(speaker_id, url, interval)?, - Commands::Run { interval, idle_timeout, cli } => run::handle_run(interval, idle_timeout, cli)?, + match args.command.unwrap_or(Commands::Run { + interval: 15, + idle_timeout: 10, + cli: false, + }) { + Commands::Gen { + speaker_id, + url, + interval, + } => gen::handle_gen(speaker_id, url, interval)?, + Commands::Run { + interval, + idle_timeout, + cli, + } => run::handle_run(interval, idle_timeout, cli)?, } Ok(()) } diff --git a/src/platform/default.rs b/src/platform/default.rs index 32ec4a5..5abcbbe 100644 --- a/src/platform/default.rs +++ b/src/platform/default.rs @@ -8,10 +8,8 @@ enum Message { } pub fn run_tray() -> anyhow::Result<()> { - let mut tray = TrayItem::new( - "Time Signal", - get_icon_source()? - ).context("トレイアイコンの作成に失敗しました")?; + let mut tray = TrayItem::new("Time Signal", get_icon_source()?) + .context("トレイアイコンの作成に失敗しました")?; tray.add_label("Time Signal")?; tray.inner_mut().add_separator()?; diff --git a/src/run.rs b/src/run.rs index ed655a6..c07d6ca 100644 --- a/src/run.rs +++ b/src/run.rs @@ -71,8 +71,7 @@ pub fn handle_run(interval: u8, idle_timeout: u64, cli: bool) -> Result<()> { } let filename = format!("voice_files/{:02}-{:02}.wav", hour, minute); - let file = File::open(&filename) - .expect(&format!("ファイル {filename} を開けませんでした")); + let file = File::open(&filename).expect(&format!("ファイル {filename} を開けませんでした")); let mut handle = rodio::OutputStreamBuilder::open_default_stream().unwrap(); handle.log_on_drop(false); diff --git a/src/voicevox.rs b/src/voicevox.rs index 6d9bce1..86cc955 100644 --- a/src/voicevox.rs +++ b/src/voicevox.rs @@ -27,13 +27,20 @@ impl VoicevoxClient { } } - fn send_request(&self, request: reqwest::blocking::RequestBuilder, endpoint: &str) -> Result { - let response = request - .send() - .with_context(|| format!("VOICEVOXエンドポイントへのリクエストに失敗しました: {endpoint}"))?; + fn send_request( + &self, + request: reqwest::blocking::RequestBuilder, + endpoint: &str, + ) -> Result { + let response = request.send().with_context(|| { + format!("VOICEVOXエンドポイントへのリクエストに失敗しました: {endpoint}") + })?; if !response.status().is_success() { - bail!("VOICEVOXへのリクエストがステータス {} で失敗しました", response.status()); + bail!( + "VOICEVOXへのリクエストがステータス {} で失敗しました", + response.status() + ); } Ok(response) @@ -103,7 +110,8 @@ impl VoicevoxClient { .collect::, _>>() .context("音声クエリを解析できませんでした")?; - let request = self.client + let request = self + .client .post(req_url) .header("Content-Type", "application/json") .json(&queries_json); @@ -112,12 +120,20 @@ impl VoicevoxClient { Ok(response.bytes()?.to_vec()) } - pub fn find_speaker_and_style<'a>(&self, speaker_id: u32, speakers: &'a Vec) -> Result<(&'a Speaker, &'a Style)> { - speakers.iter() - .find_map(|speaker| speaker.styles.iter() - .find(|style| style.id == speaker_id) - .map(|style| (speaker, style)) - ) + pub fn find_speaker_and_style<'a>( + &self, + speaker_id: u32, + speakers: &'a Vec, + ) -> Result<(&'a Speaker, &'a Style)> { + speakers + .iter() + .find_map(|speaker| { + speaker + .styles + .iter() + .find(|style| style.id == speaker_id) + .map(|style| (speaker, style)) + }) .with_context(|| format!("スタイルID {speaker_id} が見つかりません")) } }